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}

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.