Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/purpleloop/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ <h1>Interactive architecture map</h1>
<tr><td>auditd execve.a0..aN</td><td>→ CommandLine</td></tr></table>`},
evaluator:{plane:"Engine plane",name:"Evaluator",tag:"Matches the rule — honestly.",
does:"Parses each Sigma rule's detection block and matches its field conditions against the normalized events. A verdict reflects the rule actually firing, not the mere presence of telemetry.",
fn:["Native Sigma matcher: equals / contains / startswith / endswith","List OR-semantics; and / or / not conditions","Returns the matching events as evidence","Produces one of five verdict states"],
fn:["Native Sigma matcher: equals / contains / startswith / endswith","List OR-semantics; and / or / not conditions","Returns the matching events as evidence","Produces one of four verdict states"],
in:"SigmaRule + events",out:"Verdict + evidence",impl:"internal/evaluator",
extra:`<h3>Verdict states</h3><div class="vstate"><span class="vchip">DETECTED</span><span class="vchip">PARTIAL</span><span class="vchip">MISSED</span><span class="vchip">NO_TELEMETRY</span><span class="vchip">INCONCLUSIVE</span></div>
extra:`<h3>Verdict states</h3><div class="vstate"><span class="vchip">DETECTED</span><span class="vchip">MISSED</span><span class="vchip">NO_TELEMETRY</span><span class="vchip">INCONCLUSIVE</span></div>
<p style="margin-top:10px">Separating a real gap (MISSED) from a collection failure (NO_TELEMETRY) and a broken pipeline (INCONCLUSIVE) is what makes the coverage number trustworthy.</p>`},
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.",
Expand Down
86 changes: 86 additions & 0 deletions internal/collector/timestamp_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 24 additions & 6 deletions internal/collector/wazuh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 47 additions & 3 deletions internal/evaluator/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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++
}
Expand All @@ -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
}
33 changes: 17 additions & 16 deletions internal/evaluator/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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 = <int> "of" identifier_list | "all of" identifier_list
//
// 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 = <int> "of" identifier_list | "all of" identifier_list
func parseCondition(s string) (Expr, error) {
s = strings.TrimSpace(s)
p := &condParser{s: s, pos: 0}
Expand Down
69 changes: 69 additions & 0 deletions internal/evaluator/them_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package evaluator

import "testing"

// TestThemExpansion verifies that "1 of them" and "all of them" conditions
// correctly expand to all search-identifiers defined in the rule.
func TestThemExpansion_OneOf(t *testing.T) {
// Rule: two selections, condition: 1 of them
// Means: at least one of {sel_a, sel_b} matches
rule := &Rule{
Detections: map[string]FieldMap{
"sel_a": {"Image": FieldEntry{Values: []string{"cmd.exe"}}},
"sel_b": {"CommandLine": FieldEntry{Values: []string{"whoami"}}},
},
Condition: OneOfExpr{N: 1, Names: []string{}}, // "1 of them" → empty Names
}

m := Matcher{}

// Event matching sel_a (cmd.exe) but NOT sel_b
evA := map[string]string{"Image": "cmd.exe", "CommandLine": "nope"}
if !m.Match(rule, evA) {
t.Error("1 of them: event matching sel_a should match (at least 1 of 2)")
}

// Event matching sel_b (whoami) but NOT sel_a
evB := map[string]string{"Image": "nope", "CommandLine": "whoami"}
if !m.Match(rule, evB) {
t.Error("1 of them: event matching sel_b should match (at least 1 of 2)")
}

// Event matching neither
evC := map[string]string{"Image": "nope", "CommandLine": "nope"}
if m.Match(rule, evC) {
t.Error("1 of them: event matching neither should NOT match")
}
}

func TestThemExpansion_AllOf(t *testing.T) {
// Rule: two selections, condition: all of them
// Means: both {sel_a, sel_b} must match
rule := &Rule{
Detections: map[string]FieldMap{
"sel_a": {"Image": FieldEntry{Values: []string{"cmd.exe"}}},
"sel_b": {"CommandLine": FieldEntry{Values: []string{"whoami"}}},
},
Condition: AllOfExpr{Names: []string{}}, // "all of them" → empty Names
}

m := Matcher{}

// Event matching both
evBoth := map[string]string{"Image": "cmd.exe", "CommandLine": "whoami"}
if !m.Match(rule, evBoth) {
t.Error("all of them: event matching both should match")
}

// Event matching only sel_a
evA := map[string]string{"Image": "cmd.exe", "CommandLine": "nope"}
if m.Match(rule, evA) {
t.Error("all of them: event matching only sel_a should NOT match")
}

// Event matching only sel_b
evB := map[string]string{"Image": "nope", "CommandLine": "whoami"}
if m.Match(rule, evB) {
t.Error("all of them: event matching only sel_b should NOT match")
}
}
Loading
Loading