diff --git a/DESIGN.md b/DESIGN.md index aaf0d45..d3664da 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -25,7 +25,7 @@ Priority feed → Select → Execute → Collect → Evaluate → Prove → Repo 3. **Execute** (offense) — run the atomic on the target host. Record command, host, timestamp 4. **Collect** (defense) — query the SIEM for events in the exact run window on that host 5. **Evaluate** (defense) — run the Sigma rule(s) against the collected events -6. **Prove** (verdict) — DETECTED / PARTIAL / MISSED + the evidence that supports it +6. **Prove** (verdict) — DETECTED / MISSED + the evidence that supports it 7. **Report** — coverage matrix, ATT&CK Navigator layer, JSON + HTML Red = offense (execute). Blue = defense (collect + evaluate). Purple = verdict (prove + report). @@ -164,7 +164,7 @@ Detection-as-code CI green. Cross-platform: Windows victim VM + Sysmon + Windows **Deliverable:** Green CI badge and cross-platform coverage. ### Phase 4 · Arbiter integration (v0.5) — *the headline* -Feed adapter consumes threat-intel-arbiter output. Campaigns run in priority order. Report headline: "top-20 exploited-in-the-wild: 14 detected, 3 partial, 3 missed." +Feed adapter consumes threat-intel-arbiter output. Campaigns run in priority order. Report headline: "top-20 exploited-in-the-wild: 14 detected, 3 missed." **Deliverable:** The risk-driven story, fully wired. Interview centrepiece. ### Phase 5 · Emulation & release (v1.0) diff --git a/cmd/purpleloop/main.go b/cmd/purpleloop/main.go index 9ce4d8f..a9c63fa 100644 --- a/cmd/purpleloop/main.go +++ b/cmd/purpleloop/main.go @@ -273,7 +273,7 @@ func runTechnique(ctx context.Context, exec model.Executor, coll model.Collector } ruleMatched := "" - if verdict == model.Detected || verdict == model.Partial { + if verdict == model.Detected { ruleMatched = rule.Path } diff --git a/docs/index.html b/docs/index.html index 29658ea..5afa29b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -237,9 +237,9 @@
Separating a real gap (MISSED) from a collection failure (NO_TELEMETRY) and a broken pipeline (INCONCLUSIVE) is what makes the coverage number trustworthy.
`}, verdict:{plane:"Output",name:"Verdict + Proof chain",tag:"The result, plus the trail that justifies it.", does:"The per-technique outcome bundled with everything needed to trust it — from the arbiter priority that selected it down to the raw log line that fired the rule.", diff --git a/internal/collector/timestamp_test.go b/internal/collector/timestamp_test.go new file mode 100644 index 0000000..867fc27 --- /dev/null +++ b/internal/collector/timestamp_test.go @@ -0,0 +1,86 @@ +package collector + +import ( + "testing" + "time" +) + +func TestParseTimestamp(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + want time.Time // expected truncated result (time.UTC) + }{ + { + name: "wazuh archives (ms fraction + numeric offset)", + input: "2026-07-04T10:37:28.086+0000", + wantErr: false, + want: time.Date(2026, 7, 4, 10, 37, 28, 86_000_000, time.UTC), + }, + { + name: "RFC3339 with Z suffix", + input: "2026-07-04T10:37:28Z", + wantErr: false, + want: time.Date(2026, 7, 4, 10, 37, 28, 0, time.UTC), + }, + { + name: "RFC3339Nano with Z suffix", + input: "2026-07-04T10:37:28.086Z", + wantErr: false, + want: time.Date(2026, 7, 4, 10, 37, 28, 86_000_000, time.UTC), + }, + { + name: "no fraction + numeric offset", + input: "2026-07-04T10:37:28+0000", + wantErr: false, + want: time.Date(2026, 7, 4, 10, 37, 28, 0, time.UTC), + }, + { + name: "RFC3339Nano with colon offset", + input: "2026-07-04T10:37:28.086+00:00", + wantErr: false, + want: time.Date(2026, 7, 4, 10, 37, 28, 86_000_000, time.FixedZone("", 0)), + }, + { + name: "garbage", + input: "not-a-timestamp", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTimestamp(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.Equal(tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseTimestamp_WindowMatch(t *testing.T) { + // Verify parsed timestamps land inside a reasonable window and are not zero. + ts := "2026-07-04T10:37:28.086+0000" + got, err := parseTimestamp(ts) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.IsZero() { + t.Error("timestamp is zero (would be dropped)") + } + windowStart := time.Date(2026, 7, 4, 10, 37, 0, 0, time.UTC) + windowEnd := time.Date(2026, 7, 4, 10, 38, 0, 0, time.UTC) + if got.Before(windowStart) || got.After(windowEnd) { + t.Errorf("timestamp %v outside expected window [%v, %v]", got, windowStart, windowEnd) + } +} diff --git a/internal/collector/wazuh.go b/internal/collector/wazuh.go index f36afa0..efa9194 100644 --- a/internal/collector/wazuh.go +++ b/internal/collector/wazuh.go @@ -15,6 +15,28 @@ import ( "github.com/jayelbotvibe-web/purple-loop/internal/model" ) +// timestampLayouts are tried in order when parsing Wazuh archive timestamps. +// Wazuh emits formats like "2026-07-04T10:37:28.086+0000" (ms + numeric offset +// without colon). Go's RFC3339Nano requires a colon in the offset, and the +// fallback layout without fractional seconds relies on lenient parsing. +// An explicit ordered list removes the fragility. +var timestampLayouts = []string{ + time.RFC3339Nano, // "2006-01-02T15:04:05.999999999Z07:00" + "2006-01-02T15:04:05.000-0700", // ms + numeric offset + "2006-01-02T15:04:05-0700", // no fraction + numeric offset +} + +// parseTimestamp tries each known layout and returns the first successful parse. +func parseTimestamp(s string) (time.Time, error) { + for _, layout := range timestampLayouts { + t, err := time.Parse(layout, s) + if err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("cannot parse timestamp %q", s) +} + // WazuhCollector reads agent alerts from the manager's alerts.json log. type WazuhCollector struct { BaseURL string // e.g. https://localhost:55000 — empty => dry mode @@ -55,13 +77,9 @@ func (c *WazuhCollector) Query(ctx context.Context, w model.TimeWindow, host str if err := json.Unmarshal([]byte(line), &alert); err != nil { continue } - ts, err := time.Parse(time.RFC3339Nano, alert.Timestamp) + ts, err := parseTimestamp(alert.Timestamp) if err != nil { - // try without nanos - ts, err = time.Parse("2006-01-02T15:04:05-0700", alert.Timestamp) - if err != nil { - continue - } + continue } if ts.Before(w.Start) || ts.After(w.End) { continue diff --git a/internal/evaluator/matcher.go b/internal/evaluator/matcher.go index 1f3da98..99b6fb8 100644 --- a/internal/evaluator/matcher.go +++ b/internal/evaluator/matcher.go @@ -80,6 +80,12 @@ func matchField(eventValue string, entry FieldEntry) bool { // Strip leading slash/drive for path matching v := strings.ToLower(eventValue) c := strings.ToLower(candidate) + + // If candidate contains * wildcards, use glob-like matching + if strings.Contains(c, "*") { + return matchWildcard(v, c) + } + switch { case hasEndsWith: return strings.HasSuffix(v, c) @@ -109,9 +115,40 @@ func matchField(eventValue string, entry FieldEntry) bool { return false } +// matchWildcard performs case-insensitive glob matching where * matches any +// sequence of characters. It splits the pattern on * and matches each literal +// segment in order within the value string. +func matchWildcard(value, pattern string) bool { + // A single "*" matches everything + if pattern == "*" { + return true + } + + segments := strings.Split(pattern, "*") + pos := 0 + for _, seg := range segments { + if seg == "" { + continue // leading *, trailing *, or consecutive ** + } + idx := strings.Index(value[pos:], seg) + if idx < 0 { + return false + } + pos += idx + len(seg) + } + return true +} + func evalOneOf(e OneOfExpr, detections map[string]FieldMap, event map[string]string) bool { + names := e.Names + if len(names) == 0 { + // "1 of them" — expand to all search-identifiers + for k := range detections { + names = append(names, k) + } + } matched := 0 - for _, name := range e.Names { + for _, name := range names { if evalIdent(name, detections, event) { matched++ } @@ -120,10 +157,17 @@ func evalOneOf(e OneOfExpr, detections map[string]FieldMap, event map[string]str } func evalAllOf(e AllOfExpr, detections map[string]FieldMap, event map[string]string) bool { - for _, name := range e.Names { + names := e.Names + if len(names) == 0 { + // "all of them" — expand to all search-identifiers + for k := range detections { + names = append(names, k) + } + } + for _, name := range names { if !evalIdent(name, detections, event) { return false } } - return true + return len(names) > 0 // vacuous truth over empty set → false } diff --git a/internal/evaluator/rule.go b/internal/evaluator/rule.go index ace4441..e201cd1 100644 --- a/internal/evaluator/rule.go +++ b/internal/evaluator/rule.go @@ -11,10 +11,10 @@ import ( // Rule represents a parsed Sigma rule's detection block. type Rule struct { - Path string - Title string - Detections map[string]FieldMap // search-identifier → field conditions - Condition Expr // parsed condition tree + Path string + Title string + Detections map[string]FieldMap // search-identifier → field conditions + Condition Expr // parsed condition tree } // FieldMap is a search-identifier's field→value mapping with modifiers. @@ -50,12 +50,12 @@ type OneOfExpr struct { // AllOfExpr matches when all given identifiers match. type AllOfExpr struct{ Names []string } -func (IdentExpr) isExpr() {} -func (AndExpr) isExpr() {} -func (OrExpr) isExpr() {} -func (NotExpr) isExpr() {} -func (OneOfExpr) isExpr() {} -func (AllOfExpr) isExpr() {} +func (IdentExpr) isExpr() {} +func (AndExpr) isExpr() {} +func (OrExpr) isExpr() {} +func (NotExpr) isExpr() {} +func (OneOfExpr) isExpr() {} +func (AllOfExpr) isExpr() {} // RuleParser loads a Sigma rule from a YAML file. type RuleParser struct{} @@ -131,12 +131,13 @@ func parseFieldMap(val any) (FieldMap, error) { // parseCondition parses a Sigma condition string into an expression tree. // Grammar (recursive descent, no external parser lib): -// expr = or_expr -// or_expr = and_expr ("or" and_expr)* -// and_expr = not_expr ("and" not_expr)* -// not_expr = "not" not_expr | primary -// primary = identifier | "(" expr ")" | aggregates -// aggregates =` + html.EscapeString(run.StartedAt.Format("2006-01-02 15:04:05 UTC")) + `