Skip to content

Commit 7ffa294

Browse files
DavidJBiancoclaude
andcommitted
Add temporal, cross-source, and noise realism dimensions to eval
Phase 4.3-4.5: Complete all 5 scoring dimensions (23 sub-scores total): - Dimension 4: Temporal Realism (work hours, burstiness, causal ordering, timing) - Dimension 2: Cross-Source Coherence (source correctness, trace coverage, field agreement) - Dimension 3: Background Noise Realism (volume, diversity, plausibility, anomaly rate) - Visibility model for OS-to-format mapping + network sensor reuse - Anomaly detector for organic anomaly rate scoring - Causal pair rule definitions (logon→process, logon→logoff, SSH→bash) - 41 new tests (653 total passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aa8de5d commit 7ffa294

11 files changed

Lines changed: 2330 additions & 89 deletions

File tree

TODO.md

Lines changed: 71 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# EvidenceForge Implementation Plan
22

3-
**Status:** Phase 3 - MVP Release ✅ COMPLETE. Phase 4 planned.
3+
**Status:** Phase 4 - Data Quality Evaluation ✅ SCORING COMPLETE (5/5 dimensions). LLM spot-check deferred.
44
**Started:** 2026-03-11
5-
**Last Updated:** 2026-03-16 (Phase 4 Data Quality Evaluation planned, PRD at docs/data-quality-prd.md)
5+
**Last Updated:** 2026-03-16 (Phase 4 eval framework complete: 5 dimensions, 23 sub-scores, 653+ tests)
66
**Target MVP Completion:** 7-10 weeks from start
77

88
**Recent Completions:**
@@ -419,98 +419,81 @@
419419

420420
**Scoring model:** 5 dimensions roll up to an overall 0-100 score. Each dimension has weighted sub-scores. Acceptance criteria are a separate pass/fail layer on top of scores.
421421

422-
### 4.1 Report Framework & CLI Command
423-
424-
- [ ] Create `src/evidenceforge/evaluation/` package structure
425-
- [ ] `evaluation/engine.py` — Orchestrator: runs all dimensions, collects scores, applies acceptance criteria
426-
- [ ] `evaluation/report.py` — Report formatting (Rich text + JSON output)
427-
- [ ] Add `eforge eval` CLI command to `cli/commands.py`
428-
- [ ] Args: `<output_directory> --scenario <scenario.yaml>`
429-
- [ ] Flags: `--format json|text`, `--verbose`, `--llm-review`
430-
- [ ] Data model for scores: DimensionScore, SubScore, AcceptanceCriterion, QualityReport
431-
- [ ] Test: CLI argument parsing, report formatting
432-
433-
### 4.2 Dimension 1 — Record-Level Fidelity (weight: 0.15)
434-
435-
- [ ] `evaluation/dimensions/record_fidelity.py`
436-
- [ ] **Tier A: Parsability & Structure** (0.40) — parse every record, check required fields, validate types
437-
- [ ] Reuse/extend `src/evidenceforge/formats/validator.py` for per-record validation
438-
- [ ] Format-specific parsers: XML (Windows), JSON (Zeek, eCAR), regex (syslog, web, snort, bash_history)
439-
- [ ] **Tier B: Co-occurrence Rules** (0.35) — field combination checks
440-
- [ ] `evaluation/rules/co_occurrence.py` — rule engine + initial YAML rule sets per format
441-
- [ ] Start with 5-10 rules per format (e.g., Win 4624 LogonType=3 requires IpAddress; Zeek dns requires port 53)
442-
- [ ] Rules stored as YAML data files alongside format definitions
443-
- [ ] **Tier C: Population Statistics** (0.25) — aggregate distribution checks
444-
- [ ] `evaluation/rules/distributions.py` — reference profiles + divergence scoring
445-
- [ ] Event type distribution, user agent diversity, process name frequency
446-
- [ ] Test: known-good fixtures score high, known-bad fixtures score low
447-
448-
### 4.3 Dimension 5 — Signal Integrity (weight: 0.20)
449-
450-
- [ ] `evaluation/dimensions/signal_integrity.py`
451-
- [ ] **Event Presence** (0.25) — storyline events visible within sensor coverage produced traces
452-
- [ ] **Indicator Accuracy** (0.25) — present events carry correct IPs, usernames, hostnames, processes
453-
- [ ] **Pivot Linkability** (0.25) — consecutive storyline steps share pivotable indicators
454-
- [ ] **Storyline Temporal Integrity** (0.25) — events in correct order at correct times
455-
- [ ] Parse scenario storyline + build expected trace mapping
456-
- [ ] Test: scenarios with missing/wrong indicators score appropriately
457-
458-
### 4.4 Dimension 4 — Temporal Realism (weight: 0.15)
459-
460-
- [ ] `evaluation/dimensions/temporal.py`
461-
- [ ] **Work Hour Distribution** (0.20) — user events cluster in persona work hours (80-95%)
462-
- [ ] **Human Burstiness** (0.20) — CV of inter-event times per user (target: 1-3)
463-
- [ ] **System Process Regularity** (0.20) — autocorrelation of system event timestamps
464-
- [ ] **Causal Ordering** (0.20) — known causal pairs correctly sequenced
465-
- [ ] `evaluation/rules/causal_pairs.py` — causal pair definitions per format
466-
- [ ] **Timing Plausibility** (0.20) — no physically impossible timing
467-
- [ ] Test: uniform-distribution data scores low on burstiness; correctly-ordered data scores high on causality
468-
469-
### 4.5 Dimension 2 — Cross-Source Coherence (weight: 0.25)
470-
471-
- [ ] `evaluation/dimensions/cross_source.py`
472-
- [ ] `evaluation/visibility.py` — build visibility model from scenario topology
473-
- [ ] Which hosts have which logging (OS → format mapping)
474-
- [ ] Which network sensors see which traffic (reuse NetworkVisibilityEngine patterns)
475-
- [ ] **Source Correctness** (0.20) — no records in wrong sources (Windows cmds in bash_history, syslog from Windows hosts)
476-
- [ ] **Storyline Trace Coverage** (0.20) — expected traces found in visible sources
477-
- [ ] **Cross-Source Field Agreement** (0.20) — timestamps (UTC-normalized, ±30s tolerance), IPs, usernames agree
478-
- [ ] **Baseline Coherence — Sampled** (0.20) — random 5-10% sample of baseline events checked
479-
- [ ] **Baseline Coherence — Aggregate** (0.20) — per-user/system event counts proportional across sources
480-
- [ ] Test: cross-OS mismatch fixtures, missing trace fixtures
481-
482-
### 4.6 Dimension 3 — Background Noise Realism (weight: 0.25)
483-
484-
- [ ] `evaluation/dimensions/noise_realism.py`
485-
- [ ] `evaluation/anomaly.py` — lightweight statistical anomaly detector
486-
- [ ] **Volume Adequacy** (0.25) — noise-to-signal ratio meets intensity thresholds (low ~500:1, med ~5K:1, high ~10K:1+)
487-
- [ ] **User Behavioral Diversity** (0.25) — entropy/pairwise similarity of per-user event distributions
488-
- [ ] **Activity Plausibility** (0.25) — activities match persona/system/OS assignments
489-
- [ ] **Organic Anomaly Rate** (0.25) — 1-5% of background flagged as anomalous (too clean = bad, too chaotic = bad)
490-
- [ ] Test: cookie-cutter user activity scores low on diversity; clean-only background scores low on anomaly rate
491-
492-
### 4.7 LLM Spot-Check Layer (optional)
493-
494-
- [ ] Implement `--llm-review` flag in eval command
495-
- [ ] Sample 20-50 records for qualitative LLM assessment
496-
- [ ] Three check types: Record Realism, Narrative Coherence, Hunting Feasibility
497-
- [ ] Append commentary to report (does not affect numeric scores)
498-
499-
### 4.8 Integration & Acceptance Criteria
500-
501-
- [ ] Acceptance criteria engine: hard requirements (reject) + quality targets (flag)
502-
- [ ] Hard: Dim1 Tier A >= 98%, Dim2 Source Correctness >= 95%, Dim4 Causal Ordering >= 99%, Dim5 Event Presence >= 90%
503-
- [ ] Targets: Overall >= 70, each dimension >= 60, Dim3 anomaly rate 1-5%
504-
- [ ] Supplementary metrics: Difficulty Estimate (indicator distinctiveness, signal ratio)
505-
- [ ] Integration test: `eforge generate` + `eforge eval` pipeline on test scenarios
506-
- [ ] Run eval on existing fixtures (minimal, attack, retail-store) to establish baselines
422+
### 4.1 Report Framework & CLI Command ✅ COMPLETE
423+
424+
- [x] Create `src/evidenceforge/evaluation/` package structure
425+
- [x] `evaluation/engine.py` — Orchestrator with progress callbacks, acceptance criteria
426+
- [x] `evaluation/report.py` — Rich text + JSON report formatting
427+
- [x] `evaluation/models.py` — QualityReport, DimensionScore, SubScore, AcceptanceCriterion
428+
- [x] Add `eforge eval` CLI command with Rich progress bars
429+
- [x] 7 log parsers: XML (Windows), NDJSON (Zeek, eCAR), regex (syslog, snort, web, bash_history)
430+
- [x] Tests: 50 tests for parsers, models, CLI, report
431+
432+
### 4.2 Dimension 1 — Record-Level Fidelity (weight: 0.15) ✅ COMPLETE
433+
434+
- [x] `evaluation/dimensions/record_fidelity.py`
435+
- [x] Tier A: Parsability (reuses formats/validator.py)
436+
- [x] Tier B: Co-occurrence rules (YAML rule sets, 5-10 per format)
437+
- [x] Tier C: Population statistics (Jensen-Shannon divergence, hand-rolled)
438+
- [x] Tests: known-good/bad fixtures
439+
440+
### 4.3 Dimension 5 — Signal Integrity (weight: 0.20) ✅ COMPLETE
441+
442+
- [x] `evaluation/dimensions/signal_integrity.py`
443+
- [x] Event Presence, Indicator Accuracy, Pivot Linkability, Storyline Temporal Integrity
444+
- [x] Storyline resolution (ISO + relative offsets), keyword-based activity matching
445+
- [x] Tests: 17 tests covering all sub-scores
446+
447+
### 4.4 Dimension 4 — Temporal Realism (weight: 0.15) ✅ COMPLETE
448+
449+
- [x] `evaluation/dimensions/temporal.py`
450+
- [x] Work Hour Distribution, Human Burstiness (CV), System Process Regularity (autocorrelation)
451+
- [x] Causal Ordering with `evaluation/rules/causal_pairs.yaml` (4 pair types)
452+
- [x] Timing Plausibility (command rate + transfer speed checks)
453+
- [x] Tests: 17 tests
454+
455+
### 4.5 Dimension 2 — Cross-Source Coherence (weight: 0.25) ✅ COMPLETE
456+
457+
- [x] `evaluation/dimensions/cross_source.py`
458+
- [x] `evaluation/visibility.py` — OS→format mapping + NetworkVisibilityEngine reuse
459+
- [x] Source Correctness, Storyline Trace Coverage, Cross-Source Field Agreement
460+
- [x] Baseline Coherence (Sampled + Aggregate)
461+
- [x] Tests: 11 tests
462+
463+
### 4.6 Dimension 3 — Background Noise Realism (weight: 0.25) ✅ COMPLETE
464+
465+
- [x] `evaluation/dimensions/noise_realism.py`
466+
- [x] `evaluation/anomaly.py` — statistical anomaly detector (work hours, failed ops, rare processes, unexpected ports)
467+
- [x] Volume Adequacy, User Behavioral Diversity (cosine similarity), Activity Plausibility
468+
- [x] Organic Anomaly Rate (1-5% target)
469+
- [x] Tests: 13 tests
470+
471+
### 4.7 LLM Spot-Check Layer (optional) — DEFERRED
472+
473+
- [ ] Deferred until LLM client is available (Bedrock integration)
474+
- [ ] `--llm-review` flag placeholder in CLI (accepted but not yet functional)
475+
476+
### 4.8 Integration & Acceptance Criteria ✅ COMPLETE
477+
478+
- [x] Acceptance criteria engine: hard requirements (reject) + quality targets (flag)
479+
- [x] Hard: Dim1 Tier A >= 98%, Dim2 Source Correctness >= 95%, Dim4 Causal Ordering >= 99%, Dim5 Event Presence >= 90%
480+
- [x] Integration test: `eforge generate` + `eforge eval` pipeline verified on retail-store-ftp-attack
481+
- [x] 653+ tests passing, all 5 dimensions scoring
507482

508483
### 4.9 Scenario Skill Update
509484

510485
- [ ] Update `commands/eforge/scenario.md` to check sensor coverage during authoring
511486
- [ ] Flag when storyline events may not be discoverable given declared topology
512487

513-
**Phase 4 Milestone:** `eforge eval` produces comprehensive quality reports with 5-dimension scoring, acceptance criteria, and optional LLM spot-checks. Reports feed back into generation improvements.
488+
**Phase 4 Status:** ✅ All 5 scoring dimensions complete (23 sub-scores). `eforge eval` produces comprehensive quality reports. 653+ tests. LLM spot-check deferred.
489+
490+
**Baseline scores on retail-store-ftp-attack (24K records, 4 sources):**
491+
- Overall: 78/100
492+
- Dim 1 (Record Fidelity): 68 — empty SIDs, distribution skew
493+
- Dim 2 (Cross-Source): 100 — correct OS mapping, field agreement
494+
- Dim 3 (Noise Realism): 50 — volume too low, users too similar
495+
- Dim 4 (Temporal): 67 — work hours too concentrated, burstiness low
496+
- Dim 5 (Signal Integrity): 100 — all storyline events found with correct indicators
514497

515498
---
516499

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Lightweight statistical anomaly detection for background events.
2+
3+
Flags events that are anomalous-but-benign (realistic noise for hunters).
4+
Used by Dimension 3 (Background Noise Realism) to score Organic Anomaly Rate.
5+
"""
6+
7+
from collections import Counter
8+
from typing import Any
9+
10+
from evidenceforge.evaluation.dimensions.temporal import _extract_username
11+
from evidenceforge.evaluation.parsers import ParsedRecord
12+
from evidenceforge.models.scenario import Scenario
13+
from evidenceforge.validation.schema import BUILTIN_ACCOUNTS
14+
15+
# Failed operation indicators
16+
_FAILED_EVENT_IDS = {4625} # Windows failed logon
17+
_FAILED_HTTP_CODES = set(range(400, 600))
18+
_FAILED_SYSLOG_KEYWORDS = ["failed", "denied", "error", "invalid", "unauthorized"]
19+
20+
21+
def detect_anomalies(
22+
records: dict[str, list[ParsedRecord]],
23+
scenario: Scenario,
24+
) -> tuple[int, int]:
25+
"""Detect anomalous events in background noise.
26+
27+
Returns:
28+
(anomalous_count, total_checked) — both ints.
29+
"""
30+
# Build context
31+
persona_hours = _build_persona_hours(scenario)
32+
service_ports = _build_service_ports(scenario)
33+
process_freq = _build_process_frequency(records)
34+
system_accounts = {a.lower() for a in BUILTIN_ACCOUNTS}
35+
36+
total = 0
37+
anomalous = 0
38+
39+
for format_name, record_list in records.items():
40+
for record in record_list:
41+
if record.parse_errors:
42+
continue
43+
total += 1
44+
45+
is_anomalous = (
46+
_is_off_hours(record, persona_hours)
47+
or _is_failed_operation(record, format_name)
48+
or _is_rare_process(record, format_name, process_freq)
49+
or _is_unexpected_port(record, format_name, service_ports)
50+
)
51+
if is_anomalous:
52+
anomalous += 1
53+
54+
return anomalous, total
55+
56+
57+
def _build_persona_hours(scenario: Scenario) -> dict[str, list[int]]:
58+
"""Map username → list of work hours from persona."""
59+
result: dict[str, list[int]] = {}
60+
persona_map = {}
61+
if scenario.personas:
62+
persona_map = {p.name: p for p in scenario.personas}
63+
64+
for user in scenario.environment.users:
65+
if user.persona and user.persona in persona_map:
66+
persona = persona_map[user.persona]
67+
if persona.work_hours_parsed:
68+
result[user.username.lower()] = persona.work_hours_parsed.get("hours", [])
69+
70+
return result
71+
72+
73+
def _build_service_ports(scenario: Scenario) -> set[int]:
74+
"""Collect all declared service ports from scenario systems."""
75+
# Common service-to-port mappings
76+
service_ports: set[int] = set()
77+
port_map = {
78+
"ssh": 22, "http": 80, "https": 443, "ftp": 21, "smtp": 25,
79+
"dns": 53, "rdp": 3389, "smb": 445, "mysql": 3306, "postgres": 5432,
80+
"iis": 80, "nginx": 80, "apache": 80, "sql server": 1433,
81+
}
82+
for system in scenario.environment.systems:
83+
for svc in system.services:
84+
port = port_map.get(svc.lower())
85+
if port:
86+
service_ports.add(port)
87+
return service_ports
88+
89+
90+
def _build_process_frequency(records: dict[str, list[ParsedRecord]]) -> Counter:
91+
"""Count process/command frequencies across all records."""
92+
freq: Counter = Counter()
93+
for fmt, record_list in records.items():
94+
for rec in record_list:
95+
proc = _extract_process_key(rec, fmt)
96+
if proc:
97+
freq[proc] += 1
98+
return freq
99+
100+
101+
def _extract_process_key(record: ParsedRecord, fmt: str) -> str | None:
102+
"""Extract a process/command identifier from a record."""
103+
f = record.fields
104+
if fmt == "windows_event_security" and f.get("EventID") == 4688:
105+
return f.get("NewProcessName", "")
106+
if fmt == "bash_history":
107+
cmd = f.get("command", "")
108+
return cmd.split()[0] if cmd else None
109+
if fmt == "ecar" and f.get("object") == "PROCESS":
110+
return f.get("image_path", "")
111+
return None
112+
113+
114+
def _is_off_hours(record: ParsedRecord, persona_hours: dict[str, list[int]]) -> bool:
115+
"""Check if event is outside user's persona work hours."""
116+
if not record.timestamp:
117+
return False
118+
user = _extract_username(record)
119+
if not user or user not in persona_hours:
120+
return False
121+
hours = persona_hours[user]
122+
if not hours:
123+
return False
124+
return record.timestamp.hour not in hours
125+
126+
127+
def _is_failed_operation(record: ParsedRecord, fmt: str) -> bool:
128+
"""Check if event represents a failed operation."""
129+
f = record.fields
130+
if fmt == "windows_event_security":
131+
return f.get("EventID") in _FAILED_EVENT_IDS
132+
if fmt == "web_access":
133+
code = f.get("status_code")
134+
return isinstance(code, int) and code in _FAILED_HTTP_CODES
135+
if fmt == "syslog":
136+
msg = f.get("message", "").lower()
137+
return any(kw in msg for kw in _FAILED_SYSLOG_KEYWORDS)
138+
return False
139+
140+
141+
def _is_rare_process(
142+
record: ParsedRecord, fmt: str, process_freq: Counter,
143+
) -> bool:
144+
"""Check if event involves a rarely-seen process/command."""
145+
proc = _extract_process_key(record, fmt)
146+
if not proc or not process_freq:
147+
return False
148+
149+
# Bottom 5% by frequency = rare
150+
total_procs = sum(process_freq.values())
151+
if total_procs == 0:
152+
return False
153+
154+
threshold = max(1, total_procs * 0.05 / len(process_freq))
155+
return process_freq[proc] <= threshold
156+
157+
158+
def _is_unexpected_port(
159+
record: ParsedRecord, fmt: str, service_ports: set[int],
160+
) -> bool:
161+
"""Check if connection goes to a port not associated with declared services."""
162+
if fmt != "zeek_conn" or not service_ports:
163+
return False
164+
165+
resp_port = record.fields.get("id.resp_p")
166+
if not isinstance(resp_port, int):
167+
return False
168+
169+
# Common always-expected ports
170+
always_ok = {80, 443, 53, 22}
171+
if resp_port in always_ok or resp_port in service_ports:
172+
return False
173+
174+
# High ports (ephemeral) are not unexpected
175+
if resp_port > 1024:
176+
return False
177+
178+
return True

0 commit comments

Comments
 (0)