Threat Intelligence scripts
Threat Intelligence
The threat intelligence scripts and packages I've written, with their source shown directly below — no need to open GitHub to read them.
CloudTrail suspicious-activity analyzer
GoRead-onlyGo test suite (9 cases)
analyze.go Go · 276 lines
threat-intel/scripts/cloudtrail/analyze.go
1// Package cloudtrail is a bounded educational analyzer for AWS CloudTrail
2// event fixtures. It is not a production detection product and does not
3// claim AWS-equivalent evaluation.
4package cloudtrail
5
6import (
7 "encoding/json"
8 "fmt"
9 "sort"
10 "strings"
11 "time"
12)
13
14// Event is a subset of a CloudTrail management event used by this teaching analyzer.
15type Event struct {
16 EventTime time.Time `json:"-"`
17 EventTimeRaw string `json:"eventTime"`
18 EventName string `json:"eventName"`
19 EventSource string `json:"eventSource"`
20 AWSRegion string `json:"awsRegion"`
21 SourceIPAddress string `json:"sourceIPAddress"`
22 ErrorCode string `json:"errorCode,omitempty"`
23 ErrorMessage string `json:"errorMessage,omitempty"`
24 UserIdentity UserIdentity `json:"userIdentity"`
25 ResponseElements map[string]any `json:"responseElements"`
26 AdditionalEventData map[string]any `json:"additionalEventData"`
27}
28
29// UserIdentity identifies the caller.
30type UserIdentity struct {
31 Type string `json:"type"`
32 PrincipalID string `json:"principalId"`
33 ARN string `json:"arn"`
34 AccountID string `json:"accountId"`
35}
36
37// Finding is a high-signal observation that requires investigation.
38type Finding struct {
39 Type string
40 Description string
41 ActorARN string
42 SourceIP string
43 EventName string
44 Time string
45}
46
47// Options configures sequence and threshold detection.
48type Options struct {
49 PrivilegeEscalationWindow time.Duration
50 KMSDecryptDenyThreshold int
51}
52
53// DefaultOptions returns conservative teaching defaults.
54func DefaultOptions() Options {
55 return Options{
56 PrivilegeEscalationWindow: 15 * time.Minute,
57 KMSDecryptDenyThreshold: 3,
58 }
59}
60
61// ParseEvents unmarshals CloudTrail JSON and rejects malformed timestamps.
62func ParseEvents(raw []byte) ([]Event, error) {
63 var events []Event
64 if err := json.Unmarshal(raw, &events); err != nil {
65 return nil, fmt.Errorf("decode cloudtrail events: %w", err)
66 }
67 for i := range events {
68 if strings.TrimSpace(events[i].EventTimeRaw) == "" {
69 return nil, fmt.Errorf("event %d: missing eventTime", i)
70 }
71 parsed, err := time.Parse(time.RFC3339, events[i].EventTimeRaw)
72 if err != nil {
73 return nil, fmt.Errorf("event %d: malformed eventTime %q: %w", i, events[i].EventTimeRaw, err)
74 }
75 events[i].EventTime = parsed
76 if events[i].ResponseElements == nil {
77 events[i].ResponseElements = map[string]any{}
78 }
79 if events[i].AdditionalEventData == nil {
80 events[i].AdditionalEventData = map[string]any{}
81 }
82 }
83 return events, nil
84}
85
86// Analyze returns findings for the supplied events.
87func Analyze(events []Event, opts Options) []Finding {
88 if opts.PrivilegeEscalationWindow <= 0 {
89 opts.PrivilegeEscalationWindow = DefaultOptions().PrivilegeEscalationWindow
90 }
91 if opts.KMSDecryptDenyThreshold <= 0 {
92 opts.KMSDecryptDenyThreshold = DefaultOptions().KMSDecryptDenyThreshold
93 }
94
95 var findings []Finding
96 findings = append(findings, analyzeConsoleLogins(events)...)
97 findings = append(findings, analyzePrivilegeChains(events, opts.PrivilegeEscalationWindow)...)
98 findings = append(findings, analyzeKMSDecryptDenies(events, opts.KMSDecryptDenyThreshold)...)
99 findings = append(findings, analyzeLogTampering(events)...)
100 return findings
101}
102
103func actorARN(event Event) string {
104 return strings.TrimSpace(event.UserIdentity.ARN)
105}
106
107func analyzeConsoleLogins(events []Event) []Finding {
108 var findings []Finding
109 for _, event := range events {
110 if event.EventName != "ConsoleLogin" {
111 continue
112 }
113 actor := actorARN(event)
114 if actor == "" {
115 findings = append(findings, Finding{
116 Type: "malformed-actor",
117 Description: "ConsoleLogin event is missing a usable userIdentity.arn",
118 EventName: event.EventName,
119 SourceIP: event.SourceIPAddress,
120 Time: event.EventTimeRaw,
121 })
122 continue
123 }
124
125 loginResult, ok := event.ResponseElements["ConsoleLogin"].(string)
126 if !ok || loginResult != "Success" {
127 // Failed or incomplete login is not a successful non-MFA login.
128 continue
129 }
130
131 mfaRaw, hasMFA := event.AdditionalEventData["MFAUsed"]
132 mfa, mfaIsString := mfaRaw.(string)
133 switch {
134 case !hasMFA || !mfaIsString:
135 findings = append(findings, Finding{
136 Type: "console-login-mfa-metadata-missing",
137 Description: "Successful ConsoleLogin lacks explicit MFAUsed metadata; investigate session assurance separately",
138 ActorARN: actor,
139 SourceIP: event.SourceIPAddress,
140 EventName: event.EventName,
141 Time: event.EventTimeRaw,
142 })
143 case strings.EqualFold(mfa, "No") || strings.EqualFold(mfa, "false"):
144 findings = append(findings, Finding{
145 Type: "console-login-without-mfa",
146 Description: "Successful ConsoleLogin reported MFAUsed=No",
147 ActorARN: actor,
148 SourceIP: event.SourceIPAddress,
149 EventName: event.EventName,
150 Time: event.EventTimeRaw,
151 })
152 }
153 }
154 return findings
155}
156
157func analyzePrivilegeChains(events []Event, window time.Duration) []Finding {
158 byActor := map[string][]Event{}
159 for _, event := range events {
160 actor := actorARN(event)
161 if actor == "" {
162 continue
163 }
164 byActor[actor] = append(byActor[actor], event)
165 }
166
167 var findings []Finding
168 for actor, actorEvents := range byActor {
169 sorted := append([]Event(nil), actorEvents...)
170 sort.SliceStable(sorted, func(i, j int) bool {
171 return sorted[i].EventTime.Before(sorted[j].EventTime)
172 })
173
174 for i := 0; i < len(sorted); i++ {
175 if sorted[i].EventName != "CreateUser" {
176 continue
177 }
178 for j := i + 1; j < len(sorted); j++ {
179 diff := sorted[j].EventTime.Sub(sorted[i].EventTime)
180 if diff < 0 {
181 // Chronological sort makes this unreachable; guard anyway.
182 continue
183 }
184 if diff > window {
185 break
186 }
187 if sorted[j].EventName == "AttachUserPolicy" || sorted[j].EventName == "PutUserPolicy" {
188 findings = append(findings, Finding{
189 Type: "privilege-escalation-sequence",
190 Description: fmt.Sprintf(
191 "CreateUser followed by %s within %s for the same actor; investigate for unauthorized privilege growth",
192 sorted[j].EventName,
193 window,
194 ),
195 ActorARN: actor,
196 SourceIP: sorted[j].SourceIPAddress,
197 EventName: sorted[j].EventName,
198 Time: sorted[j].EventTimeRaw,
199 })
200 break
201 }
202 }
203 }
204 }
205 return findings
206}
207
208func analyzeKMSDecryptDenies(events []Event, threshold int) []Finding {
209 counts := map[string][]Event{}
210 for _, event := range events {
211 if event.EventSource != "kms.amazonaws.com" || event.EventName != "Decrypt" {
212 continue
213 }
214 if event.ErrorCode != "AccessDenied" && event.ErrorCode != "AccessDeniedException" {
215 continue
216 }
217 actor := actorARN(event)
218 if actor == "" {
219 continue
220 }
221 counts[actor] = append(counts[actor], event)
222 }
223
224 var findings []Finding
225 for actor, denied := range counts {
226 if len(denied) < threshold {
227 // A single denied decrypt is not "multiple attempts".
228 continue
229 }
230 last := denied[len(denied)-1]
231 findings = append(findings, Finding{
232 Type: "kms-decrypt-denied-threshold",
233 Description: fmt.Sprintf(
234 "%d AccessDenied Decrypt attempts for the same actor met the configured threshold of %d",
235 len(denied),
236 threshold,
237 ),
238 ActorARN: actor,
239 SourceIP: last.SourceIPAddress,
240 EventName: "Decrypt",
241 Time: last.EventTimeRaw,
242 })
243 }
244 return findings
245}
246
247func analyzeLogTampering(events []Event) []Finding {
248 interesting := map[string]struct{}{
249 "StopLogging": {},
250 "DeleteTrail": {},
251 "UpdateTrail": {},
252 "PutEventSelectors": {},
253 "DeleteEventDataStore": {},
254 "StopEventDataStoreIngestion": {},
255 }
256
257 var findings []Finding
258 for _, event := range events {
259 if _, ok := interesting[event.EventName]; !ok {
260 continue
261 }
262 actor := actorARN(event)
263 findings = append(findings, Finding{
264 Type: "cloudtrail-admin-event",
265 Description: fmt.Sprintf(
266 "High-signal CloudTrail administrative event %s observed; investigate intent and change authorization. This analyzer does not assert the event is malicious by itself.",
267 event.EventName,
268 ),
269 ActorARN: actor,
270 SourceIP: event.SourceIPAddress,
271 EventName: event.EventName,
272 Time: event.EventTimeRaw,
273 })
274 }
275 return findings
276}
1package cloudtrail_test
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 cloudtrail "github.com/jasonachkar/cybersecurity-writeups/threat-intel/scripts/cloudtrail"
9)
10
11func findingTypes(findings []cloudtrail.Finding) []string {
12 out := make([]string, 0, len(findings))
13 for _, finding := range findings {
14 out = append(out, finding.Type)
15 }
16 return out
17}
18
19func hasType(findings []cloudtrail.Finding, want string) bool {
20 for _, finding := range findings {
21 if finding.Type == want {
22 return true
23 }
24 }
25 return false
26}
27
28func TestParseEventsRejectsMalformedTimestamp(t *testing.T) {
29 _, err := cloudtrail.ParseEvents([]byte(`[
30 {"eventTime":"not-a-time","eventName":"ConsoleLogin","userIdentity":{"arn":"arn:aws:iam::111:user/a"}}
31 ]`))
32 if err == nil {
33 t.Fatal("expected malformed eventTime to fail")
34 }
35}
36
37func TestAnalyzeConsoleLoginCases(t *testing.T) {
38 tests := []struct {
39 name string
40 raw string
41 want string
42 notWant string
43 }{
44 {
45 name: "successful login with MFA",
46 raw: `[
47 {"eventTime":"2026-07-01T12:00:00Z","eventName":"ConsoleLogin","sourceIPAddress":"203.0.113.10",
48 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"},
49 "responseElements":{"ConsoleLogin":"Success"},
50 "additionalEventData":{"MFAUsed":"Yes"}}
51 ]`,
52 notWant: "console-login-without-mfa",
53 },
54 {
55 name: "successful login without MFA",
56 raw: `[
57 {"eventTime":"2026-07-01T12:00:00Z","eventName":"ConsoleLogin","sourceIPAddress":"203.0.113.10",
58 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"},
59 "responseElements":{"ConsoleLogin":"Success"},
60 "additionalEventData":{"MFAUsed":"No"}}
61 ]`,
62 want: "console-login-without-mfa",
63 },
64 {
65 name: "failed login without MFA metadata is not treated as success",
66 raw: `[
67 {"eventTime":"2026-07-01T12:00:00Z","eventName":"ConsoleLogin","sourceIPAddress":"203.0.113.10",
68 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"},
69 "responseElements":{"ConsoleLogin":"Failure"},
70 "errorCode":"FailedAuthentication"}
71 ]`,
72 notWant: "console-login-without-mfa",
73 },
74 {
75 name: "missing MFA metadata on success",
76 raw: `[
77 {"eventTime":"2026-07-01T12:00:00Z","eventName":"ConsoleLogin","sourceIPAddress":"203.0.113.10",
78 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"},
79 "responseElements":{"ConsoleLogin":"Success"},
80 "additionalEventData":{}}
81 ]`,
82 want: "console-login-mfa-metadata-missing",
83 },
84 {
85 name: "malformed additional data type",
86 raw: `[
87 {"eventTime":"2026-07-01T12:00:00Z","eventName":"ConsoleLogin","sourceIPAddress":"203.0.113.10",
88 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"},
89 "responseElements":{"ConsoleLogin":"Success"},
90 "additionalEventData":{"MFAUsed":true}}
91 ]`,
92 want: "console-login-mfa-metadata-missing",
93 },
94 {
95 name: "empty ARN",
96 raw: `[
97 {"eventTime":"2026-07-01T12:00:00Z","eventName":"ConsoleLogin",
98 "userIdentity":{"arn":""},
99 "responseElements":{"ConsoleLogin":"Success"},
100 "additionalEventData":{"MFAUsed":"No"}}
101 ]`,
102 want: "malformed-actor",
103 },
104 }
105
106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108 events, err := cloudtrail.ParseEvents([]byte(tt.raw))
109 if err != nil {
110 t.Fatalf("ParseEvents: %v", err)
111 }
112 findings := cloudtrail.Analyze(events, cloudtrail.DefaultOptions())
113 if tt.want != "" && !hasType(findings, tt.want) {
114 t.Fatalf("missing %s in %v", tt.want, findingTypes(findings))
115 }
116 if tt.notWant != "" && hasType(findings, tt.notWant) {
117 t.Fatalf("unexpected %s in %v", tt.notWant, findingTypes(findings))
118 }
119 })
120 }
121}
122
123func TestPrivilegeEscalationSequence(t *testing.T) {
124 raw := `[
125 {"eventTime":"2026-07-01T12:05:00Z","eventName":"AttachUserPolicy","sourceIPAddress":"203.0.113.10",
126 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}},
127 {"eventTime":"2026-07-01T12:00:00Z","eventName":"CreateUser","sourceIPAddress":"203.0.113.10",
128 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}}
129 ]`
130 events, err := cloudtrail.ParseEvents([]byte(raw))
131 if err != nil {
132 t.Fatal(err)
133 }
134 findings := cloudtrail.Analyze(events, cloudtrail.Options{
135 PrivilegeEscalationWindow: 15 * time.Minute,
136 KMSDecryptDenyThreshold: 3,
137 })
138 if !hasType(findings, "privilege-escalation-sequence") {
139 t.Fatalf("expected sorted CreateUser→AttachUserPolicy chain, got %v", findingTypes(findings))
140 }
141}
142
143func TestPrivilegeEscalationOutsideWindow(t *testing.T) {
144 raw := `[
145 {"eventTime":"2026-07-01T12:00:00Z","eventName":"CreateUser",
146 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}},
147 {"eventTime":"2026-07-01T13:00:00Z","eventName":"AttachUserPolicy",
148 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}}
149 ]`
150 events, err := cloudtrail.ParseEvents([]byte(raw))
151 if err != nil {
152 t.Fatal(err)
153 }
154 findings := cloudtrail.Analyze(events, cloudtrail.Options{
155 PrivilegeEscalationWindow: 15 * time.Minute,
156 KMSDecryptDenyThreshold: 3,
157 })
158 if hasType(findings, "privilege-escalation-sequence") {
159 t.Fatal("chain outside window should not match")
160 }
161}
162
163func TestNegativeTimeDifferencePrevention(t *testing.T) {
164 // Same actor, Attach before Create chronologically after sort should not
165 // create a reverse chain.
166 raw := `[
167 {"eventTime":"2026-07-01T12:00:00Z","eventName":"AttachUserPolicy",
168 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}},
169 {"eventTime":"2026-07-01T12:10:00Z","eventName":"CreateUser",
170 "userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}}
171 ]`
172 events, err := cloudtrail.ParseEvents([]byte(raw))
173 if err != nil {
174 t.Fatal(err)
175 }
176 findings := cloudtrail.Analyze(events, cloudtrail.DefaultOptions())
177 if hasType(findings, "privilege-escalation-sequence") {
178 t.Fatal("reverse chronological Attach→Create must not correlate")
179 }
180}
181
182func TestKMSDecryptThresholdAndSingleAttempt(t *testing.T) {
183 single := `[
184 {"eventTime":"2026-07-01T12:00:00Z","eventName":"Decrypt","eventSource":"kms.amazonaws.com",
185 "errorCode":"AccessDenied","userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}}
186 ]`
187 events, err := cloudtrail.ParseEvents([]byte(single))
188 if err != nil {
189 t.Fatal(err)
190 }
191 findings := cloudtrail.Analyze(events, cloudtrail.DefaultOptions())
192 if hasType(findings, "kms-decrypt-denied-threshold") {
193 t.Fatal("single AccessDenied decrypt must not claim multiple attempts")
194 }
195
196 repeated := `[
197 {"eventTime":"2026-07-01T12:00:00Z","eventName":"Decrypt","eventSource":"kms.amazonaws.com","errorCode":"AccessDenied","userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}},
198 {"eventTime":"2026-07-01T12:01:00Z","eventName":"Decrypt","eventSource":"kms.amazonaws.com","errorCode":"AccessDenied","userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}},
199 {"eventTime":"2026-07-01T12:02:00Z","eventName":"Decrypt","eventSource":"kms.amazonaws.com","errorCode":"AccessDenied","userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}}
200 ]`
201 events, err = cloudtrail.ParseEvents([]byte(repeated))
202 if err != nil {
203 t.Fatal(err)
204 }
205 findings = cloudtrail.Analyze(events, cloudtrail.DefaultOptions())
206 if !hasType(findings, "kms-decrypt-denied-threshold") {
207 t.Fatal("expected thresholded decrypt findings")
208 }
209}
210
211func TestActorSeparation(t *testing.T) {
212 raw := `[
213 {"eventTime":"2026-07-01T12:00:00Z","eventName":"CreateUser","userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}},
214 {"eventTime":"2026-07-01T12:01:00Z","eventName":"AttachUserPolicy","userIdentity":{"arn":"arn:aws:iam::111122223333:user/bob"}}
215 ]`
216 events, err := cloudtrail.ParseEvents([]byte(raw))
217 if err != nil {
218 t.Fatal(err)
219 }
220 findings := cloudtrail.Analyze(events, cloudtrail.DefaultOptions())
221 if hasType(findings, "privilege-escalation-sequence") {
222 t.Fatal("different actors must not form a chain")
223 }
224}
225
226func TestLogTamperingClassification(t *testing.T) {
227 raw := `[
228 {"eventTime":"2026-07-01T12:00:00Z","eventName":"UpdateTrail","userIdentity":{"arn":"arn:aws:iam::111122223333:user/alice"}}
229 ]`
230 events, err := cloudtrail.ParseEvents([]byte(raw))
231 if err != nil {
232 t.Fatal(err)
233 }
234 findings := cloudtrail.Analyze(events, cloudtrail.DefaultOptions())
235 if !hasType(findings, "cloudtrail-admin-event") {
236 t.Fatal("expected high-signal admin classification")
237 }
238 for _, finding := range findings {
239 if finding.Type == "cloudtrail-admin-event" && finding.EventName != "UpdateTrail" {
240 t.Fatalf("event name not preserved: %s", finding.EventName)
241 }
242 if finding.Type == "cloudtrail-admin-event" && !strings.Contains(finding.Description, "does not assert the event is malicious") {
243 t.Fatalf("description overclaims malice: %s", finding.Description)
244 }
245 }
246}
What it does
Finds selected suspicious patterns in exported AWS CloudTrail events for human review.
Why it exists
The incident research benefits from a repeatable first-pass analysis of high-volume event data.
Permissions and safety
None. It accepts exported data and does not call AWS APIs.
Usage
go test ./threat-intel/scripts/cloudtrail/...
What was tested
Nine Go cases cover each supported analysis and quiet events that should not produce findings.
Limitations
- Findings require human correlation and do not establish compromise.
- The implementation has not been run against a live account trail.