From 13becfa54db11ab0ed49fc6c27ab4d4f974f1dc1 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 21:37:17 +0530 Subject: [PATCH 01/23] =?UTF-8?q?docs:=20SIH=20submission=20artifacts=20?= =?UTF-8?q?=E2=80=94=20architecture=20diagram=20+=20PPT=20fix/viability=20?= =?UTF-8?q?notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-M3 pre-work per plan Global Constraints: commit deck assets to main, ignore brag-output renders and legacy testdata residue. Co-Authored-By: Claude Code --- .gitignore | 4 + docs/architecture-diagram.png | Bin 0 -> 395222 bytes docs/architecture-diagram.svg | 213 ++++++++++++++++++++++++++++++ docs/ppt-accuracy-fixes.md | 24 ++++ docs/ppt-feasibility-viability.md | 69 ++++++++++ 5 files changed, 310 insertions(+) create mode 100644 docs/architecture-diagram.png create mode 100644 docs/architecture-diagram.svg create mode 100644 docs/ppt-accuracy-fixes.md create mode 100644 docs/ppt-feasibility-viability.md diff --git a/.gitignore b/.gitignore index 3a5c76e..33cf0a2 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,7 @@ references/ node_modules/ web/dist/ .pytest_cache/ + +# M3 pre-work: submission artifacts + demo residue (rooted) +/brag-output/ +/testdata/ diff --git a/docs/architecture-diagram.png b/docs/architecture-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..8a414f2fec096b01e37f3c4f11502784e5edac9d GIT binary patch literal 395222 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�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 `k;mV>A*ni~hN$~}EX3EUQcHGPNo{0PD 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 K$O=X0`@$8 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%0Ab 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
    Q07C2Hz8bvG(V*PZoSRv(jHGgW&uL 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 z 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 + + + + + + + + + + + + + + + + + + + + + + + + + + ULPF — Universal Log Pre-processing Framework + lossless ingest · fingerprinting · OCSF normalization · human-gated onboarding · fail-closed drift + + + + HOT PATH — deterministic · no AI per-event + + + + + Firewall + + IDS / IPS + + Any appliance + any perimeter device + + + + + + + + + + Collector + syslog UDP/TCP · :5514 + HTTP POST · :8080 + zero parsing — wraps raw + every line → envelope → topic + + + + + + raw.logs + 6 partitions · replay-safe + Redpanda — Kafka protocol + + + + + + Pipeline workers + + ×N · consumer group + + + 1 + persist raw FIRST + immutable rows · Merkle hash chain + + + 2 + fingerprint — any format + known formats + shape-hash families + + + 3 + apply active rule → OCSF + unknown → samples · errors → DLQ + + + + + + normalized.events + keyed by fingerprint + + + + + + Drift worker + watches rule health per fingerprint + tier-1 invariants — from event 0 + tier-2 statistical vs baseline + + FAIL-CLOSED + field quarantine · rule deactivate + → re-enters onboarding + + + + quarantine field · deactivate rule + + + + raw-first persist + + + + COLD PATH — AI-assisted · human-gated · rules are data, never code + + + + unknown-format samples + + + + Onboarding service + triggers when samples pile up, no active rule + · sample split — 5 prompt / 15 held-out + · SLM drafts candidate rule + · deterministic validation gate + · candidate → pending_review + + Ollama — Qwen3 4B / 8B tiers · offline + cold path only — never per-event + fail-closed: pends on SLM outage + + + + + + + ★ + HUMAN + REVIEW + approve · edit + override + + no auto-approve + approve → rule v2 live + backlog re-parses + + + + + + + PostgreSQL — event store & control plane + least-privilege roles · pipelines are INSERT-only + + + raw_events + INSERT-only · daily partitions + + + raw_batches + per-partition hash chain + + + normalized_events + append-only · versioned + + + rules + immutable versions · 1 active + + + audit_log + every transition + + + onboarding_samples + prompt · held-out · unused + + + + current view · stats · audit + + + + + + + Gateway + Dashboard + FastAPI gateway · React UI + live feed · Review Queue + rules · drift & health · audit + self-hosted assets (air-gap) + + + + Exports + OCSF JSONL · Parquet + → SIEM · data lake + + + + Air-gap native — offline SLM (Ollama) · self-hosted UI assets · docker-compose bundle (tars) · Redpanda speaks the Kafka protocol + + + diff --git a/docs/ppt-accuracy-fixes.md b/docs/ppt-accuracy-fixes.md new file mode 100644 index 0000000..07d52a9 --- /dev/null +++ b/docs/ppt-accuracy-fixes.md @@ -0,0 +1,24 @@ +# PPT accuracy fixes (spec §17 — mandated before finals; do them before this submission) + +The deck (`references/sih-ps-26156-ppt-print.pdf`) predates the M2 merge. Three +lines no longer match what shipped — suggested replacements: + +1. **Slide 3, Onboarding engine bullet** — "Local SLM (Ollama + Granite 4.1)" + → **"Local SLM (Ollama, tiered Qwen3 4B/8B — CPU-only, offline)"** + Shipped default tier is qwen3:4b (`deploy/docker-compose.yml`, + `docs/m2-slm-sanity.md`); Granite was a harness alternate, never the default. + +2. **Slide 4, Resource-light bullet** — "billions-of-events/day throughput needs + no GPU or inference" → **"hot-path throughput needs no GPU or inference; + scale-out is Kafka partitions + consumer-group workers (benchmarks in M4)"** + The spec withdrew unmeasured throughput claims (§13 benchmark honesty: + measured numbers only — nothing extrapolated). + +3. **Slide 3, Ingestion bullet** — "buffered via Kafka" → **"buffered via + Redpanda (Kafka-protocol compatible)"** — exact and preempts the judge + question. (The architecture diagram already words it this way.) + +Also worth a glance (not a mismatch, just a risk): slides 2/4 cite Matryoshka +as "UC Berkeley/Google published research" — spec §17 flags this attribution +for re-verification; slide 6 links the julien-piet/matryoshka repo. Confirm +the affiliation wording before submission. diff --git a/docs/ppt-feasibility-viability.md b/docs/ppt-feasibility-viability.md new file mode 100644 index 0000000..690884a --- /dev/null +++ b/docs/ppt-feasibility-viability.md @@ -0,0 +1,69 @@ +# PPT feasibility & industry-viability points (slide 4 replacement copy) + +Companion to `docs/ppt-accuracy-fixes.md`. Replacement text for the deck's +slide 4 ("FEASIBILITY AND VIABILITY"), reworked from idea-stage ("should +work") to prototype-stage ("does work, with numbers"). Every claim is true of +the merged repo (M2, a8f578d). The withdrawn claims stay out: no unmeasured +throughput figures, no Granite naming, no unverified research attribution. + +Usage: drop the three sections into the slide's three template fields. If the +final-round template has a dedicated "viability in the industry" field, the +middle section lifts out as-is. + +## Analysis and Feasibility of the idea + +- **Built and verified, not proposed** — the full pipeline (ingest → + fingerprint → normalize → human review → backlog re-parse) is merged and + green: 244 automated tests, 8/8 end-to-end acceptance checks including a + forced Kafka replay (0 duplicates, 0 losses) and independent re-verification + of every Merkle hash-chain link +- **Proven building blocks only** — Redpanda (Kafka protocol), PostgreSQL, + FastAPI, React, Ollama: no exotic dependencies; the whole stack demos on + one laptop via `docker compose up` +- **Deterministic hot path** — per-event cost is regex + hashing, never + inference; scale-out is Kafka partitions + worker replicas (benchmarks + measured in M4 — no extrapolated throughput claims) +- **Tamper-evidence by construction** — immutable raw store with + per-partition hash chains; the database roles themselves deny update/delete + to the pipeline + +## Viability — in the industry + +- **Attacks the costliest step in log adoption** — per-vendor parser projects + collapse into a review-and-approve cycle; analysts approve rules instead of + writing them +- **Standards-aligned, not proprietary** — normalizes to OCSF (Linux + Foundation; backed by AWS, Splunk, IBM and 30+ vendors) and exports + JSONL/Parquet → integrates with existing SIEMs and data lakes, no + rip-and-replace +- **Serves networks cloud AI cannot** — fully air-gapped operation (offline + SLM, self-hosted UI assets, container bundle) fits government, defense, + banking and OT/ICS deployments +- **Trust survives automation** — every rule versioned with provenance + (slm / slm-edited / human), every transition audited, and no code path can + activate a rule without a human action +- **Reversible adoption** — raw bytes are stored first and never mutated, so + ULPF can sit in front of any existing pipeline; removing it loses nothing + +## Potential Challenges → Strategies (paired) + +| Challenge | Strategy (already built) | +|---|---| +| SLM rule quality varies with hardware | Deterministic 8-point validation gate + human approval **bound** model quality — a weaker model costs review time, never correctness (measured and documented on the default tier) | +| OCSF coverage is a curated subset | Unmapped fields are preserved, never dropped; extending the schema is additive work | +| Vendor formats drift silently | Tier-1 invariant checks fire from event zero (no baseline needed); tier-2 statistical drift arms after baseline; enforcement is fail-closed — quarantine or deactivate, never silently wrong data | +| Low-volume sources are hard to baseline | Windows close on event-count OR time, so a 40-events/day sensor is monitored on its own clock | + +## Speaker notes (don't put on the slide) + +- The strongest single sentence if a judge asks "is this real?": *"You can + delete our consumer group mid-run and replay every message — the store + comes out identical; that check runs in our acceptance gate."* +- If asked about the SLM tier honestly: the default 4B tier on modest CPU + hardware stores zero rules unaided (documented in docs/m2-slm-sanity.md) — + which is exactly why the architecture never lets model quality touch + correctness: the gate + human review bound it, and manual authoring always + works. +- If asked about scale numbers: commit only to "partitions + consumer-group + workers; measured per-worker benchmarks are the M4 deliverable" — the spec + explicitly withdrew unmeasured throughput claims. From f4fbf2b44605144751916e557b37eb86f7c59417 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 21:48:26 +0530 Subject: [PATCH 02/23] chore(sdd): ignore .superpowers workspace Co-Authored-By: Claude Code --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 33cf0a2..63f6daa 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ web/dist/ # M3 pre-work: submission artifacts + demo residue (rooted) /brag-output/ /testdata/ +.superpowers/ From e9b5cac03bcfbdb05c34f9cc419bcd18d86fd3af Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 21:56:25 +0530 Subject: [PATCH 03/23] chore(m3): gitignore brag-output/testdata; pythonpath drift; env knobs documented Co-Authored-By: Claude Code --- deploy/.env.example | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/deploy/.env.example b/deploy/.env.example index c652beb..d17a289 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -15,5 +15,12 @@ ULPF_OLLAMA_URL=http://ollama-4b:11434 # they are reachable only inside the compose network (onboarding-internal). ULPF_OLLAMA_MODEL=qwen3:4b +# Drift engine knobs (M3) — read by services/drift's Config.from_env; +# docker compose passes them through to the drift service. +ULPF_DRIFT_WINDOW_COUNT=1000 +ULPF_DRIFT_WINDOW_TIME_S=600 +ULPF_DRIFT_BASELINE_WINDOWS=10 +ULPF_DRIFT_POLL_S=5 + # Per-service database DSNs (pipeline, gateway, admin) are compose-internal # constants defined in docker-compose.yml — M2 adds no user-facing DB knobs. diff --git a/pyproject.toml b/pyproject.toml index d12a5a0..1138062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.pytest.ini_options] testpaths = ["libs", "services", "simulator", "bench"] -pythonpath = [".", "services/collector", "services/pipeline", "services/gateway", "simulator", "services/onboarding"] +pythonpath = [".", "services/collector", "services/pipeline", "services/gateway", "simulator", "services/onboarding", "services/drift"] [tool.ruff] line-length = 100 From 99f9fbc13c6527323834e1531aad1910c885b4ad Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 22:10:02 +0530 Subject: [PATCH 04/23] =?UTF-8?q?feat(deploy):=20migration=20007=20?= =?UTF-8?q?=E2=80=94=20drift=20tables,=20drift=5Frole=20grants=20(R-M3-2/3?= =?UTF-8?q?,=20P-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- deploy/migrations/007_drift_tables.sql | 50 ++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 deploy/migrations/007_drift_tables.sql diff --git a/deploy/migrations/007_drift_tables.sql b/deploy/migrations/007_drift_tables.sql new file mode 100644 index 0000000..4957864 --- /dev/null +++ b/deploy/migrations/007_drift_tables.sql @@ -0,0 +1,50 @@ +-- deploy/migrations/007_drift_tables.sql — M3 drift detection (R-M3-2/3). +-- Natural PKs ONLY (no serials): the 004/005/006 sequence-grant bug class is +-- structurally impossible here. Drift writes are INSERT-only. +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='drift_role') THEN + CREATE ROLE drift_role LOGIN PASSWORD 'drift_dev'; END IF; +END $$; + +CREATE TABLE drift_windows ( + fingerprint_id TEXT NOT NULL, + rule_version INT NOT NULL, + field TEXT NOT NULL, -- OCSF path, 'unmapped.', or '__rule__' sentinel + window_start TIMESTAMPTZ NOT NULL, -- parsed_at of the window's first event (deterministic key) + window_end TIMESTAMPTZ NOT NULL, + events_count INT NOT NULL, + null_rate REAL, + match_rate REAL, -- '__rule__' rows: parsed / (parsed + parse_error) + violation_rate REAL, -- tier-1 invariant violations for this field + shape_dist JSONB, -- {"ipv4": 12, "int": 3, ...} over non-null scalar values + severity TEXT NOT NULL CHECK (severity IN ('none','minor','moderate','severe')), + action_taken TEXT CHECK (action_taken IS NULL OR action_taken IN + ('alert','field_quarantined','rule_deactivated')), + scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (fingerprint_id, rule_version, field, window_start) +); +CREATE INDEX drift_windows_recent ON drift_windows (fingerprint_id, window_start DESC); + +CREATE TABLE baseline_profiles ( + fingerprint_id TEXT NOT NULL, + rule_version INT NOT NULL, + field TEXT NOT NULL, + profile JSONB NOT NULL, -- {events_count, null_rate, match_rate, shape_dist} + windows_seen INT NOT NULL, + established_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (fingerprint_id, rule_version, field) +); + +-- drift service (detection + enforcement, R-M3-3) +GRANT SELECT ON normalized_events, rules TO drift_role; +GRANT INSERT ON drift_windows, baseline_profiles TO drift_role; +-- P-2: drift_role self-reads — baseline existence checks + recent-window lookups. +GRANT SELECT ON drift_windows, baseline_profiles TO drift_role; +GRANT UPDATE (quarantined_fields) ON rules TO drift_role; +GRANT UPDATE (status, deactivated_at) ON rules TO drift_role; +GRANT INSERT ON audit_log TO drift_role; +GRANT USAGE, SELECT ON SEQUENCE audit_log_id_seq TO drift_role; + +-- gateway: human override of quarantine (audited, rules_role write path) +GRANT UPDATE (quarantined_fields) ON rules TO rules_role; +-- gateway SELECT on both new tables arrives via 003's ALTER DEFAULT PRIVILEGES. From bcf20b8386a4db89bc36315fb6c3335d9fb6417a Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 22:26:30 +0530 Subject: [PATCH 05/23] =?UTF-8?q?feat(ulpf-core):=20pure=20drift=20math=20?= =?UTF-8?q?=E2=80=94=20shapes,=20JS=20divergence,=20severity=20ladders=20(?= =?UTF-8?q?R-M3-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- libs/ulpf-core/tests/test_drift.py | 167 +++++++++++++++++++++++++++++ libs/ulpf-core/ulpf_core/drift.py | 147 +++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 libs/ulpf-core/tests/test_drift.py create mode 100644 libs/ulpf-core/ulpf_core/drift.py diff --git a/libs/ulpf-core/tests/test_drift.py b/libs/ulpf-core/tests/test_drift.py new file mode 100644 index 0000000..626dd95 --- /dev/null +++ b/libs/ulpf-core/tests/test_drift.py @@ -0,0 +1,167 @@ +# libs/ulpf-core/tests/test_drift.py — the pure drift-math module, all with concrete data: +import math + +import pytest +from ulpf_core.drift import ( + SEVERITIES, + classify_value, + decide_action, + js_divergence, + match_rate_severity, + null_rate_severity, + shape_histogram, + shape_severity, + tier1_severity, + volume_severity, +) + +# --- classify_value / shape_histogram ---------------------------------------- + +def test_classify_value_classes(): + assert classify_value("198.51.100.4") == "ipv4" + assert classify_value(52114) == "int" and classify_value("52114") == "int" + assert classify_value("2026-09-19T10:00:03Z") == "timestamp" + assert classify_value("2026/09/19 10:00:03") == "timestamp" + assert classify_value("DROP IN=eth0") == "free_text" + assert classify_value(3.14) == "free_text" # no float class + +def test_classify_value_ip_must_be_dotted_quad(): + assert classify_value("1.2.3.4.5") == "free_text" # five groups + assert classify_value("1.2.3") == "free_text" + assert classify_value("999.999.999.999") == "ipv4" # shape-level: 4 digit quads + +def test_classify_value_bool_is_not_int(): + assert classify_value(True) == "free_text" # bool is an int subclass + assert classify_value("007") == "int" # digit-string, leading zeros fine + +def test_classify_value_none_free_contract(): + with pytest.raises(ValueError): # callers filter nulls first + classify_value(None) + +def test_shape_histogram_counts(): + values = ["1.2.3.4", "5", "5", "hello", 7, "2026-09-19T00:00:00Z"] + assert shape_histogram(values) == {"ipv4": 1, "int": 3, "free_text": 1, "timestamp": 1} + +def test_shape_histogram_empty(): + assert shape_histogram([]) == {} + +# --- js_divergence ------------------------------------------------------------ + +def test_js_identity_is_zero(): + assert js_divergence({"ipv4": 90, "int": 10}, {"ipv4": 90, "int": 10}) == 0.0 + +def test_js_symmetric(): + p, q = {"ipv4": 90, "int": 10}, {"ipv4": 10, "int": 90} + assert js_divergence(p, q) == pytest.approx(js_divergence(q, p)) + +def test_js_smoothing_keeps_unseen_key_finite(): + d = js_divergence({"ipv4": 100}, {"int": 100}) # disjoint supports + assert math.isfinite(d) and 0 < d < math.log(2) + +def test_js_disjoint_smoothed_value(): + # add-1 over the union: (11/12, 1/12) vs (1/12, 11/12) -> JS = 0.406311... + assert js_divergence({"a": 10}, {"b": 10}) == pytest.approx(0.4063111975, abs=1e-6) + +def test_js_grows_toward_ln2_as_counts_grow(): + small = js_divergence({"a": 10}, {"b": 10}) + big = js_divergence({"a": 10000}, {"b": 10000}) + assert big > small and big == pytest.approx(math.log(2), abs=5e-3) + +def test_js_empty_histograms(): + assert js_divergence({}, {}) == 0.0 + +# --- null-rate ladder (legacy 3x/10x/40x + 0.05 abs gate) --------------------- +# Dyadic-friendly rates so the ratios land exactly ON the boundaries under test. + +def test_null_ratio_just_below_3x_none(): + assert null_rate_severity(0.0934375, 0.03125) == "none" # 2.99x, diff 0.0622 >= 0.05 + +def test_null_ratio_exactly_3x_minor(): + assert null_rate_severity(0.09375, 0.03125) == "minor" # 3.0x exact, diff 0.0625 + +def test_null_ratio_just_below_10x_minor(): + assert null_rate_severity(0.1546875, 0.015625) == "minor" # 9.9x + +def test_null_ratio_exactly_10x_moderate(): + assert null_rate_severity(0.15625, 0.015625) == "moderate" # 10.0x exact + +def test_null_ratio_just_below_40x_moderate(): + assert null_rate_severity(0.15234375, 0.00390625) == "moderate" # 39x + +def test_null_ratio_exactly_40x_severe(): + assert null_rate_severity(0.15625, 0.00390625) == "severe" # 40.0x exact + +def test_null_abs_gate_suppresses_tiny_baseline_ratio(): + assert null_rate_severity(0.04, 0.004) == "none" # 10x ratio but diff 0.036 < 0.05 + +def test_null_abs_diff_exactly_05_not_suppressed(): + assert null_rate_severity(0.065625, 0.015625) == "minor" # diff exactly 0.05, ratio 4.2x + +def test_null_baseline_zero_current_zero_none(): + assert null_rate_severity(0.0, 0.0) == "none" # ratio defined as 1.0 + +def test_null_baseline_zero_current_positive_severe(): + assert null_rate_severity(0.3, 0.0) == "severe" # inf ratio: field went null + +def test_null_steady_state_none(): + assert null_rate_severity(0.2, 0.2) == "none" # ratio 1, diff 0 + +# --- match-rate drop ladder (<0.10 / <0.25 / <0.50) --------------------------- + +def test_match_drop_below_10pp_none(): + assert match_rate_severity(0.6875, 0.75) == "none" # drop 0.0625 + +def test_match_drop_exactly_10pp_minor(): + assert match_rate_severity(0.9, 1.0) == "minor" # nominal 0.10 (quantized to 6dp) + +def test_match_drop_just_below_25pp_minor(): + assert match_rate_severity(0.25390625, 0.5) == "minor" # drop 0.2461 + +def test_match_drop_exactly_25pp_moderate(): + assert match_rate_severity(0.25, 0.5) == "moderate" # drop 0.25 + +def test_match_drop_just_below_50pp_moderate(): + assert match_rate_severity(0.25390625, 0.75) == "moderate" # drop 0.4961 + +def test_match_drop_exactly_50pp_severe(): + assert match_rate_severity(0.25, 0.75) == "severe" # drop 0.50 + +def test_match_improvement_none(): + assert match_rate_severity(0.9, 0.5) == "none" # negative drop: rule got better + +# --- shape / tier-1 / volume ladders ------------------------------------------ + +def test_shape_severity_bands(): + assert shape_severity(0.0) == "none" and shape_severity(0.149) == "none" + assert shape_severity(0.15) == "minor" and shape_severity(0.34) == "minor" + assert shape_severity(0.35) == "moderate" and shape_severity(0.59) == "moderate" + assert shape_severity(0.60) == "severe" and shape_severity(0.9) == "severe" + +def test_tier1_severity_bands(): + assert tier1_severity(0.0) == "none" and tier1_severity(0.049) == "none" + assert tier1_severity(0.05) == "minor" and tier1_severity(0.19) == "minor" + assert tier1_severity(0.20) == "moderate" and tier1_severity(0.49) == "moderate" + assert tier1_severity(0.50) == "severe" and tier1_severity(1.0) == "severe" + +def test_volume_silence_is_severe(): + assert volume_severity(0, 50, 100) == "severe" # dead feed never looks healthy + assert volume_severity(0, 0, 100) == "severe" # even though 0 is inside bounds + +def test_volume_inside_bounds_none(): + assert volume_severity(50, 50, 100) == "none" # ON p05 + assert volume_severity(100, 50, 100) == "none" # ON p95 + assert volume_severity(75, 50, 100) == "none" + +def test_volume_outside_bounds_minor(): + assert volume_severity(49, 50, 100) == "minor" # drought + assert volume_severity(101, 50, 100) == "minor" # flood + +# --- severity -> action mapping ----------------------------------------------- + +def test_decide_action_full_mapping(): # spec §7.2 graduated enforcement + expected = [None, "alert", "field_quarantined", "rule_deactivated"] + assert [decide_action(s) for s in SEVERITIES] == expected + assert decide_action("none") is None + +def test_severities_tuple(): + assert SEVERITIES == ("none", "minor", "moderate", "severe") diff --git a/libs/ulpf-core/ulpf_core/drift.py b/libs/ulpf-core/ulpf_core/drift.py new file mode 100644 index 0000000..5558b94 --- /dev/null +++ b/libs/ulpf-core/ulpf_core/drift.py @@ -0,0 +1,147 @@ +"""Pure drift-detection math for the M3 drift service (spec §7.1, R-M3-5). + +Everything here is deterministic and dependency-free — stdlib only, no DB, +no model calls — so Tasks 4/5 (window loop, enforcement) can import it +anywhere. Scalar value classification, shape histograms, smoothed JS +divergence, the severity ladders and the severity-to-action mapping. + +Deliberately standalone: the class vocabulary is scalar-level +("ipv4"/"int"/"timestamp"/"free_text"), independent of fingerprint's +token-level ``_classify`` shapes, and must not import it (R-M3-5). +""" + +import math +import re + +SEVERITIES = ("none", "minor", "moderate", "severe") + +# Same dotted-quad shape as validation's IP sanity check (four digit quads, +# no 0-255 range enforcement — this is a class test, not an address parser). +_IPV4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") +# ISO-8601-ish: a value that OPENS with a YYYY-MM-DD / YYYY/MM/DD date. +_TS_PREFIX_RE = re.compile(r"^\d{4}[-/]\d{2}[-/]\d{2}") +_DIGITS_RE = re.compile(r"^\d+$") + + +def classify_value(v) -> str: + """Scalar class of a mapped value: "ipv4" | "int" | "timestamp" | + "free_text". None-free contract: callers filter nulls before binning + (null-rate is its own tier-2 signal), so None is rejected loudly here + instead of silently classifying as "None" free text.""" + if v is None: + raise ValueError("classify_value is None-free: filter nulls at the caller") + if isinstance(v, str): + if _IPV4_RE.match(v): + return "ipv4" + if _TS_PREFIX_RE.match(v): + return "timestamp" + if _DIGITS_RE.match(v): + return "int" + elif isinstance(v, int) and not isinstance(v, bool): + # bool is an int subclass — a True flag is not an int value + return "int" + return "free_text" + + +def shape_histogram(values: list) -> dict[str, int]: + """Count of values per classify_value class — the tier-2 shape_dist.""" + hist: dict[str, int] = {} + for v in values: + cls = classify_value(v) + hist[cls] = hist.get(cls, 0) + 1 + return hist + + +def js_divergence(p: dict, q: dict) -> float: + """Symmetric Jensen-Shannon divergence (natural log) over two + histograms, add-1 smoothed across the key union so a key unseen on + one side contributes mass instead of a divide-by-zero infinity. + Identical histograms give exactly 0.0; disjoint supports approach + ln 2 (~0.693) as counts grow.""" + keys = set(p) | set(q) + if not keys: + return 0.0 + smoothed_p = {k: p.get(k, 0) + 1 for k in keys} + smoothed_q = {k: q.get(k, 0) + 1 for k in keys} + total_p, total_q = sum(smoothed_p.values()), sum(smoothed_q.values()) + divergence = 0.0 + for k in keys: + pi = smoothed_p[k] / total_p + qi = smoothed_q[k] / total_q + m = (pi + qi) / 2 + divergence += 0.5 * pi * math.log(pi / m) + 0.5 * qi * math.log(qi / m) + return divergence + + +def null_rate_severity(current: float, baseline: float) -> str: + """The legacy demo's proven ladder (legacy-demo/drift_monitor/monitor.py + classify_severity, verbatim semantics): ratio < 3 or absolute diff + < 0.05 -> none; ratio < 10 -> minor; ratio < 40 -> moderate; else + severe. Baseline 0: ratio is inf when current > 0 (never-null field + starting to null is infinitely worse) else 1.0.""" + current = round(current, 6) + baseline = round(baseline, 6) + if baseline > 0: + ratio = current / baseline + else: + ratio = float("inf") if current > 0 else 1.0 + difference = round(current - baseline, 6) + if ratio < 3 or difference < 0.05: + return "none" + if ratio < 10: + return "minor" + if ratio < 40: + return "moderate" + return "severe" + + +def _band_severity(value: float, minor: float, moderate: float, severe: float) -> str: + """Shared ascending-threshold ladder: below `minor` -> none, below + `moderate` -> minor, below `severe` -> moderate, else severe. The + match/shape/tier-1 ladders differ only in their threshold triples.""" + if value < minor: + return "none" + if value < moderate: + return "minor" + if value < severe: + return "moderate" + return "severe" + + +def match_rate_severity(current: float, baseline: float) -> str: + """Absolute match-rate drop ladder: <0.10 none, <0.25 minor, <0.50 + moderate, else severe. The drop is quantized to 6 decimals like the + null ladder so a nominal 1.00 -> 0.90 window sits exactly ON the 0.10 + boundary (1.0 - 0.9 is 0.0999... in binary), not just under it.""" + return _band_severity(round(baseline - current, 6), 0.10, 0.25, 0.50) + + +def shape_severity(divergence: float) -> str: + """JS-divergence ladder: <0.15 none, <0.35 minor, <0.60 moderate, + else severe.""" + return _band_severity(divergence, 0.15, 0.35, 0.60) + + +def tier1_severity(violation_rate: float) -> str: + """Tier-1 invariant-violation rate ladder: <0.05 none, <0.20 minor, + <0.50 moderate, else severe.""" + return _band_severity(violation_rate, 0.05, 0.20, 0.50) + + +def volume_severity(count: int, p05: float, p95: float) -> str: + """Per-source event volume against baseline percentile bounds: + silence (0 events) is severe — a dead feed must never look healthy — + inside [p05, p95] is none, anything else is minor (drought or flood).""" + if count == 0: + return "severe" + if p05 <= count <= p95: + return "none" + return "minor" + + +def decide_action(severity: str) -> str | None: + """Severity -> graduated enforcement step (spec §7.2): minor alerts, + moderate quarantines the field (rule stays live for healthy fields), + severe deactivates the rule; none needs no action.""" + return {"none": None, "minor": "alert", "moderate": "field_quarantined", + "severe": "rule_deactivated"}[severity] From d29f9b9ab1f268161c64259b0a3f6368332a6990 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 22:57:50 +0530 Subject: [PATCH 06/23] feat(drift): window scan + tier-1 detection + store + loop (R-M3-1/2/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth service, services/drift (Postgres only, no Kafka): stateless window recompute from the DB current view, tier-1 invariants from event zero, INSERT-only drift_windows writes, single fail-closed loop. - windows.py (pure): compute_windows closes on count_window rows OR when the window's first row ages past window_time_s against the NEWEST row's parsed_at (data-only horizon, never wall-clock); trailing partial held back; FieldStats(nulls/violations/values) per mapped OCSF path plus recorded unmapped.* keys; new_unmapped only against the baseline's first-window key set; tier1_violation mirrors validation._value_sanity (IPs strict dotted-quad str, ports/severity int-not-bool in range, time ISO-parsable str; None is a null, never a violation). - detect.py: Window -> drift_windows tuples; EMPTY HISTOGRAM GUARD (Task 3 review carry) — zero-parsed windows emit only the __rule__ row and empty value lists never reach the shape machinery; __rule__ carries match_rate = parsed/(parsed+parse_errors); action_taken stays NULL until Task 5's enforcement. - store.py: lazy-psycopg + autocommit house style; load_active_rules (ActiveDriftRule = Rule + id + quarantined_fields), fetch_current_view (unsuperseded active-version rows), insert_windows ON CONFLICT DO NOTHING in one txn, baseline_for/insert_baseline (existence-guarded, once), first_window_unmapped (baseline's first-window key set). INSERT-only — the 007 rules UPDATE grants are untouched until Task 5. - app.py: run_drift_loop(cfg, stop) — onboarding-loop shape, every failure logged with traceback and retried next poll; main() single-threaded (ONE loop, main thread). - config.py: DATABASE_URL + ULPF_DRIFT_{WINDOW_COUNT,WINDOW_TIME_S, BASELINE_WINDOWS,POLL_S}; defaults equal deploy/.env.example. - compose: drift service, no profile (always on), drift_role DSN + four knob passthroughs, depends_on migrate; docker compose config renders under default/slm-4b/slm-8b. 57 new tests (windows 23, detect 12, store 12, config 3, app 7); suite 338 passed; ruff clean. Co-Authored-By: Claude Code --- deploy/docker-compose.yml | 15 ++ services/drift/Dockerfile | 27 ++++ services/drift/__init__.py | 3 + services/drift/app.py | 96 +++++++++++ services/drift/config.py | 31 ++++ services/drift/detect.py | 86 ++++++++++ services/drift/requirements.txt | 1 + services/drift/store.py | 170 +++++++++++++++++++ services/drift/tests/__init__.py | 0 services/drift/tests/test_app.py | 197 ++++++++++++++++++++++ services/drift/tests/test_config.py | 34 ++++ services/drift/tests/test_detect.py | 163 +++++++++++++++++++ services/drift/tests/test_store.py | 228 ++++++++++++++++++++++++++ services/drift/tests/test_windows.py | 234 +++++++++++++++++++++++++++ services/drift/windows.py | 157 ++++++++++++++++++ 15 files changed, 1442 insertions(+) create mode 100644 services/drift/Dockerfile create mode 100644 services/drift/__init__.py create mode 100644 services/drift/app.py create mode 100644 services/drift/config.py create mode 100644 services/drift/detect.py create mode 100644 services/drift/requirements.txt create mode 100644 services/drift/store.py create mode 100644 services/drift/tests/__init__.py create mode 100644 services/drift/tests/test_app.py create mode 100644 services/drift/tests/test_config.py create mode 100644 services/drift/tests/test_detect.py create mode 100644 services/drift/tests/test_store.py create mode 100644 services/drift/tests/test_windows.py create mode 100644 services/drift/windows.py diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index dc7bae6..bb46ae2 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -82,6 +82,21 @@ services: OLLAMA_MODEL: ${ULPF_OLLAMA_MODEL:-qwen3:4b} restart: unless-stopped depends_on: { migrate: { condition: service_completed_successfully } } + # M3 drift detection (cold path, Postgres only — no Kafka, no profile so it + # is always on): statelessly recomputes per-fingerprint windows from the DB + # current view, fires tier-1 invariants from event zero, tier-2 statistics + # once a baseline exists; INSERT-only drift writes. Fail-closed: a failed + # scan is retried next poll — drift never blocks the hot path. + drift: + build: { context: .., dockerfile: services/drift/Dockerfile } + environment: + DATABASE_URL: postgresql://drift_role:drift_dev@postgres:5432/ulpf + ULPF_DRIFT_WINDOW_COUNT: ${ULPF_DRIFT_WINDOW_COUNT:-1000} + ULPF_DRIFT_WINDOW_TIME_S: ${ULPF_DRIFT_WINDOW_TIME_S:-600} + ULPF_DRIFT_BASELINE_WINDOWS: ${ULPF_DRIFT_BASELINE_WINDOWS:-10} + ULPF_DRIFT_POLL_S: ${ULPF_DRIFT_POLL_S:-5} + restart: unless-stopped + depends_on: { migrate: { condition: service_completed_successfully } } # Ollama sidecars. No host port by design: only the onboarding service talks # to them, inside the compose network. The entrypoint serves, then blocks on # `ollama pull` until the tier's model is local, then waits on serve. diff --git a/services/drift/Dockerfile b/services/drift/Dockerfile new file mode 100644 index 0000000..306e6a9 --- /dev/null +++ b/services/drift/Dockerfile @@ -0,0 +1,27 @@ +# services/drift/Dockerfile — M3 drift detection: stateless window recompute, +# tier-1 invariants, INSERT-only drift_windows writes. Postgres only, no Kafka. +# Build context is the repo root (compose build.context: ..; see /.dockerignore). +FROM python:3.12-slim + +WORKDIR /app + +# BOTH libs first (pipeline-image pattern), then deps (layer caching), then +# the service package. /app contains drift/ and libs/ so `python -m drift.app` +# imports cleanly. psycopg is the only direct dep — windows/tier-1 are stdlib +# plus ulpf_core (regex/pydantic arrive via ulpf-core's own pins). +COPY libs/ulpf-core/pyproject.toml /app/libs/ulpf-core/pyproject.toml +COPY libs/ulpf-core/ulpf_core/ /app/libs/ulpf-core/ulpf_core/ +COPY libs/ocsf-schema/pyproject.toml /app/libs/ocsf-schema/pyproject.toml +COPY libs/ocsf-schema/ocsf_schema/ /app/libs/ocsf-schema/ocsf_schema/ +COPY services/drift/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt \ + && pip install --no-cache-dir -e /app/libs/ulpf-core -e /app/libs/ocsf-schema + +COPY services/drift/__init__.py /app/drift/__init__.py +COPY services/drift/config.py /app/drift/config.py +COPY services/drift/windows.py /app/drift/windows.py +COPY services/drift/detect.py /app/drift/detect.py +COPY services/drift/store.py /app/drift/store.py +COPY services/drift/app.py /app/drift/app.py + +CMD ["python", "-m", "drift.app"] diff --git a/services/drift/__init__.py b/services/drift/__init__.py new file mode 100644 index 0000000..4af5261 --- /dev/null +++ b/services/drift/__init__.py @@ -0,0 +1,3 @@ +# services/drift — M3 drift-detection service (cold path, Postgres only): +# stateless window recompute from the current view, tier-1 invariants from +# event zero, INSERT-only drift_windows writes (plan Task 4). diff --git a/services/drift/app.py b/services/drift/app.py new file mode 100644 index 0000000..41541ff --- /dev/null +++ b/services/drift/app.py @@ -0,0 +1,96 @@ +# services/drift/app.py — the drift service entrypoint: ONE convergent loop +# (cold path, Postgres only — no Kafka). +# +# Per poll: load active rules, then for each rule one stateless scan — +# fetch_current_view (the DB current view) -> compute_windows (pure, data-only +# horizon) -> window_rows (tier-1) -> insert_windows (idempotent by the +# deterministic window key). Fail-closed posture: drift NEVER blocks anything +# else — every exception is logged with traceback and the loop continues; a +# failed scan is simply retried on the next poll. Tier-2 evaluation, baselines +# and enforcement join this loop in Task 5. +import logging +import threading + +from drift.config import Config +from drift.detect import window_rows +from drift.store import ( + ActiveDriftRule, + baseline_for, + connect_db, + fetch_current_view, + first_window_unmapped, + insert_windows, + load_active_rules, +) +from drift.windows import compute_windows + +log = logging.getLogger("drift.app") + + +def scan_rule(cfg: Config, rule: ActiveDriftRule) -> int: + """One scan for one active rule; returns the number of CLOSED windows + found (0 when the current view holds nothing provably complete). One + connection covers the whole scan (the reparse per-sweep pattern).""" + with connect_db(cfg.database_url) as conn: + rows = fetch_current_view(conn, rule.fingerprint_id, rule.version) + baseline = baseline_for(conn, rule.fingerprint_id, rule.version) + known = (first_window_unmapped(conn, rule.fingerprint_id, rule.version) + if baseline is not None else None) + windows = compute_windows( + rows, + count_window=cfg.window_count, + time_window_s=cfg.window_time_s, + mapped_paths=tuple(dict.fromkeys(m.ocsf_path for m in rule.mappings)), + known_unmapped=known, + ) + if not windows: + return 0 + insert_windows(conn, window_rows(rule, windows)) + log.info("scanned %s v%d: %d closed window(s) over %d current-view row(s)", + rule.fingerprint_id, rule.version, len(windows), len(rows)) + return len(windows) + + +def run_drift_loop(cfg: Config, stop: threading.Event) -> None: + """Poll for active rules and scan each, serially (cold path). `stop.set()` + ends the loop promptly (wait() is sliced by the event). Every exception is + logged; the loop continues — windows are recomputed from the DB next poll, + so a failed scan self-heals (fail-closed, never blocks the hot path).""" + log.info("drift loop started (window_count=%d, window_time_s=%d, poll=%.1fs)", + cfg.window_count, cfg.window_time_s, cfg.poll_s) + while not stop.is_set(): + try: + with connect_db(cfg.database_url) as conn: + rules = load_active_rules(conn) + for rule in rules: + if stop.is_set(): + break + try: + scan_rule(cfg, rule) + except Exception: + log.exception("drift scan failed for %s v%d; continuing", + rule.fingerprint_id, rule.version) + except Exception: + log.exception("drift loop iteration failed; will retry") + stop.wait(cfg.poll_s) + log.info("drift loop stopped") + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" + ) + cfg = Config.from_env() + # ONE loop, run in the MAIN thread (controller ruling — unlike onboarding's + # two daemon loops): SIGINT lands here directly and ends the wait-sliced + # loop; run_drift_loop stays daemon-friendly for any hosting thread. + stop = threading.Event() + try: + run_drift_loop(cfg, stop) + except KeyboardInterrupt: + stop.set() + log.info("shutdown requested") + + +if __name__ == "__main__": + main() diff --git a/services/drift/config.py b/services/drift/config.py new file mode 100644 index 0000000..6178925 --- /dev/null +++ b/services/drift/config.py @@ -0,0 +1,31 @@ +# services/drift/config.py — drift service configuration. +# +# Compose env contract (Task 1 pinned these exact names in deploy/.env.example +# and docker-compose passes them through): DATABASE_URL plus the four +# ULPF_DRIFT_* knobs. Defaults equal the .env.example values (binding ruling); +# the DSN follows the per-service constant pattern (env overrides). +import os +from dataclasses import dataclass + + +@dataclass +class Config: + database_url: str = "postgresql://drift_role:drift_dev@postgres:5432/ulpf" + window_count: int = 1000 # rows per window (count-close boundary) + window_time_s: int = 600 # close a window whose FIRST row ages out + baseline_windows: int = 10 # windows aggregated into the baseline (Task 5) + poll_s: float = 5.0 + + @classmethod + def from_env(cls) -> "Config": + base = cls() + return cls( + database_url=os.environ.get("DATABASE_URL", base.database_url), + window_count=int(os.environ.get("ULPF_DRIFT_WINDOW_COUNT", + base.window_count)), + window_time_s=int(os.environ.get("ULPF_DRIFT_WINDOW_TIME_S", + base.window_time_s)), + baseline_windows=int(os.environ.get("ULPF_DRIFT_BASELINE_WINDOWS", + base.baseline_windows)), + poll_s=float(os.environ.get("ULPF_DRIFT_POLL_S", base.poll_s)), + ) diff --git a/services/drift/detect.py b/services/drift/detect.py new file mode 100644 index 0000000..11955ca --- /dev/null +++ b/services/drift/detect.py @@ -0,0 +1,86 @@ +# services/drift/detect.py — Window -> drift_windows row assembly (R-M3-4). +# +# Tier-1 only at this layer: mapped-field rows carry the violation-rate ladder +# (ulpf_core.drift.tier1_severity), the '__rule__' sentinel carries match_rate. +# Tier-2 comparisons (null/match/shape ladders against a baseline) and the +# enforcement side (severity -> action, quarantine, deactivate, alerts) are +# Task 5's evaluate/enforce layers on top of these rows. +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ulpf_core.drift import shape_histogram, tier1_severity + +from drift.windows import FieldStats, Window + +if TYPE_CHECKING: + from drift.store import ActiveDriftRule + +# drift_windows.field vocabulary (migration 007): OCSF path, 'unmapped.', +# or the per-window rule-level sentinel that carries match_rate. +RULE_FIELD = "__rule__" +UNMAPPED_PREFIX = "unmapped." + +# Column order of the drift_windows INSERT in store.insert_windows (this is +# the tuple contract the two modules share; scanned_at uses its DB default). +_ROW_COLUMNS = ( + "fingerprint_id", "rule_version", "field", "window_start", "window_end", + "events_count", "null_rate", "match_rate", "violation_rate", "shape_dist", + "severity", "action_taken", +) + + +def shape_dist(stats: FieldStats) -> dict: + """Histogram for one field's stats — EMPTY HISTOGRAM GUARD (Task 3 review + carry, binding): an empty value list short-circuits to {} BEFORE the shape + machinery runs, so js_divergence is never fed an empty histogram and a + field with no observed values stays silent (silence is the volume ladder's + job, never a shape comparison).""" + return shape_histogram(stats.values) if stats.values else {} + + +def window_rows(rule: ActiveDriftRule, windows: list[Window]) -> list[tuple]: + """One drift_windows tuple per (window, field), in _ROW_COLUMNS order. + + A window with zero parsed docs emits ONLY the __rule__ row: with no docs + there is nothing to shape-compare (guard) and no tier-1 denominator. + action_taken stays NULL — Task 5's enforcement decides and audits it. + """ + rows: list[tuple] = [] + for win in windows: + if win.parsed: + for field, stats in win.field_stats.items(): + rows.append(_field_row(rule, win, field, stats)) + rows.append(_rule_row(rule, win)) + return rows + + +def _field_row(rule: ActiveDriftRule, win: Window, field: str, + stats: FieldStats) -> tuple: + is_unmapped = field.startswith(UNMAPPED_PREFIX) + violation_rate = None if is_unmapped else stats.violations / win.parsed + severity = "none" if is_unmapped else tier1_severity(violation_rate) + return ( + rule.fingerprint_id, rule.version, field, + win.start, win.end, win.count, + stats.nulls / win.parsed, # null_rate + None, # match_rate is __rule__-only + violation_rate, + shape_dist(stats), + severity, + None, # action_taken (Task 5 enforcement) + ) + + +def _rule_row(rule: ActiveDriftRule, win: Window) -> tuple: + denominator = win.parsed + win.parse_errors + match_rate = win.parsed / denominator if denominator else None + return ( + rule.fingerprint_id, rule.version, RULE_FIELD, + win.start, win.end, win.count, + None, match_rate, None, None, + # The match-rate severity ladder is tier-2 (needs the Task 5 baseline); + # recorded 'none' until evaluate_window joins the loop. + "none", + None, + ) diff --git a/services/drift/requirements.txt b/services/drift/requirements.txt new file mode 100644 index 0000000..6b5ba0d --- /dev/null +++ b/services/drift/requirements.txt @@ -0,0 +1 @@ +psycopg[binary]==3.2.1 diff --git a/services/drift/store.py b/services/drift/store.py new file mode 100644 index 0000000..65d170f --- /dev/null +++ b/services/drift/store.py @@ -0,0 +1,170 @@ +# services/drift/store.py — all drift DB access (cold path, Postgres only). +# +# Every function takes a live connection (the app loop opens one per scan) and +# imports psycopg lazily, so unit tests drive everything through fake conns — +# never a live DB. Connections run AUTOCOMMIT (the T7 house style): each +# multi-statement operation wraps exactly one `with conn.transaction():` block, +# single statements rely on autocommit. +# +# Grant shape (migration 007): drift_role has SELECT on normalized_events, +# rules, drift_windows and baseline_profiles, and INSERT on drift_windows, +# baseline_profiles and audit_log. Detection is INSERT-only — no UPDATE +# anywhere in this module (the rules UPDATE grants are Task 5's enforce layer). +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from pydantic import Field +from ulpf_core.models import Mapping, Rule + +if TYPE_CHECKING: + import psycopg + +log = logging.getLogger("drift.store") + +# Audit actor for every drift-side action (Task 5's enforce layer reuses it). +ACTOR = "drift" + + +class ActiveDriftRule(Rule): + """Rule plus the rules-table serial id and the M3 quarantine list (the + pipeline's rules.ActiveRule pattern). pattern is fetched only because Rule + requires it — the scan never compiles it.""" + + id: int + quarantined_fields: list[str] = Field(default_factory=list) + + +def connect_db(url: str) -> psycopg.Connection: + """psycopg connection for drift_role; autocommit per house style.""" + import psycopg # deferred: unit tests never need psycopg + + return psycopg.connect(url, autocommit=True) + + +def load_active_rules(conn: psycopg.Connection) -> list[ActiveDriftRule]: + """All status='active' rules, ordered by fingerprint_id for a deterministic + scan order. mappings arrives as JSONB (list of {source_field, ocsf_path}) + and is rebuilt into the typed Mapping model; quarantined_fields as TEXT[]. + provenance is fetched only because Rule requires it (the reparse ruling). + """ + with conn.cursor() as cur: + cur.execute( + "SELECT id, fingerprint_id, version, pattern, mappings, " + "quarantined_fields, provenance FROM rules WHERE status = 'active' " + "ORDER BY fingerprint_id" + ) + return [ + ActiveDriftRule( + id=rule_id, + fingerprint_id=fingerprint_id, + version=version, + pattern=pattern, + mappings=[Mapping(**m) for m in mappings], + provenance=provenance, + quarantined_fields=list(quarantined or []), + ) + for (rule_id, fingerprint_id, version, pattern, mappings, + quarantined, provenance) in cur.fetchall() + ] + + +def fetch_current_view(conn: psycopg.Connection, fingerprint_id: str, + version: int) -> list[tuple]: + """The DB current view (ne_current_idx semantics): unsuperseded + normalized_events rows parsed under the ACTIVE version, oldest first. A + fingerprint with only unparsed rows (rule_version 0) has no rows here — + and therefore no windows.""" + with conn.cursor() as cur: + cur.execute( + "SELECT parsed_at, status, ocsf FROM normalized_events " + "WHERE fingerprint_id = %s AND rule_version = %s " + "AND superseded_by_event_id IS NULL ORDER BY parsed_at", + (fingerprint_id, version), + ) + return cur.fetchall() + + +def insert_windows(conn: psycopg.Connection, rows: list[tuple]) -> None: + """One INSERT per drift_windows row, idempotent by the deterministic PK + (fingerprint_id, rule_version, field, window_start) — a re-scan of already + closed windows INSERTs nothing. The whole batch is one transaction so a + crash never leaves a half-written window. shape_dist (dict) is wrapped for + JSONB here, at the SQL boundary.""" + from psycopg.types.json import Json # deferred: keeps psycopg off the test path + + sql = ( + "INSERT INTO drift_windows (fingerprint_id, rule_version, field, " + "window_start, window_end, events_count, null_rate, match_rate, " + "violation_rate, shape_dist, severity, action_taken) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) " + "ON CONFLICT (fingerprint_id, rule_version, field, window_start) DO NOTHING" + ) + with conn.transaction(), conn.cursor() as cur: + for row in rows: + shape = row[9] + params = row[:9] + (Json(shape) if shape is not None else None,) + row[10:] + cur.execute(sql, params) + log.info("inserted up to %d drift window rows (conflicts are no-ops)", len(rows)) + + +def baseline_for(conn: psycopg.Connection, fingerprint_id: str, + version: int) -> dict | None: + """{field: profile} for the (fingerprint, version) baseline, or None while + no baseline exists (tier-1-only mode). profile is the JSONB dict written + at establishment (Task 5).""" + with conn.cursor() as cur: + cur.execute( + "SELECT field, profile FROM baseline_profiles " + "WHERE fingerprint_id = %s AND rule_version = %s", + (fingerprint_id, version), + ) + rows = cur.fetchall() + return {field: profile for field, profile in rows} or None + + +def first_window_unmapped(conn: psycopg.Connection, fingerprint_id: str, + version: int) -> set[str]: + """Unmapped keys of the baseline's FIRST window — the key set new unmapped + keys are measured against (Step 1: absent from the mapped targets AND from + this set). Keys are returned unprefixed (as doc['unmapped'] keys).""" + with conn.cursor() as cur: + cur.execute( + "SELECT DISTINCT field FROM drift_windows " + "WHERE fingerprint_id = %s AND rule_version = %s " + "AND field LIKE 'unmapped.%%' " + "AND window_start = (SELECT MIN(window_start) FROM drift_windows " + "WHERE fingerprint_id = %s AND rule_version = %s)", + (fingerprint_id, version, fingerprint_id, version), + ) + prefix_len = len("unmapped.") + return {row[0][prefix_len:] for row in cur.fetchall()} + + +def insert_baseline(conn: psycopg.Connection, fingerprint_id: str, version: int, + profiles: dict, windows_seen: int) -> bool: + """Insert the baseline profiles once: an existence SELECT guards the write + (the plan's INSERT-once semantics) and ON CONFLICT DO NOTHING backs it + against a race. One transaction covers guard + inserts. True iff rows were + written; False when a baseline already exists (a no-op, nothing audited).""" + from psycopg.types.json import Json # deferred: keeps psycopg off the test path + + with conn.transaction(), conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM baseline_profiles WHERE fingerprint_id = %s " + "AND rule_version = %s LIMIT 1", + (fingerprint_id, version), + ) + if cur.fetchone(): # exists (None/[] both falsy, [1] truthy) + return False + for field in sorted(profiles): + cur.execute( + "INSERT INTO baseline_profiles (fingerprint_id, rule_version, " + "field, profile, windows_seen) VALUES (%s, %s, %s, %s, %s) " + "ON CONFLICT (fingerprint_id, rule_version, field) DO NOTHING", + (fingerprint_id, version, field, Json(profiles[field]), windows_seen), + ) + log.info("baseline established for %s v%d over %d windows (%d fields)", + fingerprint_id, version, windows_seen, len(profiles)) + return True diff --git a/services/drift/tests/__init__.py b/services/drift/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/drift/tests/test_app.py b/services/drift/tests/test_app.py new file mode 100644 index 0000000..f34846c --- /dev/null +++ b/services/drift/tests/test_app.py @@ -0,0 +1,197 @@ +# services/drift/tests/test_app.py — loop wiring tests (onboarding-loop shape): +# fail-closed scan retries, the loop never raises out, scan_rule threading +# (baseline -> known_unmapped), and the single-loop main(). Monkeypatch style: +# app.py imports its store collaborators by name, so patching the app module's +# globals observes the wiring; NEVER a live DB. +import threading +from datetime import UTC, datetime, timedelta + +from ulpf_core.models import Mapping + +from drift import app +from drift.config import Config +from drift.store import ActiveDriftRule + + +def _ts(seconds: float) -> datetime: + return datetime(2026, 9, 21, 10, 0, 0, tzinfo=UTC) + timedelta(seconds=seconds) + + +def _rule(fp="fp_a", rule_id=7, version=2): + return ActiveDriftRule( + id=rule_id, fingerprint_id=fp, version=version, pattern=r"x", + mappings=[Mapping(source_field="SRC", ocsf_path="src_endpoint.ip")], + provenance="human", quarantined_fields=[]) + + +def _doc(ip="198.51.100.4"): + return {"src_endpoint": {"ip": ip, "port": 443}, "severity_id": 3, + "time": "2026-09-19T10:00:03Z", "unmapped": {}} + + +class FakeStoreConn: + """Just the context-manager role of a psycopg connection.""" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def _fast_cfg(**kw): + kw.setdefault("poll_s", 0.0) # stop.wait(0): the loop test never sleeps + return Config(**kw) + + +# --- the loop: fail-closed, never raises out ------------------------------------ + + +def test_loop_continues_after_a_failed_iteration(monkeypatch): + stop = threading.Event() + loads = {"n": 0} + scanned = [] + + def fake_load(conn): + loads["n"] += 1 + if loads["n"] == 1: + raise RuntimeError("db down") # first poll fails... + return [_rule()] # ...the next one scans + + def fake_scan(cfg, rule): + scanned.append(rule.fingerprint_id) + stop.set() # done: end the loop from inside the scan + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "load_active_rules", fake_load) + monkeypatch.setattr(app, "scan_rule", fake_scan) + + app.run_drift_loop(_fast_cfg(), stop) + assert loads["n"] == 2 and scanned == ["fp_a"] + + +def test_loop_never_raises_out_when_everything_fails(monkeypatch): + stop = threading.Event() + loads = {"n": 0} + + def fake_load(conn): + loads["n"] += 1 + if loads["n"] >= 3: + stop.set() + raise RuntimeError("still down") + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "load_active_rules", fake_load) + + app.run_drift_loop(_fast_cfg(), stop) # returns instead of raising + assert loads["n"] == 3 + + +def test_loop_continues_past_a_failed_single_scan(monkeypatch): + stop = threading.Event() + + def fake_load(conn): + return [_rule("fp_a"), _rule("fp_b")] + + def fake_scan(cfg, rule): + if rule.fingerprint_id == "fp_a": + raise RuntimeError("scan blew up") + stop.set() # fp_b still scanned after fp_a failed + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "load_active_rules", fake_load) + monkeypatch.setattr(app, "scan_rule", fake_scan) + + app.run_drift_loop(_fast_cfg(), stop) + assert stop.is_set() # reached fp_b: one failed rule does not stop the sweep + + +# --- scan_rule wiring ------------------------------------------------------------- + + +def test_scan_rule_threads_baseline_into_known_unmapped_and_inserts(monkeypatch): + view = [(_ts(0), "parsed", _doc()), (_ts(1), "parsed", _doc(ip="SRC=1.2.3.4"))] + baseline = {"src_endpoint.ip": {"null_rate": 0.0}, "unmapped.IN": {"null_rate": 0.0}} + captured, inserted = {}, [] + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: baseline) + monkeypatch.setattr(app, "first_window_unmapped", + lambda conn, fp, v: {"IN", "OUT"}) + + real_compute = app.compute_windows + + def spy(rows, **kw): + captured.update(kw) + return real_compute(rows, **kw) + + monkeypatch.setattr(app, "compute_windows", spy) + monkeypatch.setattr(app, "insert_windows", + lambda conn, rows: inserted.extend(rows)) + + n = app.scan_rule(_fast_cfg(window_count=2), _rule()) + assert n == 1 # one closed window + # Rule context threaded into the pure window math: the rule's mapped + # targets, and the baseline's first-window keys (NOT the raw baseline). + assert captured["mapped_paths"] == ("src_endpoint.ip",) + assert captured["known_unmapped"] == {"IN", "OUT"} + assert captured["count_window"] == 2 and captured["time_window_s"] == 600 + fields = [row[2] for row in inserted] + assert "src_endpoint.ip" in fields and "__rule__" in fields + # 1 violation of 2 parsed docs -> violation_rate 0.5 -> tier-1 'severe'. + ip_row = next(row for row in inserted if row[2] == "src_endpoint.ip") + assert ip_row[8] == 0.5 and ip_row[10] == "severe" + + +def test_scan_rule_without_baseline_passes_known_none(monkeypatch): + captured = {} + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: []) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: None) + monkeypatch.setattr(app, "first_window_unmapped", + lambda conn, fp, v: (_ for _ in ()).throw( + AssertionError("must not run pre-baseline"))) + real_compute = app.compute_windows + + def spy(rows, **kw): + captured.update(kw) + return real_compute(rows, **kw) + + monkeypatch.setattr(app, "compute_windows", spy) + + assert app.scan_rule(_fast_cfg(), _rule()) == 0 # no rows -> no windows + assert captured["known_unmapped"] is None # pre-baseline: keys recorded, none new + + +def test_scan_rule_holds_back_trailing_partial(monkeypatch): + # 3 fresh rows with window_count=10: nothing is provably closed, so + # insert_windows never runs. + view = [(_ts(i), "parsed", _doc()) for i in range(3)] + inserted = [] + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: None) + monkeypatch.setattr(app, "insert_windows", + lambda conn, rows: inserted.extend(rows)) + + assert app.scan_rule(_fast_cfg(), _rule()) == 0 + assert inserted == [] + + +# --- main(): ONE loop, single-threaded ------------------------------------------- + + +def test_main_runs_the_single_loop_in_the_main_thread(monkeypatch): + invoked = [] + + def fake_loop(cfg, stop): + invoked.append((cfg, stop, threading.current_thread() is threading.main_thread())) + + monkeypatch.setattr(app, "run_drift_loop", fake_loop) + app.main() + + assert len(invoked) == 1 # ONE loop (controller ruling), no daemon threads + cfg, stop, in_main = invoked[0] + assert isinstance(cfg, Config) and isinstance(stop, threading.Event) + assert in_main diff --git a/services/drift/tests/test_config.py b/services/drift/tests/test_config.py new file mode 100644 index 0000000..e82b906 --- /dev/null +++ b/services/drift/tests/test_config.py @@ -0,0 +1,34 @@ +# services/drift/tests/test_config.py — pins the binding env-knob ruling +# (deploy/.env.example names, compose-internal drift_role DSN, defaults equal +# to the .env.example values). +from drift.config import Config + + +def test_defaults_match_the_env_example_ruling(): + cfg = Config() + assert cfg.database_url == "postgresql://drift_role:drift_dev@postgres:5432/ulpf" + assert cfg.window_count == 1000 + assert cfg.window_time_s == 600 + assert cfg.baseline_windows == 10 + assert cfg.poll_s == 5.0 + + +def test_from_env_reads_exactly_the_five_compose_names(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "postgresql://x:x@db:5432/ulpf") + monkeypatch.setenv("ULPF_DRIFT_WINDOW_COUNT", "2500") + monkeypatch.setenv("ULPF_DRIFT_WINDOW_TIME_S", "300") + monkeypatch.setenv("ULPF_DRIFT_BASELINE_WINDOWS", "20") + monkeypatch.setenv("ULPF_DRIFT_POLL_S", "0.5") + cfg = Config.from_env() + assert cfg.database_url == "postgresql://x:x@db:5432/ulpf" + assert cfg.window_count == 2500 + assert cfg.window_time_s == 300 + assert cfg.baseline_windows == 20 + assert cfg.poll_s == 0.5 + + +def test_from_env_without_env_keeps_defaults(monkeypatch): + for name in ("DATABASE_URL", "ULPF_DRIFT_WINDOW_COUNT", "ULPF_DRIFT_WINDOW_TIME_S", + "ULPF_DRIFT_BASELINE_WINDOWS", "ULPF_DRIFT_POLL_S"): + monkeypatch.delenv(name, raising=False) + assert Config.from_env() == Config() diff --git a/services/drift/tests/test_detect.py b/services/drift/tests/test_detect.py new file mode 100644 index 0000000..9278dd9 --- /dev/null +++ b/services/drift/tests/test_detect.py @@ -0,0 +1,163 @@ +# services/drift/tests/test_detect.py — Window -> drift_windows row assembly +# (tier-1 severity, __rule__ match_rate, the EMPTY HISTOGRAM GUARD from Task 3's +# review). Pure functions + monkeypatched shape_histogram; NEVER a live DB. +from datetime import UTC, datetime, timedelta + +import pytest +from ulpf_core.models import Mapping + +from drift import detect +from drift.detect import RULE_FIELD, shape_dist, window_rows +from drift.store import ActiveDriftRule +from drift.windows import FieldStats, compute_windows + + +def _ts(seconds: float) -> datetime: + return datetime(2026, 9, 21, 10, 0, 0, tzinfo=UTC) + timedelta(seconds=seconds) + + +def _doc(ip="198.51.100.4", unmapped=None): + return {"src_endpoint": {"ip": ip, "port": 443}, "severity_id": 3, + "time": "2026-09-19T10:00:03Z", + "unmapped": unmapped if unmapped is not None else {}} + + +def _rule(): + return ActiveDriftRule( + id=7, fingerprint_id="fp_a", version=2, pattern=r"x", + mappings=[Mapping(source_field="SRC", ocsf_path="src_endpoint.ip")], + provenance="human", quarantined_fields=["dst_endpoint.ip"]) + + +def _windows(rows, rule=None, **kw): + rule = rule or _rule() + return compute_windows(rows, count_window=len(rows), time_window_s=600, + mapped_paths=tuple(m.ocsf_path for m in rule.mappings), + **kw) + + +# --- __rule__ sentinel row: match_rate = parsed / (parsed + parse_errors) ------ + + +def test_rule_row_carries_match_rate(): + rows = ([(_ts(i), "parsed", _doc()) for i in range(3)] + + [(_ts(3), "parse_error", None)]) + (rule_row,) = [r for r in window_rows(_rule(), _windows(rows)) if r[2] == RULE_FIELD] + fp, version, field, start, end, count, null_rate, match_rate, violation_rate, \ + shape, severity, action = rule_row + assert (fp, version, field) == ("fp_a", 2, "__rule__") + assert count == 4 and start == _ts(0) and end == _ts(3) + assert match_rate == pytest.approx(3 / 4) + # No rates, no shape, no tier-1 on the sentinel; the match-rate severity + # ladder needs a baseline (Task 5's evaluate) — recorded 'none' for now. + assert null_rate is None and violation_rate is None and shape is None + assert severity == "none" and action is None + + +def test_rule_row_zero_parsed_docs_match_rate_is_zero(): + rows = [(_ts(0), "parse_error", None), (_ts(1), "parse_error", None)] + (rule_row,) = window_rows(_rule(), _windows(rows)) + assert rule_row[7] == 0.0 + + +def test_rule_row_match_rate_none_when_no_denominator(): + # Defensive: a row with neither parsed nor parse_error status cannot happen + # in the current view (unparsed rows carry rule_version 0), but the rate + # degrades to NULL instead of dividing by zero. + from drift.windows import Window + synthetic = Window(start=_ts(0), end=_ts(0), count=1, parsed=0, parse_errors=0, + field_stats={}, new_unmapped=set()) + (rule_row,) = window_rows(_rule(), [synthetic]) + assert rule_row[7] is None + + +# --- mapped field rows: tier-1 severity ladder --------------------------------- + + +def test_mapped_field_row_tier1_severity_and_rates(): + # 1 violation of 4 parsed docs = 0.25 -> tier1_severity ladder 'moderate'. + rows = ([(_ts(i), "parsed", _doc("SRC=198.51.100.4")) for i in (0,)] + + [(_ts(i), "parsed", _doc()) for i in (1, 2, 3)]) + (ip_row,) = [r for r in window_rows(_rule(), _windows(rows)) + if r[2] == "src_endpoint.ip"] + assert ip_row[5] == 4 # events_count + assert ip_row[6] == 0.0 # null_rate = 0/4 + assert ip_row[7] is None # match_rate is __rule__-only + assert ip_row[8] == pytest.approx(0.25) # violation_rate = 1/4 + assert ip_row[9] == {"ipv4": 3, "free_text": 1} # shape over non-null values + # (the violating SRC=... garbage classifies free_text — violators still bin) + assert ip_row[10] == "moderate" # tier1_severity(0.25) + assert ip_row[11] is None # action_taken: Task 5 enforcement + + +def test_mapped_field_row_zero_violations_is_none_severity(): + rows = [(_ts(i), "parsed", _doc()) for i in range(2)] + (ip_row,) = [r for r in window_rows(_rule(), _windows(rows)) + if r[2] == "src_endpoint.ip"] + assert ip_row[8] == 0.0 and ip_row[10] == "none" + + +def test_mapped_field_row_null_rate_counts_nulls(): + docs = [(_ts(0), "parsed", _doc(None)), (_ts(1), "parsed", _doc())] + (ip_row,) = [r for r in window_rows(_rule(), _windows(docs)) + if r[2] == "src_endpoint.ip"] + assert ip_row[6] == pytest.approx(0.5) + assert ip_row[8] == 0.0 # the null is not a violation + + +# --- unmapped.* rows: recorded always, no tier-1 invariant ---------------------- + + +def test_unmapped_field_rows_have_no_violation_rate(): + rows = [(_ts(0), "parsed", _doc(unmapped={"IN": "eth0"})), + (_ts(1), "parsed", _doc(unmapped={"IN": "eth1"}))] + (in_row,) = [r for r in window_rows(_rule(), _windows(rows)) + if r[2] == "unmapped.IN"] + assert in_row[6] == 0.0 # null_rate still measured + assert in_row[8] is None # no tier-1 invariant on unmapped + assert in_row[9] == {"free_text": 2} # shape still measured + assert in_row[10] == "none" # findings stay alert-only (Task 5) + + +# --- EMPTY HISTOGRAM GUARD (Task 3 review carry, binding) ----------------------- + + +def test_zero_parsed_window_emits_only_the_rule_row(): + # A window with zero parsed docs skips shape comparison ENTIRELY — silence + # is the volume ladder's job (Task 5), never a shape comparison on {}. + rows = [(_ts(i), "parse_error", None) for i in range(3)] + assert window_rows(_rule(), _windows(rows)) == [ + ("fp_a", 2, "__rule__", _ts(0), _ts(2), 3, + None, 0.0, None, None, "none", None)] + + +def test_shape_dist_never_runs_on_an_empty_histogram(monkeypatch): + # The guard itself: an empty value list short-circuits to {} BEFORE the + # shape machinery (and therefore before any js_divergence caller in Task 5) + # ever sees it. + def boom(values): + raise AssertionError("shape machinery called on an empty histogram") + + monkeypatch.setattr(detect, "shape_histogram", boom) + assert shape_dist(FieldStats(nulls=3, violations=0, values=[])) == {} + + +def test_shape_dist_computes_histogram_from_values(): + stats = FieldStats(nulls=0, violations=0, values=["1.2.3.4", "DROP", 7]) + assert shape_dist(stats) == {"ipv4": 1, "free_text": 1, "int": 1} + + +# --- row shape: rule identity, determinism -------------------------------------- + + +def test_rows_are_deterministic_for_the_same_input(): + rows = ([(_ts(0), "parsed", _doc("SRC=1.2.3.4"))] + + [(_ts(i), "parsed", _doc()) for i in (1, 2)]) + assert (window_rows(_rule(), _windows(rows)) + == window_rows(_rule(), _windows(rows))) + + +def test_rows_order_is_mapped_then_sorted_unmapped_then_rule(): + rows = [(_ts(0), "parsed", _doc(unmapped={"Z": "1", "A": "2"}))] + fields = [r[2] for r in window_rows(_rule(), _windows(rows))] + assert fields == ["src_endpoint.ip", "unmapped.A", "unmapped.Z", "__rule__"] diff --git a/services/drift/tests/test_store.py b/services/drift/tests/test_store.py new file mode 100644 index 0000000..4b2d25d --- /dev/null +++ b/services/drift/tests/test_store.py @@ -0,0 +1,228 @@ +# services/drift/tests/test_store.py — store SQL pinned through a recording +# fake conn/cursor (the onboarding/pipeline house style; NEVER a live DB). +# +# The fake records (sql, params) in execution order and brackets each +# `with conn.transaction():` block with TXN-START / TXN-COMMIT (or +# TXN-ROLLBACK) markers. Fetch results are consumed FIFO. psycopg is not on +# the test path, so its Json wrapper is unwrapped duck-typed via .obj. +from ulpf_core.models import Mapping + +from drift.store import ( + ActiveDriftRule, + baseline_for, + fetch_current_view, + first_window_unmapped, + insert_baseline, + insert_windows, + load_active_rules, +) + + +class FakeConn: + """Fake psycopg connection: records SQL, replays scripted fetch results.""" + + def __init__(self, fetchone_results=(), fetchall_results=()): + self.sql: list = [] + self.params: list = [] + self.txns_opened = 0 + self._fetchone = list(fetchone_results) + self._fetchall = list(fetchall_results) + + def transaction(self): + conn = self + + class Txn: + def __enter__(self): + conn.txns_opened += 1 + conn.sql.append("TXN-START") + conn.params.append(None) # keep sql/params index-aligned + + def __exit__(self, exc_type, exc, tb): + conn.sql.append("TXN-ROLLBACK" if exc_type is not None else "TXN-COMMIT") + conn.params.append(None) + return False + + return Txn() + + def cursor(self): + conn = self + + class Cur: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def execute(self, sql, params=None): + conn.sql.append(sql) + conn.params.append(params) + + def fetchone(self): + return list(conn._fetchone.pop(0)) if conn._fetchone else [1] + + def fetchall(self): + return list(conn._fetchall.pop(0)) if conn._fetchall else [] + + return Cur() + + +def _unwrap(value): + """psycopg's Json adapter -> plain Python (duck-typed: no psycopg here).""" + return value.obj if hasattr(value, "obj") else value + + +def _rule_row(fp="fp_a", rule_id=7, version=2, quarantined=("dst_endpoint.ip",)): + return (rule_id, fp, version, r"x", [ + {"source_field": "SRC", "ocsf_path": "src_endpoint.ip"}], + list(quarantined) if quarantined is not None else None, "human") + + +# --- load_active_rules: id + mappings + quarantined_fields --------------------- + + +def test_load_active_rules_rebuilds_typed_rules_with_quarantine(): + conn = FakeConn(fetchall_results=[[_rule_row()]]) + rules = load_active_rules(conn) + + assert len(rules) == 1 and isinstance(rules[0], ActiveDriftRule) + rule = rules[0] + assert rule.id == 7 and rule.fingerprint_id == "fp_a" and rule.version == 2 + assert rule.mappings == [Mapping(source_field="SRC", ocsf_path="src_endpoint.ip")] + assert rule.quarantined_fields == ["dst_endpoint.ip"] + sql = next(s for s in conn.sql if isinstance(s, str)) + assert "FROM rules WHERE status = 'active'" in sql + assert "quarantined_fields" in sql and "mappings" in sql + assert "ORDER BY fingerprint_id" in sql # deterministic scan order + + +def test_load_active_rules_returns_rules_in_fingerprint_order(): + conn = FakeConn(fetchall_results=[[_rule_row("fp_b", 9), _rule_row("fp_a", 7)]]) + rules = load_active_rules(conn) + assert [r.fingerprint_id for r in rules] == ["fp_b", "fp_a"] # DB orders; passthrough + + +def test_load_active_rules_null_quarantine_becomes_empty_list(): + conn = FakeConn(fetchall_results=[[_rule_row(quarantined=None)]]) + assert load_active_rules(conn)[0].quarantined_fields == [] + + +# --- fetch_current_view: the DB current view, oldest first ---------------------- + + +def test_fetch_current_view_selects_unsuperseded_active_version_rows(): + rows = [("2026-09-21T10:00:00Z", "parsed", {"a": 1})] + conn = FakeConn(fetchall_results=[rows]) + assert fetch_current_view(conn, "fp_a", 2) == rows + + sql = next(s for s in conn.sql if isinstance(s, str)) + assert sql == ("SELECT parsed_at, status, ocsf FROM normalized_events " + "WHERE fingerprint_id = %s AND rule_version = %s " + "AND superseded_by_event_id IS NULL ORDER BY parsed_at") + assert conn.params[conn.sql.index(sql)] == ("fp_a", 2) + + +# --- insert_windows: idempotent by deterministic PK, one txn per batch ---------- + + +def test_insert_windows_on_conflict_do_nothing_in_one_transaction(): + row = ("fp_a", 2, "src_endpoint.ip", "t0", "t1", 4, 0.0, None, 0.25, + {"ipv4": 3}, "moderate", None) + conn = FakeConn() + insert_windows(conn, [row, row]) + + inserts = [(s, p) for s, p in zip(conn.sql, conn.params) + if isinstance(s, str) and s.startswith("INSERT INTO drift_windows")] + assert len(inserts) == 2 + sql, params = inserts[0] + assert "ON CONFLICT (fingerprint_id, rule_version, field, window_start) DO NOTHING" in sql + assert params[:3] == ("fp_a", 2, "src_endpoint.ip") + assert params[6] == 0.0 and params[8] == 0.25 and params[10] == "moderate" + assert params[11] is None # action_taken NULL until Task 5 + assert _unwrap(params[9]) == {"ipv4": 3} # shape_dist rides as JSONB + # One txn around the whole batch: a crash never leaves a half-written window. + assert conn.txns_opened == 1 + assert conn.sql[0] == "TXN-START" and conn.sql[-1] == "TXN-COMMIT" + + +def test_insert_windows_null_shape_dist_stays_null(): + row = ("fp_a", 2, "__rule__", "t0", "t1", 4, None, 1.0, None, None, "none", None) + conn = FakeConn() + insert_windows(conn, [row]) + params = next(p for s, p in zip(conn.sql, conn.params) + if isinstance(s, str) and s.startswith("INSERT INTO drift_windows")) + assert params[9] is None + + +def test_insert_windows_never_updates_anything(): + conn = FakeConn() + insert_windows(conn, [("fp_a", 2, "__rule__", "t0", "t1", 1, None, 1.0, + None, None, "none", None)]) + assert not [s for s in conn.sql if isinstance(s, str) + and s.upper().startswith("UPDATE")] # INSERT-only grants honored + + +# --- baseline_for: dict of profiles or None ------------------------------------- + + +def test_baseline_for_none_when_no_rows(): + conn = FakeConn(fetchall_results=[[]]) + assert baseline_for(conn, "fp_a", 2) is None + sql = next(s for s in conn.sql if isinstance(s, str)) + assert sql == ("SELECT field, profile FROM baseline_profiles " + "WHERE fingerprint_id = %s AND rule_version = %s") + assert conn.params[conn.sql.index(sql)] == ("fp_a", 2) + + +def test_baseline_for_maps_fields_to_profiles(): + profiles = [("src_endpoint.ip", {"null_rate": 0.1}), ("__rule__", {"match_rate": 1.0})] + conn = FakeConn(fetchall_results=[profiles]) + assert baseline_for(conn, "fp_a", 2) == { + "src_endpoint.ip": {"null_rate": 0.1}, "__rule__": {"match_rate": 1.0}} + + +# --- first_window_unmapped: the baseline's first-window key set ------------------ + + +def test_first_window_unmapped_reads_earliest_window_keys(): + conn = FakeConn(fetchall_results=[[("unmapped.IN",), ("unmapped.OUT",)]]) + assert first_window_unmapped(conn, "fp_a", 2) == {"IN", "OUT"} + sql = next(s for s in conn.sql if isinstance(s, str)) + assert "SELECT DISTINCT field FROM drift_windows" in sql + assert "field LIKE 'unmapped.%%'" in sql + assert "(SELECT MIN(window_start) FROM drift_windows" in sql + assert conn.params[conn.sql.index(sql)] == ("fp_a", 2, "fp_a", 2) + + +# --- insert_baseline: existence-guarded, once ----------------------------------- + + +def test_insert_baseline_skips_when_one_already_exists(): + conn = FakeConn() # default fetchone -> [1]: a row exists + inserted = insert_baseline(conn, "fp_a", 2, {"__rule__": {"match_rate": 1.0}}, 10) + + assert inserted is False + sql = [s for s in conn.sql if isinstance(s, str)] + assert any("SELECT 1 FROM baseline_profiles" in s for s in sql) + assert not [s for s in sql if s.startswith("INSERT")] # no write on the no-op + assert conn.txns_opened == 1 # guard still ran inside one txn + + +def test_insert_baseline_writes_each_field_once_with_conflict_noop(): + conn = FakeConn(fetchone_results=[[]]) # fetchone -> []: no baseline yet + profiles = {"src_endpoint.ip": {"null_rate": 0.1}, "__rule__": {"match_rate": 1.0}} + inserted = insert_baseline(conn, "fp_a", 2, profiles, 10) + + assert inserted is True + sql = [s for s in conn.sql if isinstance(s, str)] + guard = next(s for s in sql if "SELECT 1 FROM baseline_profiles" in s) + inserts = [s for s in sql if s.startswith("INSERT INTO baseline_profiles")] + assert len(inserts) == 2 # one row per field, sorted for determinism + assert all("ON CONFLICT (fingerprint_id, rule_version, field) DO NOTHING" in s + for s in inserts) + assert conn.txns_opened == 1 + assert sql.index(guard) < sql.index(inserts[0]) + first_params = conn.params[sql.index(inserts[0])] + assert first_params[:3] == ("fp_a", 2, "__rule__") # sorted() puts __rule__ first + assert first_params[4] == 10 # windows_seen + assert _unwrap(first_params[3]) == {"match_rate": 1.0} diff --git a/services/drift/tests/test_windows.py b/services/drift/tests/test_windows.py new file mode 100644 index 0000000..8b2e7e4 --- /dev/null +++ b/services/drift/tests/test_windows.py @@ -0,0 +1,234 @@ +# services/drift/tests/test_windows.py — pure window math + tier-1 semantics +# (plan Task 4 Steps 1/2), all with concrete rows/docs; NEVER a live DB. +from datetime import UTC, datetime, timedelta + +from drift.windows import FieldStats, Window, compute_windows, tier1_violation + + +def _ts(seconds: float) -> datetime: + """Aware UTC timestamp (the shape psycopg returns for TIMESTAMPTZ).""" + return datetime(2026, 9, 21, 10, 0, 0, tzinfo=UTC) + timedelta(seconds=seconds) + + +def _row(seconds: float, status: str = "parsed", ocsf: dict | None = None): + """One current-view row: (parsed_at, status, ocsf|None).""" + return (_ts(seconds), status, ocsf) + + +def _doc(ip="198.51.100.4", port=443, sev=3, time="2026-09-19T10:00:03Z", + unmapped: dict | None = None): + return { + "src_endpoint": {"ip": ip, "port": port}, + "severity_id": sev, + "time": time, + "unmapped": unmapped if unmapped is not None else {}, + } + + +# --- compute_windows: count-close / time-close / trailing held back ------------ + + +def _fast_row(millis: int, status: str = "parsed", ocsf: dict | None = None): + """Row packed milliseconds apart — 1000 rows stay well inside window_time_s, + so only the COUNT boundary can close these windows.""" + return (_ts(0) + timedelta(milliseconds=millis), status, ocsf) + + +def test_count_close_exactly_at_boundary(): + # The 1000th row CLOSES the window; the 1001st STARTS a new one — which is + # a trailing partial and therefore held back (never emitted). + rows = [_fast_row(i) for i in range(1001)] + windows = compute_windows(rows, count_window=1000, time_window_s=600) + assert len(windows) == 1 + assert windows[0].count == 1000 + assert windows[0].start == rows[0][0] and windows[0].end == rows[999][0] + + +def test_two_full_windows_close_cleanly(): + rows = [_fast_row(i) for i in range(2000)] + windows = compute_windows(rows, count_window=1000, time_window_s=600) + assert [w.count for w in windows] == [1000, 1000] + assert windows[1].start == rows[1000][0] and windows[1].end == rows[1999][0] + + +def test_time_close_low_volume_without_reaching_count(): + # 3 rows spread beyond window_time_s: the first row is older than the + # horizon of the newest row, so its window closes on TIME at count 1; the + # fresh tail [700, 800] is the trailing partial and is held back. + rows = [_row(0), _row(700), _row(800)] + windows = compute_windows(rows, count_window=1000, time_window_s=600) + assert len(windows) == 1 + assert windows[0].count == 1 + assert windows[0].start == _ts(0) and windows[0].end == _ts(0) + + +def test_trailing_partial_window_is_never_emitted(): + rows = [_row(i) for i in range(5)] + assert compute_windows(rows, count_window=10, time_window_s=600) == [] + + +def test_window_start_and_end_are_first_and_last_parsed_at(): + rows = [_row(i) for i in range(7)] + windows = compute_windows(rows, count_window=3, time_window_s=600) + # [0,1,2] and [3,4,5] close on count; [6] is trailing and held back. + assert len(windows) == 2 + assert windows[0].start == _ts(0) and windows[0].end == _ts(2) + assert windows[1].start == _ts(3) and windows[1].end == _ts(5) + + +def test_same_rows_twice_produce_identical_windows(): + rows = [_row(i, "parsed" if i % 3 else "parse_error", + _doc() if i % 3 else None) for i in range(50)] + first = compute_windows(rows, count_window=7, time_window_s=600, + mapped_paths=("src_endpoint.ip",)) + second = compute_windows(rows, count_window=7, time_window_s=600, + mapped_paths=("src_endpoint.ip",)) + assert first == second # determinism: data only, never wall-clock + + +def test_parsed_and_parse_errors_partition_the_window(): + rows = [_row(0, "parsed", _doc()), _row(1, "parsed", _doc()), + _row(2, "parse_error", None)] + windows = compute_windows(rows, count_window=3, time_window_s=600) + assert windows[0].count == 3 + assert windows[0].parsed == 2 and windows[0].parse_errors == 1 + + +def test_empty_rows_return_no_windows(): + assert compute_windows([], count_window=1000, time_window_s=600) == [] + + +# --- field stats: nulls / violations / values ---------------------------------- + + +def test_nulls_counted_not_tier1_violations(): + doc = _doc(ip=None) # mapped field present but None: a null, never a violation + windows = compute_windows([_row(0, "parsed", doc)], + count_window=1, time_window_s=600, + mapped_paths=("src_endpoint.ip",)) + assert windows[0].field_stats["src_endpoint.ip"] == FieldStats( + nulls=1, violations=0, values=[]) + + +def test_field_stats_values_collected_for_the_histogram(): + docs = [_row(0, "parsed", _doc(ip="198.51.100.4")), + _row(1, "parsed", _doc(ip="198.51.100.5"))] + windows = compute_windows(docs, count_window=2, time_window_s=600, + mapped_paths=("src_endpoint.ip",)) + assert windows[0].field_stats["src_endpoint.ip"].values == [ + "198.51.100.4", "198.51.100.5"] + + +def test_field_stats_covers_mapped_paths_that_never_fire(): + # A mapping whose group never matches: every parsed doc counts a null. + docs = [_row(0, "parsed", _doc()), _row(1, "parsed", _doc())] + windows = compute_windows(docs, count_window=2, time_window_s=600, + mapped_paths=("dst_endpoint.ip", "src_endpoint.ip")) + assert windows[0].field_stats["dst_endpoint.ip"].nulls == 2 + assert "dst_endpoint.ip" in windows[0].field_stats # present even at zero + + +def test_tier1_violations_counted_per_field_in_window(): + docs = [_row(0, "parsed", _doc(ip="SRC=198.51.100.4")), # firmware-drift garbage + _row(1, "parsed", _doc(ip="198.51.100.5")), + _row(2, "parsed", _doc(ip="DST=203.0.113.9")), + _row(3, "parsed", _doc(ip="198.51.100.6"))] + windows = compute_windows(docs, count_window=4, time_window_s=600, + mapped_paths=("src_endpoint.ip",)) + assert windows[0].field_stats["src_endpoint.ip"].violations == 2 + + +# --- unmapped keys: recorded always, "new" only against a baseline ------------- + + +def test_unmapped_keys_recorded_as_field_stats(): + docs = [_row(0, "parsed", _doc(unmapped={"IN": "eth0", "OUT": None})), + _row(1, "parsed", _doc(unmapped={"IN": "eth1"}))] + windows = compute_windows(docs, count_window=2, time_window_s=600) + stats = windows[0].field_stats + assert stats["unmapped.IN"].values == ["eth0", "eth1"] + assert stats["unmapped.IN"].nulls == 0 + assert stats["unmapped.OUT"].nulls == 1 and stats["unmapped.OUT"].values == [] + + +def test_new_unmapped_empty_without_baseline(): + # Before a baseline exists, keys are just RECORDED — none are "new". + rows = [_row(0, "parsed", _doc(unmapped={"IN": "eth0"}))] + windows = compute_windows(rows, count_window=1, time_window_s=600) + assert windows[0].new_unmapped == set() + + +def test_new_unmapped_flagged_against_known_set(): + rows = [_row(0, "parsed", _doc(unmapped={"IN": "eth0", "OUT": "wan"}))] + windows = compute_windows(rows, count_window=1, time_window_s=600, + known_unmapped=frozenset({"IN"})) + assert windows[0].new_unmapped == {"OUT"} + + +def test_unmapped_key_equal_to_a_mapped_target_never_new(): + # A quarantined mapping relocates its value into `unmapped` under the + # SOURCE key (Task 6); a key equal to a mapped OCSF target is never "new". + rows = [_row(0, "parsed", _doc(unmapped={"src_endpoint.ip": "1.2.3.4"}))] + windows = compute_windows(rows, count_window=1, time_window_s=600, + mapped_paths=("src_endpoint.ip",), + known_unmapped=frozenset()) + assert windows[0].new_unmapped == set() + + +# --- tier1_violation: mirrors validation._value_sanity, window-aggregated ------- + + +def test_ip_fields_require_dotted_quad_string(): + assert tier1_violation("src_endpoint.ip", "SRC=198.51.100.4") is True + assert tier1_violation("dst_endpoint.ip", "198.51.100.4") is False + assert tier1_violation("src_endpoint.ip", "999.999.999.999") is False # shape-level + assert tier1_violation("src_endpoint.ip", 123) is True # must be a str + assert tier1_violation("dst_endpoint.ip", "1.2.3.4.5") is True + + +def test_ports_must_be_int_in_0_65535(): + assert tier1_violation("src_endpoint.port", 70000) is True + assert tier1_violation("dst_endpoint.port", "443") is True # a str port is a failure + assert tier1_violation("src_endpoint.port", True) is True # bool is not an int + assert tier1_violation("src_endpoint.port", 0) is False + assert tier1_violation("dst_endpoint.port", 65535) is False + assert tier1_violation("src_endpoint.port", 65536) is True + assert tier1_violation("dst_endpoint.port", -1) is True + + +def test_severity_id_must_be_int_0_to_6(): + assert tier1_violation("severity_id", 9) is True + assert tier1_violation("severity_id", 3) is False + assert tier1_violation("severity_id", 0) is False + assert tier1_violation("severity_id", False) is True # bool is not an int + assert tier1_violation("severity_id", "5") is True # a str severity fails + + +def test_time_must_be_iso_parsable_string(): + assert tier1_violation("time", "not-a-ts") is True + assert tier1_violation("time", "2026-09-19T10:00:03Z") is False + assert tier1_violation("time", "2026-09-19") is False + assert tier1_violation("time", 12345) is True # must be a str + + +def test_none_is_a_null_not_a_violation(): + for path in ("src_endpoint.ip", "dst_endpoint.ip", "src_endpoint.port", + "dst_endpoint.port", "severity_id", "time"): + assert tier1_violation(path, None) is False + + +def test_unregulated_fields_never_violate(): + assert tier1_violation("message", "anything") is False + assert tier1_violation("action", 12345) is False + assert tier1_violation("unmapped.IN", "DROP") is False + + +def test_window_dataclass_is_frozen_shape(): + win = Window(start=_ts(0), end=_ts(1), count=1, parsed=1, parse_errors=0, + field_stats={}, new_unmapped=set()) + assert win.count == 1 + import dataclasses + + import pytest + with pytest.raises(dataclasses.FrozenInstanceError): + win.count = 2 diff --git a/services/drift/windows.py b/services/drift/windows.py new file mode 100644 index 0000000..4c5125a --- /dev/null +++ b/services/drift/windows.py @@ -0,0 +1,157 @@ +# services/drift/windows.py — pure window math over the current view (R-M3-4). +# +# Stateless recompute contract (spec §7.1): windows are derived ONLY from the +# rows handed in — ordered by parsed_at — and never from the wall clock, so +# the same rows always produce identical windows (idempotent INSERT keys). +# Detection math beyond counting (JS divergence, ladders) lives in +# ulpf_core.drift; this module owns slicing and the per-field aggregation. +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timedelta + +# Same dotted-quad shape as ulpf_core.validation._IP_RE / drift._IPV4_RE +# (four digit quads, no 0-255 enforcement — a shape test, not a parser). +_IPV4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") + + +@dataclass(frozen=True) +class FieldStats: + """Per-field aggregates for one window: null count, tier-1 invariant + violation count, and the non-null values (input of the shape histogram).""" + + nulls: int + violations: int + values: list + + +@dataclass(frozen=True) +class Window: + """One closed window of the current view (a trailing partial is never + emitted — it may still grow before the next scan).""" + + start: datetime # parsed_at of the window's first event + end: datetime # parsed_at of the window's last event + count: int # parsed + parse_error rows in the window + parsed: int + parse_errors: int + field_stats: dict[str, FieldStats] + new_unmapped: set[str] + + +def compute_windows(rows, *, count_window, time_window_s, + mapped_paths=(), known_unmapped=None) -> list[Window]: + """Slice current-view rows into closed windows. + + rows: (parsed_at, status, ocsf|None) ordered by parsed_at. A window closes + when it holds count_window rows OR when its FIRST row is older than + window_time_s — checked at scan time against the NEWEST row's parsed_at + (the data's own horizon, never the wall clock; determinism). The trailing + partial window is held back: it may still grow before the next scan. + + mapped_paths: the rule's mapped OCSF targets — the fields field_stats + covers (a mapped-but-None value is a null, never a tier-1 violation). + known_unmapped: None while no baseline exists (unmapped keys are recorded + but none are "new"); afterwards, the baseline's first-window key set, and + keys absent from BOTH it and the mapped targets are new (Step 1 contract). + + The frozen call form compute_windows(rows, count_window=..., + time_window_s=...) is unchanged; the two rule-context keywords default to + the pre-baseline, no-mappings behavior. + """ + if not rows: + return [] + horizon = rows[-1][0] - timedelta(seconds=time_window_s) + windows: list[Window] = [] + current: list = [] + for row in rows: + current.append(row) + if len(current) >= count_window or current[0][0] < horizon: + windows.append(_close_window(current, mapped_paths, known_unmapped)) + current = [] + return windows + + +def _close_window(rows, mapped_paths, known_unmapped) -> Window: + """Aggregate one closed window: counts, per-field stats, new unmapped.""" + docs = [ocsf for (_, status, ocsf) in rows if status == "parsed" and ocsf is not None] + parse_errors = sum(1 for (_, status, _) in rows if status == "parse_error") + + nulls = {path: 0 for path in mapped_paths} + violations = {path: 0 for path in mapped_paths} + values: dict[str, list] = {path: [] for path in mapped_paths} + un_nulls: dict[str, int] = {} + un_values: dict[str, list] = {} + for doc in docs: + for path in mapped_paths: + value = _doc_get(doc, path) + if value is None: + nulls[path] += 1 + else: + values[path].append(value) + if tier1_violation(path, value): + violations[path] += 1 + for key, value in (doc.get("unmapped") or {}).items(): + if value is None: + un_nulls[key] = un_nulls.get(key, 0) + 1 + else: + un_values.setdefault(key, []).append(value) + + field_stats = {path: FieldStats(nulls[path], violations[path], values[path]) + for path in mapped_paths} + for key in sorted(set(un_nulls) | set(un_values)): # sorted: deterministic order + field_stats[f"unmapped.{key}"] = FieldStats( + un_nulls.get(key, 0), 0, un_values.get(key, [])) + + if known_unmapped is None: + new_unmapped: set[str] = set() # pre-baseline: recorded, never "new" + else: + targets = set(mapped_paths) + new_unmapped = {key for key in set(un_nulls) | set(un_values) + if key not in targets and key not in known_unmapped} + + return Window(start=rows[0][0], end=rows[-1][0], count=len(rows), + parsed=len(docs), parse_errors=parse_errors, + field_stats=field_stats, new_unmapped=new_unmapped) + + +def tier1_violation(field: str, value) -> bool: + """Tier-1 invariant for one mapped value (spec §7.1), mirroring + validation._value_sanity's checks as window-aggregated drift-time counts — + deliberately NOT an import: validation is candidate-time, this is + drift-time. None is NOT a violation (nulls are tier-2's signal); fields + with no invariant (message, unmapped.*) never violate.""" + if value is None: + return False + if field in ("src_endpoint.ip", "dst_endpoint.ip"): + return not (isinstance(value, str) and _IPV4_RE.match(value)) + if field in ("src_endpoint.port", "dst_endpoint.port"): + return not _is_int_in(value, 0, 65535) + if field == "severity_id": + return not _is_int_in(value, 0, 6) + if field == "time": + if not isinstance(value, str): + return True + try: + datetime.fromisoformat(value) + except ValueError: + return True + return False + return False + + +def _is_int_in(v, lo: int, hi: int) -> bool: + # Mirrors validation._is_int_in: bool is an int subclass — a True flag is + # neither a port nor a severity. + return isinstance(v, int) and not isinstance(v, bool) and lo <= v <= hi + + +def _doc_get(doc: dict, dotted_path: str): + """Walk a document by dotted OCSF path ('src_endpoint.ip'); missing -> None.""" + current = doc + for part in dotted_path.split("."): + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current From 300847f6b305c4117ad6403c6b1f81de575a501e Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 23:27:43 +0530 Subject: [PATCH 07/23] feat(drift): tier-2 stats, baselines, fail-closed enforcement (R-M3-7/11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of the M3 drift plan, extending Task 4's loop: - detect.py: Finding + evaluate_window (tier-1 always, tier-2 null/match/ shape/volume ladders only against a baseline profile, per-field severity = max across signals; quarantined fields still measured and still escalate; unmapped.* are new-key-emergence signals only, alert-only, decide_action bypassed per the Task 4 review ruling); merge_findings (R-M3-11 per-field max); aggregate_baseline (FIRST-N-window aggregation, mean null/match, summed-then-normalized shape, __rule__ count bounds). - Shape-scale ruling: profiles store normalized shape_dist, but _window_scale rescales the baseline to the window's observed-value count before js_divergence — ulpf_core's add-1 smoothing is calibrated for count-scale histograms (T3 pins growth toward ln2), so raw proportions would cap every comparison near 0.32 and leave the severe band unreachable. - enforce.py (new): quarantine_field (array_append + NOT (... = ANY ...) + status='active' predicate, rowcount==0 -> False, NO audit on no-op) and deactivate_rule (expected-status predicate + deactivated_at), each audit paired in-transaction with actor 'drift'; record_windows overlays decided severity/action_taken onto measured rows (ON CONFLICT DO NOTHING); enforce_findings worst-first with ONE deactivate per fingerprint per scan and skip-after-deactivate. - store.py: fetch_window_history (ordered input for the baseline slice). - app.py: frozen pipeline order per scan — scan -> evaluate -> record windows -> FIRST baseline insert when due -> enforce worst-first; the establishing scan is tier-1-only by construction (documented ruling). 45 new tests (detect/enforce/app/store); full suite 383 green; ruff clean. Co-Authored-By: Claude Code --- services/drift/app.py | 52 ++++- services/drift/detect.py | 228 +++++++++++++++++- services/drift/enforce.py | 162 +++++++++++++ services/drift/store.py | 18 ++ services/drift/tests/test_app.py | 214 +++++++++++++++-- services/drift/tests/test_detect.py | 313 ++++++++++++++++++++++++- services/drift/tests/test_enforce.py | 331 +++++++++++++++++++++++++++ services/drift/tests/test_store.py | 17 ++ 8 files changed, 1294 insertions(+), 41 deletions(-) create mode 100644 services/drift/enforce.py create mode 100644 services/drift/tests/test_enforce.py diff --git a/services/drift/app.py b/services/drift/app.py index 41541ff..52bfe4e 100644 --- a/services/drift/app.py +++ b/services/drift/app.py @@ -1,25 +1,33 @@ # services/drift/app.py — the drift service entrypoint: ONE convergent loop # (cold path, Postgres only — no Kafka). # -# Per poll: load active rules, then for each rule one stateless scan — -# fetch_current_view (the DB current view) -> compute_windows (pure, data-only -# horizon) -> window_rows (tier-1) -> insert_windows (idempotent by the -# deterministic window key). Fail-closed posture: drift NEVER blocks anything -# else — every exception is logged with traceback and the loop continues; a -# failed scan is simply retried on the next poll. Tier-2 evaluation, baselines -# and enforcement join this loop in Task 5. +# Per poll: load active rules, then for each rule one stateless scan, in the +# frozen order — fetch_current_view (the DB current view) -> compute_windows +# (pure, data-only horizon) -> evaluate_window (tier-1 always; tier-2 only +# against an existing baseline profile) -> record_windows (idempotent by the +# deterministic window key) -> FIRST baseline insert when the Nth baseline +# window just closed -> enforce_findings worst-first. Ruling: on the +# establishing scan itself the baseline profile does not exist yet at +# evaluation time, so that scan is tier-1-only by construction — the windows +# BEYOND the Nth on that same scan keep their first-recorded tier-1 severity +# (ON CONFLICT DO NOTHING), but the next scan re-evaluates the whole current +# view WITH the profile and enforcement converges. Fail-closed posture: drift +# NEVER blocks anything else — every exception is logged with traceback and +# the loop continues; a failed scan is simply retried on the next poll. import logging import threading from drift.config import Config -from drift.detect import window_rows +from drift.detect import Finding, aggregate_baseline, evaluate_window +from drift.enforce import enforce_findings, record_windows from drift.store import ( ActiveDriftRule, baseline_for, connect_db, fetch_current_view, + fetch_window_history, first_window_unmapped, - insert_windows, + insert_baseline, load_active_rules, ) from drift.windows import compute_windows @@ -27,6 +35,22 @@ log = logging.getLogger("drift.app") +def establish_baseline(conn, cfg: Config, rule: ActiveDriftRule) -> bool: + """The FIRST baseline insert, once due (R-M3-7): after this scan's windows + are recorded, aggregate the version's first cfg.baseline_windows closed + windows and INSERT the profiles once. Returns False while fewer than N + windows exist; insert_baseline's existence guard + ON CONFLICT make later + scans no-ops (the 11th scan never re-inserts), and a version bump re-arms + naturally — the new (fingerprint, version) has no profile, so tier-1-only + baseline windows accumulate again.""" + history = fetch_window_history(conn, rule.fingerprint_id, rule.version) + profiles = aggregate_baseline(history, baseline_windows=cfg.baseline_windows) + if profiles is None: + return False + return insert_baseline(conn, rule.fingerprint_id, rule.version, + profiles, cfg.baseline_windows) + + def scan_rule(cfg: Config, rule: ActiveDriftRule) -> int: """One scan for one active rule; returns the number of CLOSED windows found (0 when the current view holds nothing provably complete). One @@ -45,7 +69,15 @@ def scan_rule(cfg: Config, rule: ActiveDriftRule) -> int: ) if not windows: return 0 - insert_windows(conn, window_rows(rule, windows)) + findings: list[Finding] = [] + for win in windows: + window_findings = evaluate_window( + win, baseline, rule_quarantined=rule.quarantined_fields) + record_windows(conn, rule, window_findings, win) + findings.extend(window_findings) + if baseline is None: + establish_baseline(conn, cfg, rule) + enforce_findings(conn, rule, findings) log.info("scanned %s v%d: %d closed window(s) over %d current-view row(s)", rule.fingerprint_id, rule.version, len(windows), len(rows)) return len(windows) diff --git a/services/drift/detect.py b/services/drift/detect.py index 11955ca..4ce4d48 100644 --- a/services/drift/detect.py +++ b/services/drift/detect.py @@ -1,15 +1,31 @@ -# services/drift/detect.py — Window -> drift_windows row assembly (R-M3-4). +# services/drift/detect.py — Window -> findings -> drift_windows rows +# (R-M3-4 measurement + R-M3-11 evaluation). # -# Tier-1 only at this layer: mapped-field rows carry the violation-rate ladder -# (ulpf_core.drift.tier1_severity), the '__rule__' sentinel carries match_rate. -# Tier-2 comparisons (null/match/shape ladders against a baseline) and the -# enforcement side (severity -> action, quarantine, deactivate, alerts) are -# Task 5's evaluate/enforce layers on top of these rows. +# Two layers over one window: window_rows (measurement — the per-field rates +# and shape histograms, tier-1 severity) and evaluate_window (Task 5 — the +# graduated decision: tier-1 ALWAYS, tier-2 null/match/shape/volume ladders +# only when a baseline profile exists, per-field severity = max across +# signals, decide_action semantics with the two ruling overrides: quarantined +# fields stay measured and still escalate, unmapped.* rows are new-key +# signals only — alert-only, decide_action bypassed). aggregate_baseline is +# the pure FIRST-N-window aggregation behind baseline establishment. from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING -from ulpf_core.drift import shape_histogram, tier1_severity +from ulpf_core.drift import ( + SEVERITIES, + decide_action, + js_divergence, + match_rate_severity, + null_rate_severity, + shape_histogram, + shape_severity, + tier1_severity, + volume_severity, +) from drift.windows import FieldStats, Window @@ -30,6 +46,20 @@ ) +@dataclass(frozen=True) +class Finding: + """One (window, field) drift decision: the measured window's per-field + severity (max across tier-1 + tier-2 signals), the graduated action the + severity warrants, the measurements behind it, and the window's start (the + audit detail + the deterministic drift_windows key).""" + + field: str + severity: str + action: str | None + stats: dict + window_start: datetime + + def shape_dist(stats: FieldStats) -> dict: """Histogram for one field's stats — EMPTY HISTOGRAM GUARD (Task 3 review carry, binding): an empty value list short-circuits to {} BEFORE the shape @@ -79,8 +109,188 @@ def _rule_row(rule: ActiveDriftRule, win: Window) -> tuple: rule.fingerprint_id, rule.version, RULE_FIELD, win.start, win.end, win.count, None, match_rate, None, None, - # The match-rate severity ladder is tier-2 (needs the Task 5 baseline); - # recorded 'none' until evaluate_window joins the loop. + # Measurement default: the decided severity/action come from + # evaluate_window and are overlaid by enforce.record_windows. "none", None, ) + + +# --- Task 5: tier-2 evaluation (R-M3-11) ------------------------------------------- + + +def _max_severity(signals: list[str]) -> str: + """Max severity by ladder order (SEVERITIES is ascending none..severe).""" + return max(signals, key=SEVERITIES.index) if signals else "none" + + +def evaluate_window(win: Window, baseline: dict | None, + *, rule_quarantined: list[str] | tuple = ()) -> list[Finding]: + """Evaluate one closed window: tier-1 ALWAYS, tier-2 only when a baseline + profile exists for the field (R-M3-11). Emission mirrors window_rows + exactly — mapped fields only when win.parsed > 0, then the __rule__ + sentinel — so enforce.record_windows can overlay findings onto measured + rows by field. Quarantined fields are STILL measured and STILL escalate: + quarantine only suppresses a redundant re-quarantine action, never a + finding, and a quarantined field going severe still deactivates the rule. + """ + findings: list[Finding] = [] + if win.parsed: + for field_name, stats in win.field_stats.items(): + findings.append(_field_finding(win, field_name, stats, baseline, + tuple(rule_quarantined))) + findings.append(_rule_finding(win, baseline)) + return findings + + +def _field_finding(win: Window, field_name: str, stats: FieldStats, + baseline: dict | None, rule_quarantined: tuple) -> Finding: + null_rate = stats.nulls / win.parsed + hist = shape_dist(stats) # the EMPTY HISTOGRAM GUARD lives in here + + if field_name.startswith(UNMAPPED_PREFIX): + # Binding ruling (Task 4 review carry): unmapped rows are NEW-KEY- + # EMERGENCE signals only — no tier-1 ladder, no tier-2 null/shape + # comparison even if a profile somehow exists, and decide_action is + # bypassed: alert-only. Rates below are the recorded measurement, the + # same numbers window_rows writes, never re-derived into severity. + is_new = field_name[len(UNMAPPED_PREFIX):] in win.new_unmapped + return Finding( + field_name, "minor" if is_new else "none", "alert" if is_new else None, + {"events_count": win.count, "null_rate": null_rate, "shape_dist": hist}, + win.start) + + violation_rate = stats.violations / win.parsed + signals = [tier1_severity(violation_rate)] + profile = baseline.get(field_name) if baseline else None + if profile is not None: # tier-2 arms per field: no profile -> tier-1 only + base_null = profile.get("null_rate") + if base_null is not None: + signals.append(null_rate_severity(null_rate, base_null)) + base_shape = profile.get("shape_dist") + if hist and base_shape: + # Empty-histogram guard: a window with no observed values never + # shape-compares (silence is the volume ladder's job). + signals.append(shape_severity( + js_divergence(hist, _window_scale(base_shape, hist)))) + severity = _max_severity(signals) + action = decide_action(severity) + if action == "field_quarantined" and field_name in rule_quarantined: + action = None # already quarantined: idempotent skip, never a re-audit + return Finding(field_name, severity, action, + {"events_count": win.count, "null_rate": null_rate, + "violation_rate": violation_rate, "shape_dist": hist}, + win.start) + + +def _rule_finding(win: Window, baseline: dict | None) -> Finding: + """The __rule__ sentinel: match_rate_severity + volume_severity, both + tier-2 (no tier-1 signal exists for the rule itself).""" + denominator = win.parsed + win.parse_errors + match_rate = win.parsed / denominator if denominator else None + signals: list[str] = [] + profile = baseline.get(RULE_FIELD) if baseline else None + if profile is not None: + base_match = profile.get("match_rate") + if match_rate is not None and base_match is not None: + signals.append(match_rate_severity(match_rate, base_match)) + bounds = profile.get("events_count") or {} + if "min" in bounds and "max" in bounds: + signals.append(volume_severity(win.count, bounds["min"], bounds["max"])) + severity = _max_severity(signals) + action = decide_action(severity) + if action == "field_quarantined": + # The sentinel is not a mappable field: a moderate match/volume + # decline alerts (humans look); only severe deactivates. + action = "alert" + return Finding(RULE_FIELD, severity, action, + {"events_count": win.count, "match_rate": match_rate}, win.start) + + +def merge_findings(findings: list[Finding]) -> dict[str, str]: + """Per-field max severity across one scan's findings (R-M3-11) — the + worst thing each field did in any window of the scan.""" + merged: dict[str, str] = {} + for finding in findings: + current = merged.get(finding.field, "none") + if SEVERITIES.index(finding.severity) > SEVERITIES.index(current): + merged[finding.field] = finding.severity + return merged + + +# --- Task 5: baseline aggregation (R-M3-7) ----------------------------------------- + + +def aggregate_baseline(rows: list[tuple], *, baseline_windows: int) -> dict | None: + """Aggregate the FIRST `baseline_windows` closed windows of a rule version + into its baseline profiles, or None while fewer windows exist. + + rows: fetch_window_history output — (window_start, field, events_count, + null_rate, match_rate, shape_dist) ordered by window_start. Per mapped + field: mean null_rate + shape_dist SUMMED across the windows then + normalized to proportions. __rule__: mean match_rate + events_count + min/max (the volume bounds, R-M3-10). unmapped.* rows are skipped — they + are alert-only new-key signals, never tier-2 compared (binding ruling). + """ + starts: list = [] + seen: set = set() + for row in rows: + if row[0] not in seen: + seen.add(row[0]) + starts.append(row[0]) + if len(starts) < baseline_windows: + return None + cutoff = starts[baseline_windows - 1] + + buckets: dict[str, dict] = {} + for window_start, field_name, events_count, null_rate, match_rate, shape in rows: + if window_start > cutoff or field_name.startswith(UNMAPPED_PREFIX): + continue + bucket = buckets.setdefault(field_name, {"null_rates": [], "match_rates": [], + "shape": {}, "counts": []}) + if null_rate is not None: + bucket["null_rates"].append(null_rate) + if match_rate is not None: + bucket["match_rates"].append(match_rate) + if shape: + for cls, n in shape.items(): + bucket["shape"][cls] = bucket["shape"].get(cls, 0) + n + bucket["counts"].append(events_count) + + profiles: dict[str, dict] = {} + for field_name, bucket in buckets.items(): + if field_name == RULE_FIELD: + profiles[field_name] = { + "match_rate": (_mean(bucket["match_rates"])), + "events_count": {"min": min(bucket["counts"]), + "max": max(bucket["counts"])}, + } + else: + profiles[field_name] = { + "null_rate": _mean(bucket["null_rates"]), + "shape_dist": _normalized(bucket["shape"]), + } + return profiles + + +def _mean(values: list[float]) -> float | None: + return sum(values) / len(values) if values else None + + +def _normalized(histogram: dict) -> dict: + """Count histogram -> proportions summing to 1.0 ({} stays {}). The STORED + profile is scale-free; comparisons rescale it back (see _window_scale).""" + total = sum(histogram.values()) + return {cls: n / total for cls, n in histogram.items()} if total else {} + + +def _window_scale(baseline_shape: dict, hist: dict) -> dict: + """Project the stored (normalized) baseline shape onto the current + window's observed-value count. R-M3-5's js_divergence add-1 smoothing is + calibrated for COUNT-scale histograms (T3 pins 'grows toward ln2 as counts + grow'); feeding raw proportions would let the +1s dominate the baseline + side and cap every shape comparison at ~0.32, leaving the severe band + unreachable. Rescaling puts both sides at the same count scale, where the + smoothing is negligible and the ladder bands mean what T3 calibrated.""" + total = sum(hist.values()) + return {cls: proportion * total for cls, proportion in baseline_shape.items()} diff --git a/services/drift/enforce.py b/services/drift/enforce.py new file mode 100644 index 0000000..34be3a6 --- /dev/null +++ b/services/drift/enforce.py @@ -0,0 +1,162 @@ +# services/drift/enforce.py — graduated, fail-closed enforcement (R-M3-11, +# spec §7.2) under drift_role's column-scoped grants (migration 007). +# +# The grant surface IS the design, exactly as in gateway/writes.py: drift_role +# may UPDATE ONLY rules(quarantined_fields) and rules(status, deactivated_at), +# INSERT drift_windows/baseline_profiles/audit_log, and SELECT its own tables. +# Every UPDATE carries its expected-state predicate in the WHERE (TOCTOU +# backstop) and is answered by a rowcount check — 0 rows means another writer +# moved the rule (already quarantined / no longer active): False, never a +# silent no-op that would still write an audit row. On a successful mutation +# the audit INSERT is paired INSIDE the same transaction, actor 'drift' +# (store.ACTOR). +# +# Worst-first scan enforcement (enforce_findings): severe findings before +# moderate; ONE deactivate per fingerprint per scan; after a deactivate the +# remaining actions for that fingerprint are skipped and logged (the rule is +# down — quarantine appends would no-op against status='active' anyway). +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ulpf_core.drift import SEVERITIES + +from drift.detect import Finding, merge_findings, window_rows +from drift.store import ACTOR, insert_windows + +if TYPE_CHECKING: + import psycopg + + from drift.store import ActiveDriftRule + from drift.windows import Window + +log = logging.getLogger("drift.enforce") + +_AUDIT_SQL = "INSERT INTO audit_log (actor, action, entity, detail) VALUES (%s, %s, %s, %s)" + +# Column-scoped exactly to migration 007's grants; the NOT (... = ANY(...)) +# term is the idempotency predicate — quarantining an already-quarantined +# field matches zero rows and cannot audit. +_QUARANTINE_SQL = ( + "UPDATE rules SET quarantined_fields = array_append(quarantined_fields, %s) " + "WHERE id = %s AND status = 'active' " + "AND NOT (%s = ANY(quarantined_fields))" +) +_DEACTIVATE_SQL = ( + "UPDATE rules SET status = 'deactivated', deactivated_at = now() " + "WHERE id = %s AND status = 'active'" +) + + +def _audit(cur, action: str, entity: str, detail: dict) -> None: + from psycopg.types.json import Json # deferred: keeps psycopg off the test path + + cur.execute(_AUDIT_SQL, (ACTOR, action, entity, Json(detail))) + + +def quarantine_field(conn: psycopg.Connection, rule: ActiveDriftRule, field: str, + window_start) -> bool: + """Append one field to the rule's quarantined_fields (mapping disabled at + parse time; values preserved in unmapped). Idempotent: the NOT (... = ANY + (...)) predicate means an already-quarantined field — or a rule that is no + longer active — updates zero rows, returns False and writes NO audit (a + no-op must never look like an action). True iff the append happened, in + which case the audit row is paired in the same transaction.""" + with conn.transaction(), conn.cursor() as cur: + cur.execute(_QUARANTINE_SQL, (field, rule.id, field)) + if cur.rowcount == 0: + log.info("quarantine no-op for %s v%d field %s (already quarantined " + "or rule inactive)", rule.fingerprint_id, rule.version, field) + return False + _audit(cur, "field_quarantined", rule.fingerprint_id, + {"field": field, "rule_id": rule.id, "version": rule.version, + "window_start": window_start}) + log.warning("field %s quarantined on %s v%d (window %s)", + field, rule.fingerprint_id, rule.version, window_start) + return True + + +def deactivate_rule(conn: psycopg.Connection, rule: ActiveDriftRule, + window_start) -> bool: + """Deactivate the rule (fingerprint reverts to raw-only and re-enters + onboarding). The status='active' predicate is the expected-state TOCTOU + backstop: zero rows -> False (a human or another scan got there first), + never an audit for something that did not happen.""" + with conn.transaction(), conn.cursor() as cur: + cur.execute(_DEACTIVATE_SQL, (rule.id,)) + if cur.rowcount == 0: + log.info("deactivate no-op for rule %d (no longer active)", rule.id) + return False + _audit(cur, "rule_deactivated", rule.fingerprint_id, + {"rule_id": rule.id, "version": rule.version, + "window_start": window_start}) + log.warning("rule %d (%s v%d) DEACTIVATED by drift (window %s)", + rule.id, rule.fingerprint_id, rule.version, window_start) + return True + + +def record_windows(conn: psycopg.Connection, rule: ActiveDriftRule, + findings: list[Finding], win: Window) -> None: + """INSERT one window's drift_windows rows with the DECIDED severity and + action_taken overlaid onto the measured row (window_rows recomputes the + rates — the exact tier-1 measurement discipline — and the finding supplies + the decision). Idempotent by the deterministic window key through + store.insert_windows' ON CONFLICT DO NOTHING; one transaction per window + batch. The plan's listed form record_windows(conn, findings, win) lacked + the rule identity the INSERT columns need — rule leads, mirroring + window_rows(rule, windows).""" + by_field = {finding.field: finding for finding in findings} + rows = [] + for measured in window_rows(rule, [win]): + finding = by_field.get(measured[2]) + if finding is None: # defensive: emission parity is pinned by test + rows.append(measured) + else: + rows.append(measured[:10] + (finding.severity, finding.action)) + insert_windows(conn, rows) + + +def _rank(severity: str) -> int: + return SEVERITIES.index(severity) + + +def enforce_findings(conn: psycopg.Connection, rule: ActiveDriftRule, + findings: list[Finding]) -> dict: + """Enforce one scan's findings, worst-first (R-M3-11): severe before + moderate (alerts need no enforcement — the drift_windows row IS the + alert), ONE deactivate per fingerprint per scan, and after a deactivate + the remaining actions for this fingerprint are skipped and logged. + Returns {"deactivated": bool, "quarantined": [fields actually appended]}. + """ + merged = merge_findings(findings) # per-field max severity (R-M3-11) + worst: dict[str, Finding] = {} # that max, as the finding that carries it + # (enforcement needs the finding's window_start for the audit detail) + for finding in sorted(findings, + key=lambda f: (-_rank(f.severity), f.window_start, f.field)): + if merged.get(finding.field, "none") == finding.severity: + worst.setdefault(finding.field, finding) + + quarantined: list[str] = [] + deactivated = False + skip_rest = False + for finding in sorted(worst.values(), key=lambda f: (-_rank(f.severity), f.field)): + if skip_rest: + log.info("skipping %s on %s (window %s): the rule was deactivated " + "this scan", finding.action, finding.field, finding.window_start) + continue + if finding.action == "alert": + # minor (or sentinel-moderate): alert + metric logged — no DB action. + log.warning("drift alert: %s v%d field %s severity %s (window %s) " + "stats %s", rule.fingerprint_id, rule.version, finding.field, + finding.severity, finding.window_start, finding.stats) + elif finding.action == "rule_deactivated": + skip_rest = True # one deactivate per fingerprint per scan + deactivated = deactivate_rule(conn, rule, finding.window_start) + if not deactivated: + log.info("rule %d no longer active; drift enforcement stands " + "down for this scan", rule.id) + elif (finding.action == "field_quarantined" + and quarantine_field(conn, rule, finding.field, finding.window_start)): + quarantined.append(finding.field) + return {"deactivated": deactivated, "quarantined": quarantined} diff --git a/services/drift/store.py b/services/drift/store.py index 65d170f..8336895 100644 --- a/services/drift/store.py +++ b/services/drift/store.py @@ -124,6 +124,24 @@ def baseline_for(conn: psycopg.Connection, fingerprint_id: str, return {field: profile for field, profile in rows} or None +def fetch_window_history(conn: psycopg.Connection, fingerprint_id: str, + version: int) -> list[tuple]: + """(window_start, field, events_count, null_rate, match_rate, shape_dist) + rows of the version's drift_windows, oldest window first, field-sorted + within a window — the ordered input detect.aggregate_baseline slices the + FIRST N closed windows from (the whole history is read: it is bounded by + raw retention, and the scan is a cold path).""" + with conn.cursor() as cur: + cur.execute( + "SELECT window_start, field, events_count, null_rate, match_rate, " + "shape_dist FROM drift_windows " + "WHERE fingerprint_id = %s AND rule_version = %s " + "ORDER BY window_start, field", + (fingerprint_id, version), + ) + return cur.fetchall() + + def first_window_unmapped(conn: psycopg.Connection, fingerprint_id: str, version: int) -> set[str]: """Unmapped keys of the baseline's FIRST window — the key set new unmapped diff --git a/services/drift/tests/test_app.py b/services/drift/tests/test_app.py index f34846c..6084ab0 100644 --- a/services/drift/tests/test_app.py +++ b/services/drift/tests/test_app.py @@ -1,8 +1,10 @@ # services/drift/tests/test_app.py — loop wiring tests (onboarding-loop shape): # fail-closed scan retries, the loop never raises out, scan_rule threading -# (baseline -> known_unmapped), and the single-loop main(). Monkeypatch style: -# app.py imports its store collaborators by name, so patching the app module's -# globals observes the wiring; NEVER a live DB. +# (baseline -> known_unmapped), the frozen Task 5 pipeline order (scan -> +# evaluate -> record -> FIRST baseline insert -> enforce worst-first), and the +# single-loop main(). Monkeypatch style: app.py imports its collaborators by +# name, so patching the app module's globals observes the wiring; NEVER a +# live DB. import threading from datetime import UTC, datetime, timedelta @@ -17,11 +19,11 @@ def _ts(seconds: float) -> datetime: return datetime(2026, 9, 21, 10, 0, 0, tzinfo=UTC) + timedelta(seconds=seconds) -def _rule(fp="fp_a", rule_id=7, version=2): +def _rule(fp="fp_a", rule_id=7, version=2, quarantined=()): return ActiveDriftRule( id=rule_id, fingerprint_id=fp, version=version, pattern=r"x", mappings=[Mapping(source_field="SRC", ocsf_path="src_endpoint.ip")], - provenance="human", quarantined_fields=[]) + provenance="human", quarantined_fields=list(quarantined)) def _doc(ip="198.51.100.4"): @@ -109,10 +111,11 @@ def fake_scan(cfg, rule): # --- scan_rule wiring ------------------------------------------------------------- -def test_scan_rule_threads_baseline_into_known_unmapped_and_inserts(monkeypatch): +def test_scan_rule_threads_baseline_into_known_unmapped_and_records(monkeypatch): view = [(_ts(0), "parsed", _doc()), (_ts(1), "parsed", _doc(ip="SRC=1.2.3.4"))] - baseline = {"src_endpoint.ip": {"null_rate": 0.0}, "unmapped.IN": {"null_rate": 0.0}} - captured, inserted = {}, [] + baseline = {"src_endpoint.ip": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}, + "unmapped.IN": {"null_rate": 0.0, "shape_dist": {}}} + captured, recorded, enforced = {}, [], [] monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) @@ -127,8 +130,10 @@ def spy(rows, **kw): return real_compute(rows, **kw) monkeypatch.setattr(app, "compute_windows", spy) - monkeypatch.setattr(app, "insert_windows", - lambda conn, rows: inserted.extend(rows)) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: recorded.append(findings)) + monkeypatch.setattr(app, "enforce_findings", + lambda conn, rule, findings: enforced.append(findings)) n = app.scan_rule(_fast_cfg(window_count=2), _rule()) assert n == 1 # one closed window @@ -137,11 +142,15 @@ def spy(rows, **kw): assert captured["mapped_paths"] == ("src_endpoint.ip",) assert captured["known_unmapped"] == {"IN", "OUT"} assert captured["count_window"] == 2 and captured["time_window_s"] == 600 - fields = [row[2] for row in inserted] + (findings,) = recorded + fields = [f.field for f in findings] assert "src_endpoint.ip" in fields and "__rule__" in fields - # 1 violation of 2 parsed docs -> violation_rate 0.5 -> tier-1 'severe'. - ip_row = next(row for row in inserted if row[2] == "src_endpoint.ip") - assert ip_row[8] == 0.5 and ip_row[10] == "severe" + # 1 violation of 2 parsed docs -> violation_rate 0.5 -> tier-1 'severe' + # -> rule_deactivated, and the scan's enforcement saw that finding. + ip = next(f for f in findings if f.field == "src_endpoint.ip") + assert ip.stats["violation_rate"] == 0.5 + assert ip.severity == "severe" and ip.action == "rule_deactivated" + assert enforced == [findings] def test_scan_rule_without_baseline_passes_known_none(monkeypatch): @@ -166,17 +175,184 @@ def spy(rows, **kw): def test_scan_rule_holds_back_trailing_partial(monkeypatch): # 3 fresh rows with window_count=10: nothing is provably closed, so - # insert_windows never runs. + # record_windows never runs. view = [(_ts(i), "parsed", _doc()) for i in range(3)] - inserted = [] + recorded = [] monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: None) - monkeypatch.setattr(app, "insert_windows", - lambda conn, rows: inserted.extend(rows)) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: recorded.append(findings)) assert app.scan_rule(_fast_cfg(), _rule()) == 0 - assert inserted == [] + assert recorded == [] + + +# --- scan_rule pipeline: the frozen Task 5 order ---------------------------------- + + +def test_scan_rule_runs_scan_evaluate_record_baseline_enforce_in_order(monkeypatch): + view = [(_ts(i * 10), "parsed", _doc()) for i in range(4)] # 2 windows of 2 + history = [(_ts(0), "__rule__", 2, None, 1.0, None), + (_ts(20), "__rule__", 2, None, 1.0, None)] + order = [] + + real_compute, real_evaluate = app.compute_windows, app.evaluate_window + + def compute_spy(rows, **kw): + order.append("windows") + return real_compute(rows, **kw) + + def evaluate_spy(win, baseline, *, rule_quarantined): + order.append("evaluate") + return real_evaluate(win, baseline, rule_quarantined=rule_quarantined) + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", + lambda conn, fp, v: (order.append("scan"), view)[1]) + monkeypatch.setattr(app, "baseline_for", + lambda conn, fp, v: (order.append("baseline-read"), None)[1]) + monkeypatch.setattr(app, "first_window_unmapped", + lambda conn, fp, v: (_ for _ in ()).throw( + AssertionError("must not run pre-baseline"))) + monkeypatch.setattr(app, "compute_windows", compute_spy) + monkeypatch.setattr(app, "evaluate_window", evaluate_spy) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: order.append("record")) + monkeypatch.setattr(app, "fetch_window_history", lambda conn, fp, v: history) + monkeypatch.setattr(app, "insert_baseline", + lambda conn, fp, v, profiles, n: + (order.append("baseline-insert"), True)[1]) + monkeypatch.setattr(app, "enforce_findings", + lambda conn, rule, findings: order.append("enforce")) + + assert app.scan_rule(_fast_cfg(window_count=2, baseline_windows=2), _rule()) == 2 + # evaluate+record run per window; the FIRST baseline insert comes after + # all windows are recorded; enforcement is last, worst-first. + assert order == ["scan", "baseline-read", "windows", "evaluate", "record", + "evaluate", "record", "baseline-insert", "enforce"] + + +def test_scan_rule_establishes_baseline_exactly_at_the_nth_window(monkeypatch): + # 20 rows / window_count 2 -> the 10th window closes on THIS scan: the + # profile INSERTs once, windows_seen == 10, aggregates from the history. + view = [(_ts(i * 10), "parsed", _doc()) for i in range(20)] + history = [(_ts(i * 10), "src_endpoint.ip", 2, 0.0, None, {"ipv4": 2}) + for i in range(0, 20, 2)] + history += [(_ts(i * 10), "__rule__", 2, None, 1.0, None) + for i in range(0, 20, 2)] + inserts = [] + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: None) + monkeypatch.setattr(app, "first_window_unmapped", + lambda conn, fp, v: (_ for _ in ()).throw( + AssertionError("must not run pre-baseline"))) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: None) + monkeypatch.setattr(app, "fetch_window_history", lambda conn, fp, v: history) + monkeypatch.setattr(app, "insert_baseline", + lambda conn, fp, v, profiles, n: + inserts.append((fp, v, profiles, n)) or True) + + assert app.scan_rule(_fast_cfg(window_count=2, baseline_windows=10), + _rule()) == 10 + assert len(inserts) == 1 # exactly one insert, this scan + fp, version, profiles, windows_seen = inserts[0] + assert (fp, version, windows_seen) == ("fp_a", 2, 10) + assert set(profiles) == {"src_endpoint.ip", "__rule__"} + assert profiles["src_endpoint.ip"] == {"null_rate": 0.0, + "shape_dist": {"ipv4": 1.0}} + assert profiles["__rule__"]["events_count"] == {"min": 2, "max": 2} + + +def test_scan_rule_below_n_windows_never_inserts_a_baseline(monkeypatch): + view = [(_ts(i * 10), "parsed", _doc()) for i in range(20)] + history = [(_ts(i * 10), "__rule__", 2, None, 1.0, None) + for i in range(0, 20, 2)] # 10 windows, N demands 11 + inserts = [] + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: None) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: None) + monkeypatch.setattr(app, "fetch_window_history", lambda conn, fp, v: history) + monkeypatch.setattr(app, "insert_baseline", + lambda conn, fp, v, profiles, n: + inserts.append((fp, v, profiles, n)) or True) + + app.scan_rule(_fast_cfg(window_count=2, baseline_windows=11), _rule()) + assert inserts == [] # not due: 10 < 11 + + +def test_scan_rule_post_baseline_never_reinserts_and_runs_tier2(monkeypatch): + # The 11th+ scan: baseline_for returns the profile, so establishment never + # runs, evaluate receives the profile (tier-2 armed) plus the rule's + # quarantine list, and enforcement still sees every finding. + baseline = {"src_endpoint.ip": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}, + "__rule__": {"match_rate": 1.0, + "events_count": {"min": 1, "max": 4}}} + view = [(_ts(i), "parsed", _doc()) for i in range(4)] + evaluated, enforced = [], [] + + real_evaluate = app.evaluate_window + + def evaluate_spy(win, baseline_arg, *, rule_quarantined): + evaluated.append((baseline_arg, rule_quarantined)) + return real_evaluate(win, baseline_arg, rule_quarantined=rule_quarantined) + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: baseline) + monkeypatch.setattr(app, "first_window_unmapped", lambda conn, fp, v: set()) + monkeypatch.setattr(app, "evaluate_window", evaluate_spy) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: None) + monkeypatch.setattr(app, "establish_baseline", + lambda conn, cfg, rule: (_ for _ in ()).throw( + AssertionError("establishment must not re-run"))) + monkeypatch.setattr(app, "enforce_findings", + lambda conn, rule, findings: enforced.append(findings)) + + assert app.scan_rule(_fast_cfg(window_count=2), _rule()) == 2 + assert all(b is baseline and q == [] for b, q in evaluated) + assert len(enforced) == 1 and len(enforced[0]) == 4 # 2 windows x 2 fields + + +def test_scan_rule_version_bump_is_tier1_only_then_rebases(monkeypatch): + # A new (fingerprint, version) has no profile: evaluate runs tier-1-only + # (baseline None), establishment is attempted (not due on empty history), + # and enforcement still runs — tier-1 fires from event zero. + view = [(_ts(0), "parsed", _doc()), (_ts(1), "parsed", _doc())] + evaluated, enforced, inserts = [], [], [] + + real_evaluate = app.evaluate_window + + def evaluate_spy(win, baseline_arg, *, rule_quarantined): + evaluated.append(baseline_arg) + return real_evaluate(win, baseline_arg, rule_quarantined=rule_quarantined) + + monkeypatch.setattr(app, "connect_db", lambda url: FakeStoreConn()) + monkeypatch.setattr(app, "fetch_current_view", lambda conn, fp, v: view) + monkeypatch.setattr(app, "baseline_for", lambda conn, fp, v: None) + monkeypatch.setattr(app, "first_window_unmapped", + lambda conn, fp, v: (_ for _ in ()).throw( + AssertionError("must not run pre-baseline"))) + monkeypatch.setattr(app, "evaluate_window", evaluate_spy) + monkeypatch.setattr(app, "record_windows", + lambda conn, rule, findings, win: None) + monkeypatch.setattr(app, "fetch_window_history", lambda conn, fp, v: []) + monkeypatch.setattr(app, "insert_baseline", + lambda conn, fp, v, profiles, n: + inserts.append(n) or True) + monkeypatch.setattr(app, "enforce_findings", + lambda conn, rule, findings: enforced.append(findings)) + + assert app.scan_rule(_fast_cfg(window_count=2), _rule(version=5)) == 1 + assert evaluated == [None] # tier-1-only phase + assert inserts == [] # nothing to aggregate yet + assert len(enforced) == 1 # enforcement ran anyway # --- main(): ONE loop, single-threaded ------------------------------------------- diff --git a/services/drift/tests/test_detect.py b/services/drift/tests/test_detect.py index 9278dd9..a49abce 100644 --- a/services/drift/tests/test_detect.py +++ b/services/drift/tests/test_detect.py @@ -1,15 +1,25 @@ # services/drift/tests/test_detect.py — Window -> drift_windows row assembly # (tier-1 severity, __rule__ match_rate, the EMPTY HISTOGRAM GUARD from Task 3's -# review). Pure functions + monkeypatched shape_histogram; NEVER a live DB. +# review) PLUS Task 5's evaluate_window / merge_findings / aggregate_baseline +# (tier-2 ladders, graduated actions, baseline aggregation). Pure functions + +# monkeypatched shape_histogram; NEVER a live DB. from datetime import UTC, datetime, timedelta import pytest from ulpf_core.models import Mapping from drift import detect -from drift.detect import RULE_FIELD, shape_dist, window_rows +from drift.detect import ( + RULE_FIELD, + Finding, + aggregate_baseline, + evaluate_window, + merge_findings, + shape_dist, + window_rows, +) from drift.store import ActiveDriftRule -from drift.windows import FieldStats, compute_windows +from drift.windows import FieldStats, Window, compute_windows def _ts(seconds: float) -> datetime: @@ -161,3 +171,300 @@ def test_rows_order_is_mapped_then_sorted_unmapped_then_rule(): rows = [(_ts(0), "parsed", _doc(unmapped={"Z": "1", "A": "2"}))] fields = [r[2] for r in window_rows(_rule(), _windows(rows))] assert fields == ["src_endpoint.ip", "unmapped.A", "unmapped.Z", "__rule__"] + + +# --- Task 5: evaluate_window ------------------------------------------------------ + + +def _win(parsed=10, errors=0, stats=None, new=(), start=None): + """Synthetic Window (parsed>0 keeps field rows; the FieldStats are the + test's chosen measurement, no docs needed — evaluate is pure over stats).""" + return Window(start=start or _ts(0), end=_ts(60), count=parsed + errors, + parsed=parsed, parse_errors=errors, + field_stats=stats or {}, new_unmapped=set(new)) + + +def _ip_stats(nulls=0, violations=0, values=None): + return FieldStats(nulls=nulls, violations=violations, + values=values if values is not None else ["198.51.100.7"]) + + +def _finding_for(win, baseline, field_name, *, quarantined=()): + """The one finding for field_name out of the window's evaluation.""" + return next(f for f in evaluate_window(win, baseline, rule_quarantined=quarantined) + if f.field == field_name) + + +def test_evaluate_tier1_only_without_baseline_ladder_and_actions(): + # violation_rate 0.1 -> tier-1 'minor' -> alert; decide_action semantics. + win = _win(stats={"src_endpoint.ip": _ip_stats(violations=1)}) + finding = _finding_for(win, None, "src_endpoint.ip") + assert (finding.field, finding.severity, finding.action) == ( + "src_endpoint.ip", "minor", "alert") + assert finding.stats["violation_rate"] == pytest.approx(0.1) + assert finding.stats["null_rate"] == 0.0 and finding.stats["events_count"] == 10 + assert finding.window_start == win.start + + +def test_evaluate_tier1_moderate_quarantines_and_severe_deactivates(): + # 0.2 violation-rate -> moderate -> field_quarantined; 0.5 -> severe -> + # rule_deactivated (the full §7.2 ladder through tier-1 alone). + stats_mod = _win(stats={"src_endpoint.ip": _ip_stats(violations=2)}) + assert _finding_for(stats_mod, None, "src_endpoint.ip").action \ + == "field_quarantined" + stats_sev = _win(stats={"src_endpoint.ip": _ip_stats(violations=5)}) + f = _finding_for(stats_sev, None, "src_endpoint.ip") + assert f.severity == "severe" and f.action == "rule_deactivated" + + +def test_evaluate_tier1_only_when_field_missing_from_baseline(): + # A field with no profile in the baseline dict (e.g. never observed during + # the baseline windows) degrades to tier-1-only, never KeyError. + baseline = {"other.field": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"src_endpoint.ip": _ip_stats()}) + finding = _finding_for(win, baseline, "src_endpoint.ip") + assert finding.severity == "none" and finding.action is None + + +def test_evaluate_tier2_null_rate_escalates_over_quiet_tier1(): + # null 0.0 -> 0.2 against baseline 0.01: ratio 20, diff 0.19 -> moderate. + baseline = {"src_endpoint.ip": {"null_rate": 0.01, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"src_endpoint.ip": _ip_stats( + nulls=2, values=["198.51.100.7"] * 8)}) # null_rate 0.2, shape identical + finding = _finding_for(win, baseline, "src_endpoint.ip") + assert finding.severity == "moderate" and finding.action == "field_quarantined" + + +def test_evaluate_tier2_shape_divergence_alone_reaches_severe(): + # A field with NO tier-1 invariant (shape is its only signal): 1000 + # free-text values against an all-ipv4 baseline rescale to count parity + # ({free_text: 1000} vs {ipv4: 1000}) -> T3's disjoint-count divergence + # ~0.693 -> severe -> rule_deactivated. + baseline = {"metadata.note": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"metadata.note": FieldStats(nulls=0, violations=0, + values=["DROP"] * 1000)}) + finding = _finding_for(win, baseline, "metadata.note") + assert finding.severity == "severe" and finding.action == "rule_deactivated" + + +def test_evaluate_tier2_shape_rescales_baseline_to_window_count(monkeypatch): + # The stored profile is normalized, but js_divergence must see it at the + # window's count scale: {ipv4: 1.0} x 4 observed values -> {ipv4: 4.0}. + captured = {} + + def spy(p, q): + captured.update(p=p, q=q) + return 0.0 + + monkeypatch.setattr(detect, "js_divergence", spy) + baseline = {"src_endpoint.ip": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"src_endpoint.ip": _ip_stats(values=["198.51.100.7"] * 4)}) + _finding_for(win, baseline, "src_endpoint.ip") + assert captured["p"] == {"ipv4": 4} and captured["q"] == {"ipv4": 4.0} + + +def test_evaluate_severity_is_max_across_signals(): + # tier-1 minor (0.1 violations) + tier-2 null moderate (0.2 vs 0.01): + # the field's severity is the MAX — moderate — not either alone. + baseline = {"src_endpoint.ip": {"null_rate": 0.01, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"src_endpoint.ip": _ip_stats(nulls=2, violations=1, + values=["198.51.100.7"] * 7)}) + finding = _finding_for(win, baseline, "src_endpoint.ip") + assert finding.severity == "moderate" and finding.action == "field_quarantined" + + +def test_evaluate_quarantined_field_still_measured_and_skips_requarantine(): + # Already-quarantined field sitting at moderate: STILL measured (the + # finding exists, severity recorded) but the quarantine action is an + # idempotent skip — decide_action('moderate') is suppressed to None. + baseline = {"src_endpoint.ip": {"null_rate": 0.01, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"src_endpoint.ip": _ip_stats(nulls=2, + values=["198.51.100.7"] * 8)}) + finding = _finding_for(win, baseline, "src_endpoint.ip", + quarantined=["src_endpoint.ip"]) + assert finding.severity == "moderate" and finding.action is None + + +def test_evaluate_quarantined_field_worsening_to_severe_deactivates(): + # The binding escalation rule: a quarantined field going WORSE must reach + # rule_deactivated — quarantine never hides the field. + baseline = {"src_endpoint.ip": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"src_endpoint.ip": _ip_stats(nulls=5, + values=["198.51.100.7"] * 5)}) + finding = _finding_for(win, baseline, "src_endpoint.ip", + quarantined=["src_endpoint.ip"]) + assert finding.severity == "severe" and finding.action == "rule_deactivated" + + +def test_evaluate_never_shape_compares_an_empty_current_histogram(monkeypatch): + # Task 3/4 review carry (binding): a window with no observed values + # short-circuits shape comparison entirely — js_divergence must never see + # an empty histogram. The baseline here is an ALWAYS-NULL field (null + # ladder quiet), so only a broken shape path could fire. + def boom(p, q): + raise AssertionError("js_divergence called on an empty histogram") + + monkeypatch.setattr(detect, "js_divergence", boom) + baseline = {"src_endpoint.ip": {"null_rate": 1.0, "shape_dist": {}}} + win = _win(stats={"src_endpoint.ip": _ip_stats(nulls=10, values=[])}) + finding = _finding_for(win, baseline, "src_endpoint.ip") + assert finding.severity == "none" # null 1.0 vs baseline 1.0: ratio 1.0 + assert finding.stats["shape_dist"] == {} + + +def test_evaluate_rule_row_pre_baseline_is_none(): + win = _win(errors=0, stats={}) # parsed 10, match_rate 1.0, no baseline yet + (finding,) = evaluate_window(win, None, rule_quarantined=[]) + assert finding.field == RULE_FIELD + assert finding.severity == "none" and finding.action is None + assert finding.stats == {"events_count": 10, "match_rate": 1.0} + + +def test_evaluate_rule_row_match_rate_collapse_deactivates(): + # match 1.0 -> 0.3 = 0.7 drop -> severe; volume inside bounds stays none. + baseline = {RULE_FIELD: {"match_rate": 1.0, + "events_count": {"min": 5, "max": 20}}} + win = _win(parsed=3, errors=7, stats={}) + (finding,) = evaluate_window(win, baseline, rule_quarantined=[]) + assert finding.severity == "severe" and finding.action == "rule_deactivated" + assert finding.stats["match_rate"] == pytest.approx(0.3) + + +def test_evaluate_rule_row_volume_outside_bounds_is_minor_alert(): + # count 25 outside [5, 20] -> volume 'minor'; match unchanged -> none. + baseline = {RULE_FIELD: {"match_rate": 1.0, + "events_count": {"min": 5, "max": 20}}} + win = _win(parsed=25, errors=0, stats={}) + (finding,) = evaluate_window(win, baseline, rule_quarantined=[]) + assert finding.severity == "minor" and finding.action == "alert" + + +def test_evaluate_rule_row_moderate_downgrades_quarantine_to_alert(): + # The __rule__ sentinel is NOT a quarantinable field: a moderate match drop + # (1.0 -> 0.7, inside volume bounds) alerts — only severe deactivates. + baseline = {RULE_FIELD: {"match_rate": 1.0, + "events_count": {"min": 5, "max": 20}}} + win = _win(parsed=7, errors=3, stats={}) # match 0.7 -> 0.3 drop: moderate + (finding,) = evaluate_window(win, baseline, rule_quarantined=[]) + assert finding.severity == "moderate" and finding.action == "alert" + + +def test_evaluate_unmapped_new_key_is_minor_alert_only(): + # decide_action is BYPASSED for unmapped.*: a brand-new key surfaces as + # minor/alert (the extension-opportunity card), never quarantine/deactivate. + win = _win(stats={"unmapped.NEW": FieldStats(nulls=0, violations=0, + values=["eth9"])}, + new={"NEW"}) + finding = _finding_for(win, None, "unmapped.NEW") + assert (finding.field, finding.severity, finding.action) == ( + "unmapped.NEW", "minor", "alert") + + +def test_evaluate_unmapped_known_key_stays_none_even_with_terrible_rates(): + # Binding ruling: unmapped rows are new-key-emergence signals ONLY — null + # rates are never re-derived into severity (10/10 nulls here stay 'none'), + # and even a (defensive) baseline profile for an unmapped field is ignored. + baseline = {"unmapped.IN": {"null_rate": 0.0, "shape_dist": {"ipv4": 1.0}}} + win = _win(stats={"unmapped.IN": FieldStats(nulls=10, violations=0, + values=[])}, + new=set()) + finding = _finding_for(win, baseline, "unmapped.IN") + assert finding.severity == "none" and finding.action is None + assert finding.stats["null_rate"] == 1.0 # measured + recorded, never acted on + + +def test_evaluate_zero_parsed_window_emits_only_the_rule_finding(): + win = _win(parsed=0, errors=3, stats={"src_endpoint.ip": _ip_stats()}) + findings = evaluate_window(win, None, rule_quarantined=[]) + assert [f.field for f in findings] == [RULE_FIELD] + + +def test_evaluate_emission_parity_with_window_rows(): + # record_windows overlays findings onto window_rows' measured rows, so the + # two must emit the SAME field set for the same window (mapped, unmapped, + # sentinel; zero-parsed case above). + rows = ([(_ts(i), "parsed", _doc()) for i in range(2)] + + [(_ts(2), "parsed", _doc(unmapped={"IN": "eth0", "Z": "1"}))]) + win = _windows(rows)[0] + measured = [r[2] for r in window_rows(_rule(), [win])] + evaluated = [f.field for f in evaluate_window(win, None, rule_quarantined=[])] + assert evaluated == measured + + +# --- Task 5: merge_findings (R-M3-11 per-field max) ------------------------------- + + +def test_merge_findings_takes_per_field_max_across_windows(): + findings = [ + Finding("src_endpoint.ip", "minor", "alert", {}, _ts(0)), + Finding("src_endpoint.ip", "moderate", "field_quarantined", {}, _ts(60)), + Finding("dst_endpoint.ip", "severe", "rule_deactivated", {}, _ts(60)), + Finding("dst_endpoint.ip", "none", None, {}, _ts(120)), + ] + assert merge_findings(findings) == { + "src_endpoint.ip": "moderate", "dst_endpoint.ip": "severe"} + + +def test_merge_findings_empty_is_empty(): + assert merge_findings([]) == {} + + +# --- Task 5: aggregate_baseline (the FIRST-N window aggregation) ------------------ + + +def _hist_row(start_s, field, count, null_rate, match_rate, shape): + return (_ts(start_s), field, count, null_rate, match_rate, shape) + + +def test_aggregate_baseline_matches_hand_computed_fixture(): + # Hand-computed: 3 windows, ip field nulls 0.1/0.2/0.3 -> mean 0.2; shapes + # summed {ipv4: 18, free_text: 12} -> normalized 0.6/0.4; __rule__ match + # 1.0/0.9/0.8 -> 0.9 with count bounds min 10 / max 14; unmapped excluded. + rows = [ + _hist_row(0, "src_endpoint.ip", 10, 0.1, None, {"ipv4": 8, "free_text": 2}), + _hist_row(0, RULE_FIELD, 10, None, 1.0, None), + _hist_row(60, "src_endpoint.ip", 12, 0.2, None, {"ipv4": 6, "free_text": 4}), + _hist_row(60, RULE_FIELD, 12, None, 0.9, None), + _hist_row(120, "src_endpoint.ip", 14, 0.3, None, {"ipv4": 4, "free_text": 6}), + _hist_row(120, RULE_FIELD, 14, None, 0.8, None), + _hist_row(120, "unmapped.X", 14, 0.5, None, {"free_text": 3}), + ] + profiles = aggregate_baseline(rows, baseline_windows=3) + + assert set(profiles) == {"src_endpoint.ip", RULE_FIELD} # unmapped skipped + ip = profiles["src_endpoint.ip"] + assert ip["null_rate"] == pytest.approx(0.2) + assert ip["shape_dist"]["ipv4"] == pytest.approx(0.6) + assert ip["shape_dist"]["free_text"] == pytest.approx(0.4) + assert sum(ip["shape_dist"].values()) == pytest.approx(1.0) + rule = profiles[RULE_FIELD] + assert rule["match_rate"] == pytest.approx(0.9) + assert rule["events_count"] == {"min": 10, "max": 14} + + +def test_aggregate_baseline_none_before_n_windows_exist(): + rows = [_hist_row(0, RULE_FIELD, 10, None, 1.0, None)] + assert aggregate_baseline(rows, baseline_windows=10) is None + assert aggregate_baseline([], baseline_windows=10) is None + + +def test_aggregate_baseline_uses_only_the_first_n_windows(): + # Windows 4 and 5 exist (null 0.9) but must NOT enter a 3-window baseline. + rows = ([_hist_row(i * 60, "src_endpoint.ip", 10 + i, 0.1 + 0.1 * i, None, + {"ipv4": 10}) + for i in range(3)] + + [_hist_row(240, "src_endpoint.ip", 99, 0.9, None, {"free_text": 10}), + _hist_row(300, "src_endpoint.ip", 99, 0.9, None, {"free_text": 10})]) + profiles = aggregate_baseline(rows, baseline_windows=3) + assert profiles["src_endpoint.ip"]["null_rate"] == pytest.approx(0.2) + assert profiles["src_endpoint.ip"]["shape_dist"] == {"ipv4": 1.0} + + +def test_aggregate_baseline_skips_none_match_rates_in_mean(): + rows = [ + _hist_row(0, RULE_FIELD, 10, None, 1.0, None), + _hist_row(60, RULE_FIELD, 10, None, 0.8, None), + _hist_row(120, RULE_FIELD, 10, None, None, None), # defensive NULL + ] + profiles = aggregate_baseline(rows, baseline_windows=3) + assert profiles[RULE_FIELD]["match_rate"] == pytest.approx(0.9) diff --git a/services/drift/tests/test_enforce.py b/services/drift/tests/test_enforce.py new file mode 100644 index 0000000..417f2b3 --- /dev/null +++ b/services/drift/tests/test_enforce.py @@ -0,0 +1,331 @@ +# services/drift/tests/test_enforce.py — Task 5 enforcement: quarantine / +# deactivate SQL (TOCTOU expected-status predicates + rowcount checks, the +# gateway writes.py discipline under drift_role's column-scoped grants), +# record_windows row assembly, and worst-first scan enforcement. Everything is +# pinned through a recording fake conn/cursor or monkeypatched collaborators; +# NEVER a live DB. +from datetime import UTC, datetime, timedelta + +import pytest +from ulpf_core.models import Mapping + +from drift import enforce +from drift.detect import RULE_FIELD, Finding, evaluate_window +from drift.enforce import ( + deactivate_rule, + enforce_findings, + quarantine_field, + record_windows, +) +from drift.store import ActiveDriftRule +from drift.windows import FieldStats, Window + + +def _ts(seconds: float) -> datetime: + return datetime(2026, 9, 21, 10, 0, 0, tzinfo=UTC) + timedelta(seconds=seconds) + + +def _rule(fp="fp_a", rule_id=7, version=2, quarantined=()): + return ActiveDriftRule( + id=rule_id, fingerprint_id=fp, version=version, pattern=r"x", + mappings=[Mapping(source_field="SRC", ocsf_path="src_endpoint.ip")], + provenance="human", quarantined_fields=list(quarantined)) + + +class FakeConn: + """Fake psycopg connection: records SQL + params, replays scripted + rowcounts (drift enforcement's TOCTOU answers) and fetch results.""" + + def __init__(self, rowcounts=(), fetchone_results=(), fetchall_results=()): + self.sql: list = [] + self.params: list = [] + self.txns_opened = 0 + self._rowcounts = list(rowcounts) + self._fetchone = list(fetchone_results) + self._fetchall = list(fetchall_results) + + def transaction(self): + conn = self + + class Txn: + def __enter__(self): + conn.txns_opened += 1 + conn.sql.append("TXN-START") + conn.params.append(None) # keep sql/params index-aligned + + def __exit__(self, exc_type, exc, tb): + conn.sql.append("TXN-ROLLBACK" if exc_type is not None else "TXN-COMMIT") + conn.params.append(None) + return False + + return Txn() + + def cursor(self): + conn = self + + class Cur: + rowcount = None + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def execute(self, sql, params=None): + conn.sql.append(sql) + conn.params.append(params) + self.rowcount = (conn._rowcounts.pop(0) + if conn._rowcounts else 1) + + def fetchone(self): + return list(conn._fetchone.pop(0)) if conn._fetchone else [1] + + def fetchall(self): + return list(conn._fetchall.pop(0)) if conn._fetchall else [] + + return Cur() + + +def _unwrap(value): + """psycopg's Json adapter -> plain Python (duck-typed: no psycopg here).""" + return value.obj if hasattr(value, "obj") else value + + +def _statements(conn): + return [(s, p) for s, p in zip(conn.sql, conn.params) if isinstance(s, str)] + + +# --- quarantine_field: array_append + NOT ANY + expected status ------------------- + + +def test_quarantine_field_sql_and_params(): + conn = FakeConn(rowcounts=[1]) + assert quarantine_field(conn, _rule(), "src_endpoint.ip", _ts(0)) is True + + (sql, params), = [sp for sp in _statements(conn) if sp[0].startswith("UPDATE")] + assert sql == ( + "UPDATE rules SET quarantined_fields = array_append(quarantined_fields, %s) " + "WHERE id = %s AND status = 'active' " + "AND NOT (%s = ANY(quarantined_fields))") + # (field for array_append, rule id, field again for the ANY guard) + assert params == ("src_endpoint.ip", 7, "src_endpoint.ip") + + +def test_quarantine_field_rowcount_zero_is_false_and_never_audited(): + # Already quarantined OR rule inactive: the WHERE matches nothing -> 0 + # rows -> False, idempotent, and NO audit row on the no-op. + conn = FakeConn(rowcounts=[0]) + assert quarantine_field(conn, _rule(), "src_endpoint.ip", _ts(0)) is False + assert not [s for s, _ in _statements(conn) if s.startswith("INSERT")] + assert conn.txns_opened == 1 # the UPDATE still ran inside its transaction + + +def test_quarantine_field_true_audits_with_actor_drift_and_detail(): + conn = FakeConn(rowcounts=[1]) + rule = _rule(rule_id=9, version=3) + assert quarantine_field(conn, rule, "dst_endpoint.ip", _ts(90)) is True + + audits = [(s, p) for s, p in _statements(conn) if s.startswith("INSERT INTO audit_log")] + (sql, params), = audits + assert sql == ("INSERT INTO audit_log (actor, action, entity, detail) " + "VALUES (%s, %s, %s, %s)") + assert params[0] == "drift" and params[1] == "field_quarantined" + assert params[2] == "fp_a" + assert _unwrap(params[3]) == {"field": "dst_endpoint.ip", "rule_id": 9, + "version": 3, "window_start": _ts(90)} + # UPDATE and its audit pair inside ONE transaction (the writes.py pairing). + assert conn.txns_opened == 1 + assert conn.sql[0] == "TXN-START" and conn.sql[-1] == "TXN-COMMIT" + + +def test_quarantine_field_touches_only_the_granted_column(): + conn = FakeConn(rowcounts=[1]) + quarantine_field(conn, _rule(), "f", _ts(0)) + (sql, _), = [sp for sp in _statements(conn) if sp[0].startswith("UPDATE")] + # Only quarantined_fields is SET — migration 007 grants UPDATE on exactly + # that column (plus status/deactivated_at for deactivate). + assert sql.startswith("UPDATE rules SET quarantined_fields = array_append") + + +# --- deactivate_rule: expected-status predicate ----------------------------------- + + +def test_deactivate_rule_sql_expected_status_predicate(): + conn = FakeConn(rowcounts=[1]) + assert deactivate_rule(conn, _rule(), _ts(0)) is True + + (sql, params), = [sp for sp in _statements(conn) if sp[0].startswith("UPDATE")] + assert sql == ("UPDATE rules SET status = 'deactivated', deactivated_at = now() " + "WHERE id = %s AND status = 'active'") + assert params == (7,) + + +def test_deactivate_rule_false_on_non_active_and_never_audited(): + # The 409-equivalent under drift_role: status moved concurrently -> 0 rows + # -> False, no audit (a silent no-op that still audits would lie). + conn = FakeConn(rowcounts=[0]) + assert deactivate_rule(conn, _rule(), _ts(0)) is False + assert not [s for s, _ in _statements(conn) if s.startswith("INSERT")] + + +def test_deactivate_rule_true_audits_with_actor_drift(): + conn = FakeConn(rowcounts=[1]) + rule = _rule(rule_id=11, version=4) + assert deactivate_rule(conn, rule, _ts(30)) is True + + (_, params), = [(s, p) for s, p in _statements(conn) + if s.startswith("INSERT INTO audit_log")] + assert params[0] == "drift" and params[1] == "rule_deactivated" + assert params[2] == "fp_a" + assert _unwrap(params[3]) == {"rule_id": 11, "version": 4, "window_start": _ts(30)} + + +# --- record_windows: findings overlaid onto the measured rows --------------------- + + +def _win(): + return Window(start=_ts(0), end=_ts(9), count=10, parsed=10, parse_errors=0, + field_stats={"src_endpoint.ip": FieldStats( + nulls=0, violations=3, values=["198.51.100.7"] * 7)}, + new_unmapped=set()) + + +def test_record_windows_writes_severity_and_action_taken(): + conn = FakeConn() + rule, win = _rule(), _win() + findings = evaluate_window(win, None, rule_quarantined=[]) + record_windows(conn, rule, findings, win) + + inserts = [(s, p) for s, p in _statements(conn) + if s.startswith("INSERT INTO drift_windows")] + assert len(inserts) == 2 # field row + __rule__ row + assert all("ON CONFLICT (fingerprint_id, rule_version, field, window_start) " + "DO NOTHING" in s for s, _ in inserts) + by_field = {p[2]: p for _, p in inserts} + ip = by_field["src_endpoint.ip"] + # 3 violations of 10 parsed = 0.3 -> tier-1 moderate -> field_quarantined. + assert ip[8] == pytest.approx(0.3) and ip[10] == "moderate" + assert ip[11] == "field_quarantined" + assert _unwrap(ip[9]) == {"ipv4": 7} + assert by_field[RULE_FIELD][10] == "none" and by_field[RULE_FIELD][11] is None + + +def test_record_windows_keeps_zero_parsed_and_unmapped_row_shapes(): + conn = FakeConn() + rule = _rule() + win = Window(start=_ts(0), end=_ts(2), count=3, parsed=3, parse_errors=0, + field_stats={"unmapped.NEW": FieldStats(nulls=0, violations=0, + values=["eth9"])}, + new_unmapped={"NEW"}) + record_windows(conn, rule, evaluate_window(win, None, rule_quarantined=[]), win) + + by_field = {p[2]: p for s, p in _statements(conn) + if s.startswith("INSERT INTO drift_windows")} + new = by_field["unmapped.NEW"] + assert new[8] is None # no tier-1 invariant on unmapped + assert new[10] == "minor" and new[11] == "alert" + + zero = Window(start=_ts(0), end=_ts(2), count=3, parsed=0, parse_errors=3, + field_stats={}, new_unmapped=set()) + conn2 = FakeConn() + record_windows(conn2, rule, evaluate_window(zero, None, rule_quarantined=[]), + zero) + fields = [p[2] for s, p in _statements(conn2) + if s.startswith("INSERT INTO drift_windows")] + assert fields == [RULE_FIELD] # zero-parsed: only the sentinel row + + +# --- enforce_findings: worst-first, one deactivate per scan ----------------------- + + +def _finding(field, severity, action, start=0): + return Finding(field, severity, action, {}, _ts(start)) + + +class _Spy: + def __init__(self, results=()): + self.calls = [] + self._results = list(results) + + def __call__(self, conn, rule, *args): + self.calls.append(args if args else rule.fingerprint_id) + return self._results.pop(0) if self._results else True + + +def test_enforce_findings_severe_first_skips_moderate_after_deactivate(monkeypatch): + deact, quar = _Spy(), _Spy() + monkeypatch.setattr(enforce, "deactivate_rule", deact) + monkeypatch.setattr(enforce, "quarantine_field", quar) + findings = [ + _finding("src_endpoint.ip", "moderate", "field_quarantined", 60), + _finding("dst_endpoint.ip", "severe", "rule_deactivated", 0), + ] + result = enforce_findings(FakeConn(), _rule(), findings) + assert result == {"deactivated": True, "quarantined": []} + assert len(deact.calls) == 1 and quar.calls == [] # worst-first: severe won + + +def test_enforce_findings_one_deactivate_per_scan(monkeypatch): + deact = _Spy() + monkeypatch.setattr(enforce, "deactivate_rule", deact) + findings = [ + _finding("src_endpoint.ip", "severe", "rule_deactivated", 0), + _finding(RULE_FIELD, "severe", "rule_deactivated", 60), + _finding("dst_endpoint.ip", "severe", "rule_deactivated", 120), + ] + result = enforce_findings(FakeConn(), _rule(), findings) + assert result["deactivated"] is True + assert len(deact.calls) == 1 # the first worst finding deactivates, rest skip + + +def test_enforce_findings_quarantines_each_moderate_field(monkeypatch): + quar = _Spy() + monkeypatch.setattr(enforce, "deactivate_rule", _Spy()) + monkeypatch.setattr(enforce, "quarantine_field", quar) + findings = [ + _finding("src_endpoint.ip", "moderate", "field_quarantined", 60), + _finding("dst_endpoint.ip", "moderate", "field_quarantined", 0), + ] + result = enforce_findings(FakeConn(), _rule(), findings) + assert result == {"deactivated": False, + "quarantined": ["dst_endpoint.ip", "src_endpoint.ip"]} + assert quar.calls == [("dst_endpoint.ip", _ts(0)), ("src_endpoint.ip", _ts(60))] + + +def test_enforce_findings_ignores_none_minor_and_none_actions(monkeypatch): + deact = _Spy() + quar = _Spy() + monkeypatch.setattr(enforce, "deactivate_rule", deact) + monkeypatch.setattr(enforce, "quarantine_field", quar) + findings = [ + _finding("src_endpoint.ip", "none", None), + _finding("unmapped.NEW", "minor", "alert"), + _finding("dst_endpoint.ip", "moderate", None), # already quarantined + ] + assert enforce_findings(FakeConn(), _rule(), findings) == { + "deactivated": False, "quarantined": []} + assert deact.calls == [] and quar.calls == [] + + +def test_enforce_findings_deactivate_false_still_ends_the_scan(monkeypatch): + # deactivate_rule False (rule no longer active, TOCTOU): remaining actions + # are skipped too — every later UPDATE would no-op anyway. + deact, quar = _Spy(results=[False]), _Spy() + monkeypatch.setattr(enforce, "deactivate_rule", deact) + monkeypatch.setattr(enforce, "quarantine_field", quar) + findings = [ + _finding("src_endpoint.ip", "severe", "rule_deactivated"), + _finding("dst_endpoint.ip", "moderate", "field_quarantined"), + ] + result = enforce_findings(FakeConn(), _rule(), findings) + assert result == {"deactivated": False, "quarantined": []} + assert quar.calls == [] + + +def test_enforce_findings_alerts_never_touch_the_db(monkeypatch): + monkeypatch.setattr(enforce, "deactivate_rule", _Spy()) + conn = FakeConn() + findings = [_finding("src_endpoint.ip", "minor", "alert")] + enforce_findings(conn, _rule(), findings) + assert _statements(conn) == [] # an alert IS the drift_windows row, no SQL diff --git a/services/drift/tests/test_store.py b/services/drift/tests/test_store.py index 4b2d25d..eb70f55 100644 --- a/services/drift/tests/test_store.py +++ b/services/drift/tests/test_store.py @@ -11,6 +11,7 @@ ActiveDriftRule, baseline_for, fetch_current_view, + fetch_window_history, first_window_unmapped, insert_baseline, insert_windows, @@ -181,6 +182,22 @@ def test_baseline_for_maps_fields_to_profiles(): "src_endpoint.ip": {"null_rate": 0.1}, "__rule__": {"match_rate": 1.0}} +# --- fetch_window_history: the baseline aggregation input (Task 5) --------------- + + +def test_fetch_window_history_reads_version_rows_oldest_first(): + rows = [("w0", "src_endpoint.ip", 10, 0.1, None, {"ipv4": 10})] + conn = FakeConn(fetchall_results=[rows]) + assert fetch_window_history(conn, "fp_a", 2) == rows + + sql = next(s for s in conn.sql if isinstance(s, str)) + assert sql == ("SELECT window_start, field, events_count, null_rate, match_rate, " + "shape_dist FROM drift_windows " + "WHERE fingerprint_id = %s AND rule_version = %s " + "ORDER BY window_start, field") + assert conn.params[conn.sql.index(sql)] == ("fp_a", 2) + + # --- first_window_unmapped: the baseline's first-window key set ------------------ From c7fe41d1f2866e50d27ae2e2e67795a3ed75e62a Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 21 Sep 2026 23:48:15 +0530 Subject: [PATCH 08/23] =?UTF-8?q?feat(pipeline):=20honor=20quarantined=20f?= =?UTF-8?q?ields=20=E2=80=94=20mappings=20filtered,=20values=20preserved?= =?UTF-8?q?=20(R-M3-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- services/pipeline/rules.py | 17 +- services/pipeline/tests/test_quarantine.py | 182 +++++++++++++++++++++ services/pipeline/tests/test_rules.py | 68 ++++++++ services/pipeline/worker.py | 15 ++ 4 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 services/pipeline/tests/test_quarantine.py create mode 100644 services/pipeline/tests/test_rules.py diff --git a/services/pipeline/rules.py b/services/pipeline/rules.py index 33b2473..1428661 100644 --- a/services/pipeline/rules.py +++ b/services/pipeline/rules.py @@ -3,11 +3,14 @@ # One SELECT per batch (the worker refreshes every consume batch, so a rule # activated mid-stream is picked up on the next batch — no restart, no push). # mappings arrives as JSONB (list of {source_field, ocsf_path} dicts) and is -# rebuilt into the typed Mapping model before parse() ever sees the rule. +# rebuilt into the typed Mapping model before parse() ever sees the rule; +# quarantined_fields arrives as TEXT[] (a Python list via psycopg) — the M3 +# drift role appends OCSF paths there and the worker filters those mappings. from __future__ import annotations from typing import TYPE_CHECKING +from pydantic import Field from ulpf_core.models import Mapping, Rule if TYPE_CHECKING: @@ -20,10 +23,13 @@ class ActiveRule(Rule): load_active_rules must return dict[str, Rule] per the plan contract, but normalized_events.rule_id wants the DB id — so the id rides on this Rule subclass (an ActiveRule IS a Rule; parse() and the type contract are - unaffected, and the worker reads ``rule.id`` for attribution). + unaffected, and the worker reads ``rule.id`` for attribution). M3 adds + the quarantine list the same way: model_copy in the worker's filter + preserves this class, so id and version ride along on filtered rules. """ id: int + quarantined_fields: list[str] = Field(default_factory=list) def load_active_rules(conn: psycopg.Connection) -> dict[str, Rule]: @@ -32,16 +38,19 @@ def load_active_rules(conn: psycopg.Connection) -> dict[str, Rule]: rules: dict[str, Rule] = {} with conn.cursor() as cur: cur.execute( - "SELECT id, fingerprint_id, version, pattern, mappings, provenance " + "SELECT id, fingerprint_id, version, pattern, mappings, " + "quarantined_fields, provenance " "FROM rules WHERE status = 'active'" ) - for rule_id, fingerprint, version, pattern, mappings, provenance in cur.fetchall(): + for (rule_id, fingerprint, version, pattern, mappings, quarantined, + provenance) in cur.fetchall(): rules[fingerprint] = ActiveRule( id=rule_id, fingerprint_id=fingerprint, version=version, pattern=pattern, mappings=[Mapping(**m) for m in mappings], + quarantined_fields=list(quarantined or []), provenance=provenance, ) return rules diff --git a/services/pipeline/tests/test_quarantine.py b/services/pipeline/tests/test_quarantine.py new file mode 100644 index 0000000..6d94e84 --- /dev/null +++ b/services/pipeline/tests/test_quarantine.py @@ -0,0 +1,182 @@ +# services/pipeline/tests/test_quarantine.py — R-M3-6 (spec §7.2): the worker +# filters mappings whose OCSF path the drift role quarantined (Task 5 appends +# to rules.quarantined_fields) BEFORE parse; the captured key then lands in +# doc['unmapped'] — "mapping disabled, values preserved". An empty quarantine +# list is the M1/M2 hot path and MUST stay byte-identical: pinned here +# against an explicit expected document and a no-quarantine baseline run. +# No Kafka, no real DB (test_worker house style: fakes + monkeypatched +# persistence; the loader pin lives in test_rules.py). +import json + +from ulpf_core.fingerprint import fingerprint_id +from ulpf_core.ids import event_id_for, raw_id_for +from ulpf_core.models import Mapping + +import pipeline.worker as worker_mod +from pipeline.rules import ActiveRule +from pipeline.worker import PipelineWorker + +# Real corpus line (simulator/data/raw_logs_syslog.txt) — both this and the +# nomatch line below fingerprint to the known-format id "syslog", so they hit +# the same active rule. +IPTABLES_LINE = ("Aug 27 14:32:07 fw01 kernel: IPTABLES-DROP: IN=eth0 OUT= " + "SRC=203.0.113.45 DST=192.168.1.10 LEN=60 PROTO=TCP " + "SPT=51422 DPT=22 FLAGS=SYN") +NOMATCH_SYSLOG_LINE = "Aug 27 14:35:00 fw01 sshd[1234]: Accepted publickey for root" + +# The seed's syslog pattern: extension KV scan, SRC/DST mapped (M2 shape). +PATTERN = r"^.*?IPTABLES-\w+:\s*(?P.*)$" + +# What the line parses to with NO quarantine — the explicit M2 regression +# pin. OUT= is never captured by the extension KV grammar (empty value with +# no terminator), so it is absent from unmapped: asserted as the code +# behaves. time is the worker's received_at fill (no time mapping). +EXPECTED_DOC = { + "class_uid": 4001, + "class_name": "Network Activity", + "activity_id": 99, + "severity_id": None, + "time": "2026-09-19T12:00:00+00:00", + "src_endpoint": {"ip": "203.0.113.45"}, + "dst_endpoint": {"ip": "192.168.1.10"}, + "action": None, + "message": None, + "metadata": {"product": "ULPF"}, + "unmapped": {"IN": "eth0", "LEN": 60, "PROTO": "TCP", + "SPT": 51422, "DPT": 22, "FLAGS": "SYN"}, +} + + +def syslog_rule(quarantined=None): + """ActiveRule for the syslog fingerprint; quarantined=None means the kwarg + is not passed at all — the exact M2 construction path.""" + kwargs: dict = { + "id": 11, + "fingerprint_id": fingerprint_id(IPTABLES_LINE), + "version": 2, + "pattern": PATTERN, + "mappings": [Mapping(source_field="SRC", ocsf_path="src_endpoint.ip"), + Mapping(source_field="DST", ocsf_path="dst_endpoint.ip")], + "provenance": "human", + } + if quarantined is not None: + kwargs["quarantined_fields"] = quarantined + return ActiveRule(**kwargs) + + +def envelope_bytes(raw): + return json.dumps({"source_id": "s", "transport": "udp", + "received_at": "2026-09-19T12:00:00+00:00", + "raw": raw}).encode() + + +class FakeMsg: + def __init__(self, partition, offset, value: bytes): + self._p, self._o, self._v = partition, offset, value + + def topic(self): + return "raw.logs" + + def partition(self): + return self._p + + def offset(self): + return self._o + + def error(self): + return None + + def value(self): + return self._v + + +class StubConn: + """Only rollback() is reachable: persistence is monkeypatched out and + every message's fingerprint has an active rule (no sample-count query).""" + + def rollback(self): + pass + + +class SilentProducer: + def produce(self, *args, **kwargs): + pass + + def flush(self, timeout=10.0): + return 0 + + +class CommitCounter: + def __init__(self): + self.commits = 0 + + def commit(self): + self.commits += 1 + + +def run_batch(monkeypatch, rule, lines): + """One process_batch of *lines* under *rule* (all lines share the rule's + fingerprint — asserted); returns the NormalizedRows persist_normalized + received, in message order.""" + assert {fingerprint_id(line) for line in lines} == {rule.fingerprint_id} + seen: dict = {} + monkeypatch.setattr(worker_mod, "persist_batch", lambda conn, rows, parts: None) + monkeypatch.setattr(worker_mod, "load_active_rules", + lambda conn: {rule.fingerprint_id: rule}) + monkeypatch.setattr(worker_mod, "persist_normalized", + lambda conn, events, samples=None: seen.update(events=events)) + msgs = [FakeMsg(0, i + 1, envelope_bytes(line)) for i, line in enumerate(lines)] + worker = PipelineWorker(CommitCounter(), SilentProducer(), StubConn()) + assert worker.process_batch(msgs) is True + return seen["events"] + + +def test_quarantined_mapping_skipped_value_in_unmapped(monkeypatch): + events = run_batch(monkeypatch, syslog_rule(["src_endpoint.ip"]), [IPTABLES_LINE]) + row = events[0] + assert row.status == "parsed" + doc = row.ocsf + # src_endpoint's ONLY mapping was the quarantined one: _new_parts starts + # it as {} and _build_document renders an empty endpoint as None — that + # is what the code produces (mapping disabled, spec §7.2). + assert doc["src_endpoint"] is None + assert doc["dst_endpoint"] == {"ip": "192.168.1.10"} # untouched mapping + # The value is preserved verbatim in unmapped (never dropped, §6.4/§7.2): + # everything else is exactly the no-quarantine document. + assert doc["unmapped"] == dict(EXPECTED_DOC["unmapped"], SRC="203.0.113.45") + assert {k: v for k, v in doc.items() if k not in ("src_endpoint", "unmapped")} == { + k: v for k, v in EXPECTED_DOC.items() if k not in ("src_endpoint", "unmapped")} + + +def test_empty_quarantine_identical_behavior(monkeypatch): + # REGRESSION PIN: quarantined_fields=[] must be byte-identical to the M2 + # no-quarantine path (the worker's falsy check short-circuits the + # model_copy — zero object churn): same document, pinned explicitly + # below, and identical event_id / rule_id / rule_version. + baseline = run_batch(monkeypatch, syslog_rule(), [IPTABLES_LINE])[0] + empty_q = run_batch(monkeypatch, syslog_rule([]), [IPTABLES_LINE])[0] + + assert empty_q.status == baseline.status == "parsed" + assert empty_q.ocsf == EXPECTED_DOC + assert empty_q.ocsf == baseline.ocsf + assert empty_q.event_id == baseline.event_id == event_id_for( + raw_id_for("raw.logs", 0, 1), 2) + assert (empty_q.rule_id, empty_q.rule_version) == \ + (baseline.rule_id, baseline.rule_version) == (11, 2) + + +def test_model_copy_preserves_id(monkeypatch): + # The filter's model_copy must preserve the ActiveRule subclass: rule.id + # attribution and rule.version ride along on the filtered rule for parsed + # AND parse_error rows alike — the MAX_LINE_CHARS cap and the parse-error + # path read the same loop-local rule the copy produced. + parsed, error = run_batch( + monkeypatch, syslog_rule(["src_endpoint.ip"]), + [IPTABLES_LINE, NOMATCH_SYSLOG_LINE]) + + assert parsed.status == "parsed" + assert parsed.rule_id == 11 and parsed.rule_version == 2 # id survived the copy + assert error.status == "parse_error" + assert error.rule_id == 11 and error.rule_version == 2 + assert error.event_id == event_id_for(raw_id_for("raw.logs", 0, 2), 2) + assert error.ocsf is None diff --git a/services/pipeline/tests/test_rules.py b/services/pipeline/tests/test_rules.py new file mode 100644 index 0000000..7984ff0 --- /dev/null +++ b/services/pipeline/tests/test_rules.py @@ -0,0 +1,68 @@ +# services/pipeline/tests/test_rules.py — active-rule cache loader pins +# (M3 R-M3-6): the per-batch SELECT must carry rules.quarantined_fields so +# the worker can filter quarantined mappings at apply time (Task 5's drift +# role appends OCSF paths there). Fake conn in the drift/onboarding house +# style — NEVER a live DB. +from ulpf_core.models import Mapping + +from pipeline.rules import ActiveRule, load_active_rules + + +class FakeConn: + """Fake psycopg connection: records SQL, replays scripted fetch results.""" + + def __init__(self, fetchall_results=()): + self.sql: list = [] + self._fetchall = list(fetchall_results) + + def cursor(self): + conn = self + + class Cur: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def execute(self, sql, params=None): + conn.sql.append(sql) + + def fetchall(self): + return conn._fetchall.pop(0) + + return Cur() + + +def _rule_row(rule_id=11, fp="syslog", quarantined=("src_endpoint.ip",)): + return (rule_id, fp, 2, r"^.*?IPTABLES-\w+:\s*(?P.*)$", [ + {"source_field": "SRC", "ocsf_path": "src_endpoint.ip"}, + {"source_field": "DST", "ocsf_path": "dst_endpoint.ip"}], + list(quarantined) if quarantined is not None else None, "human") + + +def test_load_active_rules_selects_quarantined_fields_column(): + conn = FakeConn([[_rule_row()]]) + rules = load_active_rules(conn) + + # Column list pinned exactly: quarantined_fields must ride the one + # per-batch SELECT alongside mappings (TEXT[] -> Python list via psycopg). + assert conn.sql == [ + ("SELECT id, fingerprint_id, version, pattern, mappings, " + "quarantined_fields, provenance FROM rules WHERE status = 'active'") + ] + rule = rules["syslog"] + assert isinstance(rule, ActiveRule) + assert rule.id == 11 and rule.version == 2 + assert rule.mappings == [ + Mapping(source_field="SRC", ocsf_path="src_endpoint.ip"), + Mapping(source_field="DST", ocsf_path="dst_endpoint.ip")] + assert rule.quarantined_fields == ["src_endpoint.ip"] + + +def test_load_active_rules_null_quarantine_becomes_empty_list(): + # The column is NOT NULL DEFAULT '{}' (migration 001), but the loader is + # defensive exactly like drift.store's: NULL -> [] so the worker's + # falsy check (the M1/M2 hot path) still short-circuits. + conn = FakeConn([[_rule_row(quarantined=None)]]) + assert load_active_rules(conn)["syslog"].quarantined_fields == [] diff --git a/services/pipeline/worker.py b/services/pipeline/worker.py index 101cb4a..f638648 100644 --- a/services/pipeline/worker.py +++ b/services/pipeline/worker.py @@ -205,6 +205,21 @@ def process_batch(self, msgs) -> bool: rules = load_active_rules(self.conn) # refreshed every batch + # M3 quarantine (R-M3-6, spec §7.2 "mapping disabled, values + # preserved"): drop mappings whose OCSF path the drift role + # quarantined — parse() then leaves the captured key in unmapped. + # Applied once per quarantined rule per batch, not per row: one rule + # object is shared by every row of its fingerprint. model_copy + # preserves ActiveRule, so rule.id attribution and rule.version ride + # along (quarantine never bumps the version — no event_id churn); + # the falsy check short-circuits, so an empty quarantine means zero + # copies and the M1/M2 hot path is byte-identical. + for fp, rule in rules.items(): + if rule.quarantined_fields: + rules[fp] = rule.model_copy(update={ + "mappings": [m for m in rule.mappings + if m.ocsf_path not in set(rule.quarantined_fields)]}) + # Sample capture (R17): only fingerprints with NO active rule, capped # per fingerprint by what onboarding_samples already holds. One grouped # count query for the whole batch; known fingerprints query nothing. From a71b2f992661092ad065c81f9b9418cb1bf0996c Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 22 Sep 2026 00:03:57 +0530 Subject: [PATCH 09/23] feat(gateway): drift metrics/alerts + human un-quarantine (R-P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/drift/metrics: latest drift_windows row per (fingerprint_id, field) via DISTINCT ON ... window_start DESC, DEFAULT_LIMIT clamp - GET /api/drift/alerts (controller P-4): one kind-discriminated list — window rows (severity IN minor/moderate/severe) merged with audit rows (field_quarantined/field_unquarantined/rule_deactivated, any actor), both latest-first, each source and the merged list clamp-capped - POST /api/rules/{fp}/unquarantine -> writes.unquarantine_field: array_remove UPDATE under the 007 rules_role grant, TOCTOU predicates (status='active' AND field = ANY(quarantined_fields)), existence pre-check splits 404 (unknown fingerprint) from 409 (inactive rule / field not quarantined), field_unquarantined audit paired inside the same transaction, post-state quarantined_fields returned - tests: +11 (8 app/query pins in test_gateway.py, 3 write-path pins in test_writes.py), purely additive; suite 399 passed, ruff clean Co-Authored-By: Claude Code --- services/gateway/app.py | 28 ++++ services/gateway/queries.py | 59 +++++++++ services/gateway/tests/test_gateway.py | 177 +++++++++++++++++++++++++ services/gateway/tests/test_writes.py | 61 +++++++++ services/gateway/writes.py | 56 ++++++++ 5 files changed, 381 insertions(+) diff --git a/services/gateway/app.py b/services/gateway/app.py index 374ca99..67ab56d 100644 --- a/services/gateway/app.py +++ b/services/gateway/app.py @@ -99,6 +99,12 @@ class ManualBody(BaseModel): confidence: float | None = None +class UnquarantineBody(BaseModel): + field: str + actor: str = "anonymous" + reason: str | None = None + + def _write_http_error(exc: writes.WriteError) -> HTTPException: """writes.* typed errors -> the API contract: 404 unknown, 409 conflict, 422 failed validation carrying the gate's checks + notes (the Review UI @@ -237,6 +243,28 @@ async def reactivate(rule_id: int, body: RejectBody | None = None): except writes.WriteError as exc: raise _write_http_error(exc) from exc + # ---------- M3: drift surfaces + human un-quarantine (Task 7) ---------- + + @app.get("/api/drift/metrics") + async def drift_metrics(): + return {"metrics": await asyncio.to_thread(queries_module.fetch_drift_metrics)} + + @app.get("/api/drift/alerts") + async def drift_alerts(): + # P-4: one kind-discriminated list — "window" rows (drift_windows with + # minor/moderate/severe severity) merged with "audit" rows (quarantine/ + # un-quarantine/deactivate actions, any actor), latest first. + return {"alerts": await asyncio.to_thread(queries_module.fetch_drift_alerts)} + + @app.post("/api/rules/{fingerprint_id}/unquarantine") + async def unquarantine(fingerprint_id: str, body: UnquarantineBody): + try: + return await asyncio.to_thread( + writes_module.unquarantine_field, fingerprint_id, body.field, + actor=body.actor, reason=body.reason) + except writes.WriteError as exc: + raise _write_http_error(exc) from exc + return app diff --git a/services/gateway/queries.py b/services/gateway/queries.py index 8420052..33f80ad 100644 --- a/services/gateway/queries.py +++ b/services/gateway/queries.py @@ -245,3 +245,62 @@ def fetch_audit(fingerprint=None, limit=DEFAULT_LIMIT): (fingerprint, fingerprint, limit), ) return cur.fetchall() + + +# --- M3 drift surfaces (Task 7; SELECT arrives via 003's default privileges) ---- + +_DRIFT_WINDOW_COLUMNS = ( + "fingerprint_id, rule_version, field, window_start, window_end, events_count, " + "null_rate, match_rate, violation_rate, shape_dist, severity, action_taken" +) + + +def fetch_drift_metrics(limit=DEFAULT_LIMIT) -> list[dict]: + """GET /api/drift/metrics rows: the LATEST closed window per + (fingerprint_id, field) — DISTINCT ON over window_start DESC — clamp-capped. + shape_dist is JSONB, so dict_row hands back a dict natively (no unwrap).""" + limit = clamp_limit(limit) + with _connect() as conn, conn.cursor() as cur: + cur.execute( + f"SELECT DISTINCT ON (fingerprint_id, field) {_DRIFT_WINDOW_COLUMNS} " + "FROM drift_windows " + "ORDER BY fingerprint_id, field, window_start DESC LIMIT %s", + (limit,), + ) + return cur.fetchall() + + +def _alert_recency(row: dict): + """Latest-first merge key across the two alert sources (P-4): a window row + is as recent as its window_start, an audit row as its ts.""" + return row["window_start"] if "window_start" in row else row["ts"] + + +def fetch_drift_alerts(limit=DEFAULT_LIMIT) -> list[dict]: + """GET /api/drift/alerts rows — controller ruling P-4's kind-discriminated + union, rendered by the web feed as ONE list: "window" rows are + drift_windows with severity minor/moderate/severe (the row IS the alert); + "audit" rows are audit_log actions field_quarantined/field_unquarantined/ + rule_deactivated from ANY actor (drift's enforcement or a human override). + Both sources are fetched latest-first and clamp-capped, then merged into a + single latest-first list capped at the same limit. The severity/action + IN-lists are module-controlled vocabulary (never request input), so they + stay literals — the _STATUS_KEYS / samples-role style.""" + limit = clamp_limit(limit) + with _connect() as conn, conn.cursor() as cur: + cur.execute( + f"SELECT {_DRIFT_WINDOW_COLUMNS} FROM drift_windows " + "WHERE severity IN ('minor', 'moderate', 'severe') " + "ORDER BY window_start DESC LIMIT %s", + (limit,), + ) + windows = [dict(row, kind="window") for row in cur.fetchall()] + cur.execute( + "SELECT id, ts, actor, action, entity, detail FROM audit_log " + "WHERE action IN ('field_quarantined', 'field_unquarantined', " + "'rule_deactivated') " + "ORDER BY id DESC LIMIT %s", + (limit,), + ) + audits = [dict(row, kind="audit") for row in cur.fetchall()] + return sorted(windows + audits, key=_alert_recency, reverse=True)[:limit] diff --git a/services/gateway/tests/test_gateway.py b/services/gateway/tests/test_gateway.py index 66b13b0..d7c8c0d 100644 --- a/services/gateway/tests/test_gateway.py +++ b/services/gateway/tests/test_gateway.py @@ -708,3 +708,180 @@ def fake_reactivate(rule_id, **kwargs): assert r.json() == {"rule_id": 5, "version": 2, "status": "active"} assert seen["reactivate"][0] == 5 assert seen["reactivate"][1]["actor"] == "anonymous" # default actor + + +# ================= M3 (Task 7): drift surfaces + human un-quarantine ========== +# Everything below is ADDITIVE — the frozen M1+M2 contracts above are untouched. +# Same patterns: GETs return the queries-module fixtures verbatim (shapes +# mirrored key-for-key by Task 10's types.ts); the un-quarantine POST forwards +# to gateway.writes off the event loop; the fake-conn tests pin the real SQL. + +DRIFT_WINDOW_KEYS = { + "fingerprint_id", "rule_version", "field", "window_start", "window_end", + "events_count", "null_rate", "match_rate", "violation_rate", "shape_dist", + "severity", "action_taken", +} + + +def drift_window_row(**overrides) -> dict: + """One drift_windows row as psycopg dict_row returns it (JSONB shape_dist + loaded as a dict, real timestamptz datetimes).""" + row = { + "fingerprint_id": "fp_ssh_denied", + "rule_version": 3, + "field": "src_endpoint.ip", + "window_start": datetime(2026, 9, 21, 12, 0, 0, tzinfo=UTC), + "window_end": datetime(2026, 9, 21, 12, 15, 0, tzinfo=UTC), + "events_count": 240, + "null_rate": 0.012, + "match_rate": 0.988, + "violation_rate": 0.0, + "shape_dist": {"ipv4": 230, "int": 8}, + "severity": "minor", + "action_taken": "alert", + } + row.update(overrides) + return row + + +# ---------- GET /api/drift/metrics ---------- + +def test_drift_metrics_shape(client, queries, monkeypatch): + rows = [drift_window_row()] + monkeypatch.setattr(queries, "fetch_drift_metrics", lambda: rows) + r = client.get("/api/drift/metrics") + assert r.status_code == 200 + assert r.json() == {"metrics": [jsonable(rows[0])]} + assert set(r.json()["metrics"][0]) == DRIFT_WINDOW_KEYS # exact keys + + +# ---------- GET /api/drift/alerts (controller P-4: one discriminated list) ---- + +def test_drift_alerts_shape_both_kinds(client, queries, monkeypatch): + alerts = [ + dict(drift_window_row(severity="severe", action_taken="field_quarantined"), + kind="window"), + dict(audit_row(action="field_quarantined", actor="drift"), kind="audit"), + dict(audit_row(action="field_unquarantined", actor="amy", id=42), kind="audit"), + ] + monkeypatch.setattr(queries, "fetch_drift_alerts", lambda: alerts) + r = client.get("/api/drift/alerts") + assert r.status_code == 200 + assert r.json() == {"alerts": [jsonable(a) for a in alerts]} + # window rows: metric shape + the P-4 discriminator; audit rows: audit shape + it + assert set(r.json()["alerts"][0]) == DRIFT_WINDOW_KEYS | {"kind"} + assert r.json()["alerts"][0]["kind"] == "window" + assert set(r.json()["alerts"][1]) == AUDIT_ROW_KEYS | {"kind"} + assert r.json()["alerts"][1]["kind"] == "audit" + assert r.json()["alerts"][2]["kind"] == "audit" # any actor, per P-4 + + +# ---------- drift read helpers over a fake conn (real SQL pinned) ---------- + +def test_fetch_drift_metrics_latest_window_per_field(queries, monkeypatch): + conn = _FakeRowsConn(fetchall=[[drift_window_row()]]) + monkeypatch.setattr(queries, "_connect", lambda: conn) + assert queries.fetch_drift_metrics() == [drift_window_row()] + # LATEST window per (fingerprint_id, field): DISTINCT ON over window_start DESC + assert "DISTINCT ON (fingerprint_id, field)" in conn.sql[0] + assert "FROM drift_windows" in conn.sql[0] + assert "ORDER BY fingerprint_id, field, window_start DESC LIMIT %s" in conn.sql[0] + assert conn.params[0] == (50,) # DEFAULT_LIMIT through the clamp + + +def test_fetch_drift_alerts_queries_both_sources_and_clamps(queries, monkeypatch): + conn = _FakeRowsConn(fetchall=[ + [drift_window_row()], + [audit_row(action="field_quarantined", actor="drift")], + ]) + monkeypatch.setattr(queries, "_connect", lambda: conn) + out = queries.fetch_drift_alerts(limit=10**9) # nonsense limit -> clamped + assert len(conn.sql) == 2 # BOTH sources: drift_windows, then audit_log + assert "FROM drift_windows" in conn.sql[0] + assert "severity IN ('minor', 'moderate', 'severe')" in conn.sql[0] + assert "ORDER BY window_start DESC LIMIT %s" in conn.sql[0] + assert "FROM audit_log" in conn.sql[1] + assert ("action IN ('field_quarantined', 'field_unquarantined', " + "'rule_deactivated')") in conn.sql[1] + assert "ORDER BY id DESC LIMIT %s" in conn.sql[1] + assert conn.params[0] == (500,) and conn.params[1] == (500,) + assert [row["kind"] for row in out] == ["window", "audit"] # P-4 discriminator + + +def test_fetch_drift_alerts_merges_latest_first_across_sources(queries, monkeypatch): + new_window = drift_window_row(window_start=datetime(2026, 9, 21, 13, 0, tzinfo=UTC)) + old_window = drift_window_row(field="dst_endpoint.ip", + window_start=datetime(2026, 9, 20, 13, 0, tzinfo=UTC)) + drift_audit = audit_row(action="field_quarantined", actor="drift", + ts=datetime(2026, 9, 21, 12, 30, tzinfo=UTC)) + human_audit = audit_row(action="field_unquarantined", actor="amy", + ts=datetime(2026, 9, 19, 9, 0, tzinfo=UTC)) + conn = _FakeRowsConn(fetchall=[[new_window, old_window], + [drift_audit, human_audit]]) + monkeypatch.setattr(queries, "_connect", lambda: conn) + out = queries.fetch_drift_alerts() + # ONE list, latest first across both sources (window_start vs ts as the key) + assert [row["kind"] for row in out] == ["window", "audit", "window", "audit"] + assert out[0]["window_start"] == new_window["window_start"] + assert out[1]["ts"] == drift_audit["ts"] + assert out[3]["ts"] == human_audit["ts"] + + capped = _FakeRowsConn(fetchall=[[new_window, old_window], + [drift_audit, human_audit]]) + monkeypatch.setattr(queries, "_connect", lambda: capped) + assert len(queries.fetch_drift_alerts(limit=1)) == 1 # merged list is capped too + + +# ---------- POST /api/rules/{fp}/unquarantine (human override) ---------- + +def test_unquarantine_endpoint_forwards_and_runs_in_worker_thread(client, monkeypatch): + import threading + + from gateway import writes + + seen = {} + + def fake(fp, field, **kwargs): + seen["fp"], seen["field"], seen["kwargs"] = fp, field, kwargs + seen["main_thread"] = threading.current_thread() is threading.main_thread() + return {"fingerprint_id": fp, "field": field, "quarantined_fields": []} + + monkeypatch.setattr(writes, "unquarantine_field", fake) + r = client.post("/api/rules/fp_ssh_denied/unquarantine", + json={"field": "src_endpoint.ip", "actor": "amy", + "reason": "false positive"}) + assert r.status_code == 200 + assert r.json() == {"fingerprint_id": "fp_ssh_denied", + "field": "src_endpoint.ip", "quarantined_fields": []} + assert (seen["fp"], seen["field"]) == ("fp_ssh_denied", "src_endpoint.ip") + assert seen["kwargs"] == {"actor": "amy", "reason": "false positive"} + assert seen["main_thread"] is False # asyncio.to_thread, not the event loop + + +def test_unquarantine_defaults_actor_and_reason(client, monkeypatch): + from gateway import writes + + seen = {} + + def fake(fp, field, **kwargs): + seen.update(fp=fp, field=field, **kwargs) + return {"fingerprint_id": fp, "field": field, "quarantined_fields": ["other"]} + + monkeypatch.setattr(writes, "unquarantine_field", fake) + r = client.post("/api/rules/fp_a/unquarantine", json={"field": "src_endpoint.ip"}) + assert r.status_code == 200 + assert seen["actor"] == "anonymous" and seen["reason"] is None # body defaults + + +def test_unquarantine_error_mapping_404_and_409(client, monkeypatch): + from gateway import writes + + monkeypatch.setattr(writes, "unquarantine_field", + _raises(writes.NotFoundError("unknown fingerprint"))) + assert client.post("/api/rules/fp_none/unquarantine", + json={"field": "x"}).status_code == 404 + + monkeypatch.setattr(writes, "unquarantine_field", + _raises(writes.ConflictError("not quarantined"))) + assert client.post("/api/rules/fp_a/unquarantine", + json={"field": "x"}).status_code == 409 diff --git a/services/gateway/tests/test_writes.py b/services/gateway/tests/test_writes.py index 3abb5ea..677e5d7 100644 --- a/services/gateway/tests/test_writes.py +++ b/services/gateway/tests/test_writes.py @@ -502,3 +502,64 @@ def test_reactivate_rowcount_zero_conflicts_without_audit(): writes.reactivate_rule(5, conn=conn) assert not _audit_rows(conn) assert "TXN-ROLLBACK" in conn.sql + + +# --- un-quarantine (M3 Task 7: human override of drift's field quarantine) ----- + + +def test_unquarantine_removes_field_audits_inside_txn_and_returns_post_state(): + conn = FakeConn(fetchone=[ + {"id": 5}, # existence: the fingerprint has rule rows + {"quarantined_fields": [], "id": 5, "version": 2}, # RETURNING post-state + ]) + result = writes.unquarantine_field("fp_a", "src_endpoint.ip", actor="amy", + reason="false positive", conn=conn) + + assert result == {"fingerprint_id": "fp_a", "field": "src_endpoint.ip", + "quarantined_fields": []} + assert conn.txns_opened == 1 # single transaction + assert conn.sql[0] == "TXN-START" and conn.sql[-1] == "TXN-COMMIT" + + # Existence pre-check: any rule row proves the fingerprint known (status + # deliberately unfiltered — inactive is a 409, not a 404). + assert "SELECT id FROM rules WHERE fingerprint_id = %s LIMIT 1" in conn.sql[1] + assert conn.params[1] == ("fp_a",) + + updates = _statements(conn, "UPDATE rules") + assert len(updates) == 1 + assert "quarantined_fields = array_remove(quarantined_fields, %s)" in updates[0][0] + assert "WHERE fingerprint_id = %s AND status = 'active'" in updates[0][0] + assert "(%s = ANY(quarantined_fields))" in updates[0][0] # TOCTOU predicate + assert updates[0][1] == ("src_endpoint.ip", "fp_a", "src_endpoint.ip") + + audits = _audit_rows(conn) + assert len(audits) == 1 + (actor, action, entity, _), detail = audits[0] + assert (actor, action, entity) == ("amy", "field_unquarantined", "fp_a") + assert detail == {"field": "src_endpoint.ip", "rule_id": 5, "version": 2, + "reason": "false positive"} + # audit paired INSIDE the mutation's transaction bracket + start, end = conn.sql.index("TXN-START"), conn.sql.index("TXN-COMMIT") + audit_idx = next(i for i, sql in enumerate(conn.sql) + if isinstance(sql, str) and sql.startswith("INSERT INTO audit_log")) + assert start < audit_idx < end + + +def test_unquarantine_unknown_fingerprint_not_found(): + conn = FakeConn(fetchone=[None]) + with pytest.raises(writes.NotFoundError): + writes.unquarantine_field("fp_none", "src_endpoint.ip", conn=conn) + assert not _statements(conn, "UPDATE rules") + assert not _audit_rows(conn) + + +def test_unquarantine_field_not_quarantined_or_inactive_conflicts(): + # The fingerprint exists (existence check passes) but the UPDATE matches + # zero rows: the rule is inactive, or the field was never quarantined / + # already un-quarantined — 409, no audit for a mutation that never landed. + conn = FakeConn(fetchone=[{"id": 5}], rowcounts=[0]) + with pytest.raises(writes.ConflictError): + writes.unquarantine_field("fp_a", "src_endpoint.ip", conn=conn) + assert _statements(conn, "UPDATE rules") # the UPDATE ran and lost the race + assert not _audit_rows(conn) + assert "TXN-ROLLBACK" in conn.sql diff --git a/services/gateway/writes.py b/services/gateway/writes.py index bce4cc0..d1b9a96 100644 --- a/services/gateway/writes.py +++ b/services/gateway/writes.py @@ -482,3 +482,59 @@ def _reactivate_once(conn, rule_id, actor, reason): "reason": reason, }, actor=actor) return {"rule_id": rule_id, "version": row["version"], "status": "active"} + + +# --- un-quarantine (M3 Task 7: human override of drift's field quarantine) ------ + +# Column-scoped exactly to migration 007's rules_role grant (quarantined_fields +# only). The predicate carries BOTH TOCTOU backstops: status='active' and the +# field actually being quarantined — so an inactive rule, a concurrent +# un-quarantine, or a never-quarantined field updates zero rows and can never +# still write the audit row. RETURNING supplies the post-state list for the +# response body plus the rule identity for the audit detail. +_UNQUARANTINE_SQL = ( + "UPDATE rules SET quarantined_fields = array_remove(quarantined_fields, %s) " + "WHERE fingerprint_id = %s AND status = 'active' " + "AND (%s = ANY(quarantined_fields)) " + "RETURNING quarantined_fields, id, version" +) + + +def unquarantine_field(fingerprint_id: str, field: str, *, actor: str = "anonymous", + reason: str | None = None, + conn: psycopg.Connection | None = None) -> dict: + """Remove one field from the ACTIVE rule's quarantined_fields — the human + override that re-enables a mapping drift quarantined (values were preserved + in unmapped, so nothing is lost). 404 when the fingerprint has no rule row + at all; 409 when the UPDATE matches nothing: the rule is inactive, or the + field is not quarantined (anymore). The field_unquarantined audit row is + paired INSIDE the same transaction as the mutation.""" + return _run(_unquarantine_once, conn, fingerprint_id, field, actor, reason) + + +def _unquarantine_once(conn, fingerprint_id, field, actor, reason): + with conn.transaction(), conn.cursor() as cur: + # Existence pre-check first (the deactivate_rule pattern) so an unknown + # fingerprint answers 404, distinct from the UPDATE's 409 cases. Any + # rule row proves the fingerprint known — status is deliberately NOT + # filtered here (inactive is a 409, not a 404). + cur.execute( + "SELECT id FROM rules WHERE fingerprint_id = %s LIMIT 1", + (fingerprint_id,), + ) + if cur.fetchone() is None: + raise NotFoundError(f"no rule for fingerprint {fingerprint_id!r}") + cur.execute(_UNQUARANTINE_SQL, (field, fingerprint_id, field)) + if cur.rowcount == 0: + raise ConflictError( + f"field {field!r} is not quarantined on the active rule for " + f"fingerprint {fingerprint_id!r} (or the rule is not active)") + row = cur.fetchone() + _audit(cur, "field_unquarantined", fingerprint_id, { + "field": field, + "rule_id": row["id"], + "version": row["version"], + "reason": reason, + }, actor=actor) + return {"fingerprint_id": fingerprint_id, "field": field, + "quarantined_fields": row["quarantined_fields"]} From 712ce24f1a1a533195b958a537f650c8fda022d4 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 22 Sep 2026 00:17:07 +0530 Subject: [PATCH 10/23] feat(gateway): OCSF JSONL + Parquet export endpoints (R-M3-8, P-6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both exports share fetch_export_rows (parsed-only current view, newest first, EXPORT_MAX=50000 clamp): /api/export/ocsf streams one OCSF doc per line (application/x-ndjson, non-parsed rows skipped — never a null line); /api/export/parquet returns a snappy attachment with a flat stable schema (ocsf as JSON string, ISO timestamps before the table). pyarrow pinned to 18.1.0 per controller P-6 (cp312+cp313 wheels; host==CI==image). Co-Authored-By: Claude Code --- services/gateway/app.py | 99 +++++++++++- services/gateway/queries.py | 41 +++++ services/gateway/requirements.txt | 4 + services/gateway/tests/test_gateway.py | 212 ++++++++++++++++++++++++- 4 files changed, 352 insertions(+), 4 deletions(-) diff --git a/services/gateway/app.py b/services/gateway/app.py index 67ab56d..a476bbe 100644 --- a/services/gateway/app.py +++ b/services/gateway/app.py @@ -18,7 +18,7 @@ import uvicorn from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import StreamingResponse +from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from gateway import queries, writes @@ -41,6 +41,14 @@ def _json_default(value): raise TypeError(f"unserializable SSE value: {type(value).__name__}") +def _wire_str(value) -> str: + """Scalar wire form shared by BOTH exports: exactly the serialization + _json_default applies inside json.dumps (UUID -> str, datetime -> + ISO-8601), applied to Parquet columns BEFORE the arrow table is built so + every non-int column is a plain string and the file schema stays stable.""" + return value.isoformat() if isinstance(value, datetime) else str(value) + + async def sse_events(queries_module, poll_seconds, heartbeat_seconds, max_polls): """Yield one `data:` line per new current-view row, then poll. @@ -105,6 +113,55 @@ class UnquarantineBody(BaseModel): reason: str | None = None +# --- M3 export bodies (Task 8; spec §11 / PS g+h — SIEM/data-lake feeds). ------ +# Both builders are module-level like sse_events so the routes can run them in +# a worker thread; both share fetch_export_rows (parsed-only current view, +# newest first, EXPORT_MAX-capped) so the two formats carry identical rows. + +def _export_jsonl_lines(queries_module, fingerprint, status, limit) -> list[str]: + """OCSF JSONL export body, assembled in the caller's worker thread (the + route runs this via asyncio.to_thread so the fetch + serialization never + touch the event loop): ONE OCSF document per line, newest first. Rows + arrive parsed-only from fetch_export_rows, so no line is ever "null". + Memory bound = the EXPORT_MAX cap: the row list and the line list are both + capped by the same LIMIT.""" + rows = queries_module.fetch_export_rows(fingerprint, status, limit) + return [json.dumps(row["ocsf"], default=_json_default) for row in rows] + + +def _export_parquet_bytes(queries_module, fingerprint, limit) -> bytes: + """Parquet export body, built in the caller's worker thread. pyarrow is + imported lazily (mirrors queries.py's lazy psycopg) so importing + gateway.app stays cheap for non-export callers. The ocsf column is stored + as a JSON-serialized STRING per row: nested OCSF documents vary row to row + (optional attributes come and go), so a struct schema would churn with + every producer change — a flat string column keeps the file schema + constant for data-lake ingestion (consumers json-parse it on read). + Timestamps/UUIDs are wire-serialized BEFORE the table (_wire_str: the same + serialization as the JSONL export) so both formats carry identical values + and the arrow types are plain strings. Memory bound = the EXPORT_MAX cap + (row list + column arrays + the in-memory file).""" + import io + + import pyarrow as pa + import pyarrow.parquet as pq + + rows = queries_module.fetch_export_rows(fingerprint, None, limit) + table = pa.table({ + "event_id": pa.array([_wire_str(r["event_id"]) for r in rows], type=pa.string()), + "raw_id": pa.array([_wire_str(r["raw_id"]) for r in rows], type=pa.string()), + "fingerprint_id": pa.array([r["fingerprint_id"] for r in rows], type=pa.string()), + "rule_version": pa.array([r["rule_version"] for r in rows], type=pa.int32()), + "status": pa.array([r["status"] for r in rows], type=pa.string()), + "parsed_at": pa.array([_wire_str(r["parsed_at"]) for r in rows], type=pa.string()), + "ocsf": pa.array([json.dumps(r["ocsf"], default=_json_default) for r in rows], + type=pa.string()), + }) + sink = io.BytesIO() + pq.write_table(table, sink, compression="snappy") + return sink.getvalue() + + def _write_http_error(exc: writes.WriteError) -> HTTPException: """writes.* typed errors -> the API contract: 404 unknown, 409 conflict, 422 failed validation carrying the gate's checks + notes (the Review UI @@ -265,6 +322,46 @@ async def unquarantine(fingerprint_id: str, body: UnquarantineBody): except writes.WriteError as exc: raise _write_http_error(exc) from exc + # ---------- M3: export endpoints (Task 8; spec §11 / PS g+h) ---------- + + @app.get("/api/export/ocsf") + async def export_ocsf( + fingerprint: str | None = None, + status: str | None = None, + limit: int = Query(default=queries.EXPORT_MAX), + ): + """OCSF JSONL export: application/x-ndjson, ONE OCSF document per + line, newest first. Parsed-status DEFAULT — the export carries OCSF + documents and only parsed rows have one, so the status filter narrows + WITHIN parsed: non-parsed rows are SKIPPED (status=unparsed/ + parse_error/quarantined matches nothing and yields a 200 with an empty + stream — never a "null" line). limit deliberately has NO le= bound: + a bulk export must not 422; the fetch clamps it to EXPORT_MAX.""" + lines = await asyncio.to_thread( + _export_jsonl_lines, queries_module, fingerprint, status, limit) + return StreamingResponse((line + "\n" for line in lines), + media_type="application/x-ndjson") + + @app.get("/api/export/parquet") + async def export_parquet( + fingerprint: str | None = None, + limit: int = Query(default=queries.EXPORT_MAX), + ): + """Parquet export: the SAME fetch as the JSONL endpoint (EventRow + columns + ocsf, parsed-only current view, newest first, EXPORT_MAX- + capped; no status param in the frozen signature — the parsed default + applies), returned as a snappy-compressed attachment. The ocsf column + rides as a JSON string (flat, stable schema) and timestamps are + ISO-8601 strings — see _export_parquet_bytes.""" + data = await asyncio.to_thread( + _export_parquet_bytes, queries_module, fingerprint, limit) + return Response( + content=data, + media_type="application/octet-stream", + headers={"Content-Disposition": + 'attachment; filename="ulpf-events.parquet"'}, + ) + return app diff --git a/services/gateway/queries.py b/services/gateway/queries.py index 33f80ad..7940bb1 100644 --- a/services/gateway/queries.py +++ b/services/gateway/queries.py @@ -22,6 +22,10 @@ DEFAULT_LIMIT = 50 MAX_LIMIT = 500 SNAPSHOT_LIMIT = 100 # SSE initial snapshot: latest N rows, emitted oldest-first +# Export rows are bulk (SIEM/data-lake integration), so the export endpoints +# get their OWN cap — 100x MAX_LIMIT — kept separate so the interactive LIMIT +# clamp can never silently widen (and vice versa). +EXPORT_MAX = 50_000 _STATUS_KEYS = ("parsed", "unparsed", "parse_error", "quarantined") @@ -304,3 +308,40 @@ def fetch_drift_alerts(limit=DEFAULT_LIMIT) -> list[dict]: ) audits = [dict(row, kind="audit") for row in cur.fetchall()] return sorted(windows + audits, key=_alert_recency, reverse=True)[:limit] + + +# --- M3 exports (Task 8; spec §11 / PS g+h — SIEM/data-lake integration) -------- + +def clamp_export_limit(limit: int) -> int: + """Export LIMIT clamp: 1..EXPORT_MAX regardless of caller input. The export + endpoints take an UNBOUNDED limit param (no FastAPI le= — a bulk export + must not 422) and clamp here, in the query: one enforcement point, pinned + by the fake-conn tests.""" + return max(1, min(int(limit), EXPORT_MAX)) + + +def fetch_export_rows(fingerprint=None, status=None, limit=EXPORT_MAX): + """Export fetch shared by GET /api/export/{ocsf,parquet}: current view + (R9 predicate), EventRow columns + ocsf, newest first, clamp-capped at + EXPORT_MAX. + + PARSED-ONLY by design: the exports carry OCSF documents, and only parsed + rows have one. The `status = 'parsed'` literal is module-controlled + vocabulary (never request input), so it stays a literal like the + drift-severity lists; the status param narrows WITHIN parsed — it arrives + parameterized and INTERSECTS, so status=unparsed/parse_error/quarantined + selects nothing (non-parsed rows are skipped here, in SQL — the JSONL + stream never carries a "null" line) rather than erroring.""" + limit = clamp_export_limit(limit) + with _connect() as conn, conn.cursor() as cur: + cur.execute( + "SELECT event_id, raw_id, fingerprint_id, rule_version, status, parsed_at, ocsf " + "FROM normalized_events " + "WHERE superseded_by_event_id IS NULL " + "AND status = 'parsed' " + "AND (%s::text IS NULL OR status = %s) " + "AND (%s::text IS NULL OR fingerprint_id = %s) " + "ORDER BY parsed_at DESC LIMIT %s", + (status, status, fingerprint, fingerprint, limit), + ) + return cur.fetchall() diff --git a/services/gateway/requirements.txt b/services/gateway/requirements.txt index c20a40c..53ef28c 100644 --- a/services/gateway/requirements.txt +++ b/services/gateway/requirements.txt @@ -1,4 +1,8 @@ fastapi==0.111.0 uvicorn[standard]==0.30.1 psycopg[binary]==3.2.1 +# Controller P-6: 18.1.0, NOT 17.0.0 — 17.0.0 has no cp313 wheel and the +# py3.13 host must run the same version as CI (py3.12) and the image; 18.1.0 +# ships both cp312 and cp313 wheels. +pyarrow==18.1.0 httpx # tests (FastAPI TestClient), dev diff --git a/services/gateway/tests/test_gateway.py b/services/gateway/tests/test_gateway.py index d7c8c0d..921871a 100644 --- a/services/gateway/tests/test_gateway.py +++ b/services/gateway/tests/test_gateway.py @@ -263,11 +263,13 @@ def test_clamp_limit(queries): def test_r9_current_view_predicate_in_every_normalized_query(queries): # Controller R9: EVERY query over normalized_events carries # `superseded_by_event_id IS NULL` — fetch_stats (3 normalized queries), - # fetch_events, fetch_raw_trace (join), poll_events. fetch_chain_heads is - # deliberately absent (raw_batches has no supersede column). + # fetch_events, fetch_raw_trace (join), poll_events, fetch_export_rows + # (M3 export fetch). fetch_chain_heads is deliberately absent + # (raw_batches has no supersede column). import inspect - for fn_name in ("fetch_stats", "fetch_events", "fetch_raw_trace", "poll_events"): + for fn_name in ("fetch_stats", "fetch_events", "fetch_raw_trace", "poll_events", + "fetch_export_rows"): src = inspect.getsource(getattr(queries, fn_name)) assert "superseded_by_event_id IS NULL" in src, f"{fn_name} missing R9 predicate" @@ -885,3 +887,207 @@ def test_unquarantine_error_mapping_404_and_409(client, monkeypatch): _raises(writes.ConflictError("not quarantined"))) assert client.post("/api/rules/fp_a/unquarantine", json={"field": "x"}).status_code == 409 + + +# ============== M3 (Task 8): export endpoints (OCSF JSONL + Parquet) =========== +# Both exports share fetch_export_rows (parsed-only current view, newest +# first, EXPORT_MAX-capped). Route tests monkeypatch the fetch and pin the +# wire bytes; fake-conn tests pin the real SQL — the parsed-only literal, the +# intersecting status filter, the R9 predicate, and the cap clamp (the ONE +# enforcement point). The Parquet tests round-trip the ACTUAL response bytes +# through pq.read_table — the same path a data lake takes. + +PARQUET_COLUMNS = ["event_id", "raw_id", "fingerprint_id", + "rule_version", "status", "parsed_at", "ocsf"] + + +def ocsf_doc(**overrides) -> dict: + """One realistic parsed-row OCSF document (class_uid 4001 = File Activity, + nested attributes exactly as the JSONB column loads them).""" + doc = {"class_uid": 4001, "activity_name": "Open", + "severity_id": 99, "src_endpoint": {"ip": "10.1.2.3", "port": 51000}} + doc.update(overrides) + return doc + + +def _read_parquet(content): + import io + + import pyarrow.parquet as pq + + return pq.read_table(io.BytesIO(content)) + + +# ---------- GET /api/export/ocsf (JSONL) ---------- + +def test_export_ocsf_one_ocsf_doc_per_line(client, queries, monkeypatch): + rows = [event_row(ocsf=ocsf_doc(activity_name="Open"), + event_id=uuid.UUID("11111111-1111-1111-1111-111111111111")), + event_row(ocsf=ocsf_doc(activity_name="Close", + dst_endpoint={"ip": "192.0.2.9"}))] + monkeypatch.setattr(queries, "fetch_export_rows", lambda *a, **k: rows) + r = client.get("/api/export/ocsf") + assert r.status_code == 200 + assert r.headers["content-type"].startswith("application/x-ndjson") + lines = r.content.decode("utf-8").splitlines() + assert len(lines) == 2 # one line per row: the OCSF doc alone, not EventRow + docs = [json.loads(line) for line in lines] + assert all(doc["class_uid"] == 4001 for doc in docs) + assert docs == [rows[0]["ocsf"], rows[1]["ocsf"]] # faithful, unkeyed + assert r.content.decode("utf-8").endswith("\n") # NDJSON trailing newline + + +def test_export_ocsf_newest_first(client, queries, monkeypatch): + # fetch_export_rows orders newest first (pinned in the SQL below); the + # stream must emit that order unchanged — line 0 is the newest row's doc. + newest = event_row(parsed_at=datetime(2026, 9, 21, 12, 0, tzinfo=UTC), + ocsf=ocsf_doc(time=2)) + oldest = event_row(parsed_at=datetime(2026, 9, 21, 11, 0, tzinfo=UTC), + ocsf=ocsf_doc(time=1)) + monkeypatch.setattr(queries, "fetch_export_rows", lambda *a, **k: [newest, oldest]) + r = client.get("/api/export/ocsf") + docs = [json.loads(line) for line in r.content.decode("utf-8").splitlines()] + assert [doc["time"] for doc in docs] == [2, 1] + + +def test_export_ocsf_forwards_filters_runs_off_loop_and_defaults( + client, queries, monkeypatch): + import threading + + seen = {} + + def fake(fingerprint=None, status=None, limit=None): + seen.update(fingerprint=fingerprint, status=status, limit=limit) + seen["main_thread"] = threading.current_thread() is threading.main_thread() + return [] + + monkeypatch.setattr(queries, "fetch_export_rows", fake) + assert client.get("/api/export/ocsf").status_code == 200 + # defaults: no fingerprint/status filters, bulk default limit = EXPORT_MAX + assert (seen["fingerprint"], seen["status"]) == (None, None) + assert seen["limit"] == queries.EXPORT_MAX + assert seen["main_thread"] is False # fetch + line assembly in a worker thread + + assert client.get("/api/export/ocsf", params={ + "fingerprint": "fp_ssh_denied", "status": "parsed", "limit": 100, + }).status_code == 200 + assert seen["fingerprint"] == "fp_ssh_denied" and seen["status"] == "parsed" + assert seen["limit"] == 100 + + +def test_export_ocsf_nonparsed_status_is_skipped_not_nulled(client, queries, monkeypatch): + # The status filter narrows WITHIN parsed: status=unparsed matches nothing + # (non-parsed rows have no OCSF document), so the export is a 200 with an + # EMPTY stream — never a "null" line. What the SQL selects is pinned in + # test_fetch_export_rows_sql below; here the route contract: 200, empty. + seen = {} + + def fake(fingerprint=None, status=None, limit=None): + seen["status"] = status + return [] # what the parsed-only SQL selects for status=unparsed + + monkeypatch.setattr(queries, "fetch_export_rows", fake) + r = client.get("/api/export/ocsf", params={"status": "unparsed"}) + assert r.status_code == 200 + assert r.content == b"" + assert seen["status"] == "unparsed" # forwarded, applied inside the fetch + + +def test_export_ocsf_huge_limit_is_clamped_not_rejected(client, queries, monkeypatch): + # Bulk exports must not 422: no le= on the param; EXPORT_MAX is enforced + # inside fetch_export_rows (pinned below via the fake conn). + monkeypatch.setattr(queries, "fetch_export_rows", lambda *a, **k: []) + assert client.get("/api/export/ocsf", params={"limit": 10**12}).status_code == 200 + + +# ---------- GET /api/export/parquet ---------- + +def test_export_parquet_round_trips_rows(client, queries, monkeypatch): + import pyarrow as pa + + rows = [event_row(ocsf=ocsf_doc()), event_row(ocsf=ocsf_doc(severity_id=1), + rule_version=4)] + monkeypatch.setattr(queries, "fetch_export_rows", lambda *a, **k: rows) + r = client.get("/api/export/parquet") + assert r.status_code == 200 + table = _read_parquet(r.content) # the data-lake path: bytes -> table + assert table.num_rows == 2 + assert table.column_names == PARQUET_COLUMNS # EventRow order, flat schema + assert table.schema.field("rule_version").type == pa.int32() + # wire serialization applied BEFORE the table (UUID -> str, datetime -> + # ISO-8601): byte-identical to what the JSONL endpoint's _json_default puts out + assert table.column("event_id").to_pylist() == [str(row["event_id"]) for row in rows] + assert table.column("raw_id").to_pylist() == [str(row["raw_id"]) for row in rows] + assert table.column("parsed_at").to_pylist() == ["2026-09-19T12:00:00+00:00"] * 2 + assert table.column("rule_version").to_pylist() == [3, 4] + # ocsf column = one JSON string per row; parsing it back yields the doc + ocsf_strings = table.column("ocsf").to_pylist() + assert ocsf_strings == [json.dumps(row["ocsf"]) for row in rows] + assert [json.loads(s) for s in ocsf_strings] == [row["ocsf"] for row in rows] + + +def test_export_parquet_headers_and_empty_valid_file(client, queries, monkeypatch): + monkeypatch.setattr(queries, "fetch_export_rows", lambda *a, **k: []) + r = client.get("/api/export/parquet") + assert r.status_code == 200 # 200 always: empty export = empty file, not error + assert r.headers["content-type"].startswith("application/octet-stream") + assert r.headers["content-disposition"] == \ + 'attachment; filename="ulpf-events.parquet"' + table = _read_parquet(r.content) + assert table.num_rows == 0 + assert table.column_names == PARQUET_COLUMNS # schema present and stable + + +def test_export_parquet_forwards_fingerprint_parsed_default_and_cap( + client, queries, monkeypatch): + seen = {} + + def fake(fingerprint=None, status=None, limit=None): + seen.update(fingerprint=fingerprint, status=status, limit=limit) + return [] + + monkeypatch.setattr(queries, "fetch_export_rows", fake) + assert client.get("/api/export/parquet").status_code == 200 + assert seen == {"fingerprint": None, "status": None, "limit": queries.EXPORT_MAX} + + assert client.get("/api/export/parquet", params={ + "fingerprint": "fp_x", "limit": 7}).status_code == 200 + # frozen signature has NO status param — the fetch's parsed-only default rides + assert seen == {"fingerprint": "fp_x", "status": None, "limit": 7} + + # cap enforced as a clamp, never a 422 (same shared fetch as JSONL) + assert client.get("/api/export/parquet", params={"limit": 10**12}).status_code == 200 + + +# ---------- export fetch over a fake conn (real SQL + cap pinned) ---------- + +def test_fetch_export_rows_sql_pins_parsed_only_filters_order_and_cap( + queries, monkeypatch): + row = event_row() + conn = _FakeRowsConn(fetchall=[[row]]) + monkeypatch.setattr(queries, "_connect", lambda: conn) + out = queries.fetch_export_rows(fingerprint="fp_a", status=None, limit=10**9) + assert out == [row] + sql = conn.sql[0] + assert "superseded_by_event_id IS NULL" in sql # R9 current view + assert "AND status = 'parsed'" in sql # parsed-only literal (module vocabulary) + assert "AND (%s::text IS NULL OR status = %s)" in sql # narrows WITHIN parsed + assert "AND (%s::text IS NULL OR fingerprint_id = %s)" in sql + assert "ORDER BY parsed_at DESC LIMIT %s" in sql # newest first + # nonsense limit clamped to EXPORT_MAX; None statuses forwarded as-is + assert conn.params[0] == (None, None, "fp_a", "fp_a", queries.EXPORT_MAX) + + # the status param intersects: forwarded parameterized, never interpolated + conn2 = _FakeRowsConn(fetchall=[[row]]) + monkeypatch.setattr(queries, "_connect", lambda: conn2) + queries.fetch_export_rows(status="unparsed") + assert conn2.params[0][:2] == ("unparsed", "unparsed") + + +def test_clamp_export_limit(queries): + assert queries.EXPORT_MAX == 50_000 # separate from MAX_LIMIT (500) + assert queries.clamp_export_limit(123) == 123 + assert queries.clamp_export_limit(50_000) == 50_000 + assert queries.clamp_export_limit(10**12) == queries.EXPORT_MAX + assert queries.clamp_export_limit(0) == 1 + assert queries.clamp_export_limit(-5) == 1 From e771fc6427f28613e1229319518e72156cc264de Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 22 Sep 2026 00:26:39 +0530 Subject: [PATCH 11/23] feat(web): Events browser page + shared TraceDrawer (fourth page) Co-Authored-By: Claude Code --- web/src/App.css | 50 ++++++++ web/src/App.tsx | 87 +------------ web/src/Events.test.tsx | 199 +++++++++++++++++++++++++++++ web/src/Events.tsx | 168 ++++++++++++++++++++++++ web/src/components/TraceDrawer.tsx | 89 +++++++++++++ 5 files changed, 510 insertions(+), 83 deletions(-) create mode 100644 web/src/Events.test.tsx create mode 100644 web/src/Events.tsx create mode 100644 web/src/components/TraceDrawer.tsx diff --git a/web/src/App.css b/web/src/App.css index 75c4faa..faa4a4a 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -587,3 +587,53 @@ body { opacity: 0.5; cursor: not-allowed; } + +/* --- events browser (M3 Task 9) -------------------------------------------- + The table reuses .feed-table (borders, sticky header, row hover) and the + badge-* tokens; the additions below only cover the filter bar, which no + existing class expresses. */ + +.filters { + display: flex; + gap: 12px; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.filters label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +.filters select, +.filters input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 6px 8px; + font-size: 13px; +} + +.filters select:focus-visible, +.filters input:focus-visible { + outline: none; + border-color: var(--accent); +} + +.filters button { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} + +.filters button:hover { + border-color: var(--accent); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 0c91aca..3b020cb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,11 +5,12 @@ // fonts, zero external requests). Same-origin /api only (dev: Vite proxy, // prod: Caddy) — the gateway ships no CORS. import { useCallback, useEffect, useState } from 'react' -import { getRaw, getStats } from './api' -import type { EventRow, OcsfEndpoint, RawTrace, Stats } from './types' +import { getStats } from './api' +import type { EventRow, OcsfEndpoint, Stats } from './types' import { useEventStream } from './useEventStream' import ReviewQueue from './ReviewQueue' import Rules from './Rules' +import TraceDrawer from './components/TraceDrawer' import './App.css' const STATS_REFRESH_MS = 5000 @@ -62,88 +63,8 @@ function statCards(stats: Stats | null): StatCard[] { ] } -/** Side drawer: full OCSF document plus the raw line behind the event - * (fetched by event_id; the fetch is aborted via AbortController if the - * drawer closes first). */ -function TraceDrawer({ row, onClose }: { row: EventRow; onClose: () => void }) { - const [trace, setTrace] = useState(null) - const [error, setError] = useState(null) - - useEffect(() => { - const controller = new AbortController() - getRaw(row.event_id, controller.signal) - .then((t) => setTrace(t)) - .catch((err: unknown) => { - if (!controller.signal.aborted) { - setError(err instanceof Error ? err.message : String(err)) - } - }) - return () => controller.abort() - }, [row.event_id]) - - useEffect(() => { - const onKey = (ev: KeyboardEvent) => { - if (ev.key === 'Escape') onClose() - } - window.addEventListener('keydown', onKey) - return () => window.removeEventListener('keydown', onKey) - }, [onClose]) - - return ( - - ) -} - /** The M1 Overview page: 4 stat cards, the SSE live-feed table, and the - * raw-traceability drawer. Unchanged behavior. */ + * raw-traceability drawer (shared component, components/TraceDrawer). */ function Overview() { const [stats, setStats] = useState(null) const [statsError, setStatsError] = useState(null) diff --git a/web/src/Events.test.tsx b/web/src/Events.test.tsx new file mode 100644 index 0000000..9e20cc1 --- /dev/null +++ b/web/src/Events.test.tsx @@ -0,0 +1,199 @@ +// @vitest-environment jsdom +// web/src/Events.test.tsx — the Events browser tests (M3 Task 9). The +// './api' module is mocked wholesale (house rule: tests never touch the +// network); this file's tree imports getEvents (Events) and getRaw (the +// shared TraceDrawer), so those are the exports the mock carries. +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Events from './Events' +import { getEvents, getRaw } from './api' +import type { EventRow, RawTrace } from './types' + +vi.mock('./api', () => ({ + getEvents: vi.fn(), + getRaw: vi.fn(), +})) + +const mockGetEvents = vi.mocked(getEvents) +const mockGetRaw = vi.mocked(getRaw) + +/** A parsed row for fp-acme-fw with both endpoints mapped. */ +function eventRow(overrides: Partial = {}): EventRow { + return { + event_id: '3f2bd0a6-1b1e-4c5a-9d3e-a1b2c3d4e5f6', + raw_id: '9a8b7c6d-5e4f-4a3b-8c2d-0e9f8a7b6c5d', + fingerprint_id: 'fp-acme-fw', + rule_version: 3, + status: 'parsed', + parsed_at: '2026-09-21T18:04:05Z', + ocsf: { + class_uid: 4001, + class_name: 'Network Activity', + activity_id: 0, + severity_id: null, + time: null, + src_endpoint: { ip: '10.0.0.1', port: 445 }, + dst_endpoint: { ip: '10.0.0.2', port: 445 }, + action: 'DROP', + message: null, + metadata: { product: 'ULPF' }, + unmapped: {}, + }, + ...overrides, + } +} + +/** The raw line behind the parsed row (GET /api/events/{id}/raw shape). */ +const rawTrace: RawTrace = { + raw_id: '9a8b7c6d-5e4f-4a3b-8c2d-0e9f8a7b6c5d', + received_at: '2026-09-21T18:04:04Z', + source_id: 'acme-fw-01', + transport: 'file', + content_hash: 'sha256:abc123', + raw_text: 'Sep 21 18:04:05 fw1 kernel: DROP tcp 10.0.0.1:445 -> 10.0.0.2:445', +} + +/** One parsed + one unparsed row: the second exercises ocsf-null rendering + * (— endpoints, — rule version). */ +const twoRows: EventRow[] = [ + eventRow(), + eventRow({ + event_id: 'c81d4d2e-7f6a-4b0c-9e5d-4f3e2d1c0b0a', + raw_id: '01234567-89ab-4cde-8f01-234567890abc', + fingerprint_id: 'fp-br-dns', + rule_version: null, + status: 'unparsed', + parsed_at: '2026-09-21T18:04:07Z', + ocsf: null, + }), +] + +afterEach(() => { + cleanup() // vitest without globals does not auto-cleanup RTL renders +}) + +describe('Events browser', () => { + it('renders rows from the current-view fetch: badges, mono fingerprints, rule v, src → dst', async () => { + mockGetEvents.mockResolvedValue({ events: twoRows }) + const { container } = render() + + // mount fetch: no status/fingerprint filter, default limit 50 + await screen.findByText('fp-acme-fw') + expect(mockGetEvents).toHaveBeenCalledWith({ + status: undefined, + fingerprint: undefined, + limit: 50, + }) + + // both fingerprints render in mono cells + expect(screen.getByText('fp-acme-fw').className).toBe('mono') + expect(screen.getByText('fp-br-dns').className).toBe('mono') + // status badges carry the badge- classes + expect(container.querySelector('.badge-parsed')).not.toBeNull() + expect(container.querySelector('.badge-unparsed')).not.toBeNull() + // parsed row: rule version + mapped endpoints; unparsed row: — / — → — + expect(screen.getByText('3')).not.toBeNull() + expect(screen.getByText('10.0.0.1 → 10.0.0.2')).not.toBeNull() + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1) + expect(screen.getByText('— → —')).not.toBeNull() + // topbar counts the current view + expect(screen.getByText('2 event(s) in the current view')).not.toBeNull() + }) + + it('forwards filter changes to getEvents: status, fingerprint, limit', async () => { + mockGetEvents.mockResolvedValue({ events: twoRows }) + render() + await screen.findByText('fp-acme-fw') + + fireEvent.change(screen.getByLabelText('status'), { + target: { value: 'parsed' }, + }) + await waitFor(() => { + expect(mockGetEvents).toHaveBeenLastCalledWith({ + status: 'parsed', + fingerprint: undefined, + limit: 50, + }) + }) + + fireEvent.change(screen.getByLabelText('fingerprint'), { + target: { value: 'fp-acme-fw' }, + }) + await waitFor(() => { + expect(mockGetEvents).toHaveBeenLastCalledWith({ + status: 'parsed', + fingerprint: 'fp-acme-fw', + limit: 50, + }) + }) + + fireEvent.change(screen.getByLabelText('limit'), { + target: { value: '100' }, + }) + await waitFor(() => { + expect(mockGetEvents).toHaveBeenLastCalledWith({ + status: 'parsed', + fingerprint: 'fp-acme-fw', + limit: 100, + }) + }) + }) + + it('clicking a row opens the TraceDrawer: getRaw by event id, raw line shown, Close clears', async () => { + mockGetEvents.mockResolvedValue({ events: [eventRow()] }) + mockGetRaw.mockResolvedValue(rawTrace) + render() + fireEvent.click(await screen.findByText('fp-acme-fw')) + + expect(mockGetRaw).toHaveBeenCalledWith( + '3f2bd0a6-1b1e-4c5a-9d3e-a1b2c3d4e5f6', + expect.anything(), // the AbortSignal the drawer cancels on close + ) + expect( + screen.getByRole('heading', { name: 'Traceability' }), + ).not.toBeNull() + // the raw line and the normalized document both render + expect( + await screen.findByText( + 'Sep 21 18:04:05 fw1 kernel: DROP tcp 10.0.0.1:445 -> 10.0.0.2:445', + ), + ).not.toBeNull() + expect(screen.getByText(/"src_endpoint"/)).not.toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Close (Esc)' })) + expect( + screen.queryByRole('heading', { name: 'Traceability' }), + ).toBeNull() + }) + + it('Enter on a row also opens the drawer (keyboard activation)', async () => { + mockGetEvents.mockResolvedValue({ events: [eventRow()] }) + mockGetRaw.mockResolvedValue(rawTrace) + render() + fireEvent.keyDown(await screen.findByText('fp-acme-fw'), { key: 'Enter' }) + + expect( + await screen.findByRole('heading', { name: 'Traceability' }), + ).not.toBeNull() + }) + + it('shows the empty state when no events match', async () => { + mockGetEvents.mockResolvedValue({ events: [] }) + render() + await screen.findByText(/no events match/i) + }) + + it('shows an error banner when the load fails, and Retry recovers', async () => { + mockGetEvents.mockRejectedValueOnce( + new Error('GET /api/events failed: 500 Internal Server Error'), + ) + render() + await screen.findByRole('alert') + expect(screen.getByRole('alert').textContent).toMatch(/500/) + + mockGetEvents.mockResolvedValueOnce({ events: [eventRow()] }) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('fp-acme-fw')).not.toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/web/src/Events.tsx b/web/src/Events.tsx new file mode 100644 index 0000000..056b442 --- /dev/null +++ b/web/src/Events.tsx @@ -0,0 +1,168 @@ +// web/src/Events.tsx — the Events browser (M3 Task 9, the fourth §12 page): +// a filterable current-view table (GET /api/events) with the raw↔normalized +// drill-down drawer shared with Overview (components/TraceDrawer). +// +// Filters refetch live: status / fingerprint / limit are useEffect deps, so +// every change re-fires the fetch (empty-string status and fingerprint are +// forwarded as undefined — no filter). The Reload button and the error +// banner's Retry bump the same reloadTick the M2 pages use. Rows reuse the +// Overview interaction pattern: click or Enter opens the drawer, keyed by +// event_id so switching rows remounts it. +// +// Air-gap rules: same-origin /api only, system fonts, zero external requests. +import { useCallback, useEffect, useState } from 'react' +import { getEvents } from './api' +import type { EventRow, OcsfEndpoint } from './types' +import TraceDrawer from './components/TraceDrawer' + +function endpointIp(ep: OcsfEndpoint | null): string { + return ep?.ip ?? '—' +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString() +} + +/** The Events tab: filter bar + current-view table + traceability drawer. */ +export default function Events() { + const [status, setStatus] = useState('') + const [fingerprint, setFingerprint] = useState('') + const [limit, setLimit] = useState(50) + const [events, setEvents] = useState(null) + const [error, setError] = useState(null) + const [reloadTick, setReloadTick] = useState(0) + const [selected, setSelected] = useState(null) + + useEffect(() => { + let alive = true + getEvents({ + status: status || undefined, + fingerprint: fingerprint || undefined, + limit, + }) + .then((res) => { + if (!alive) return + setEvents(res.events) + setError(null) + }) + .catch((err: unknown) => { + if (!alive) return + setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + alive = false + } + }, [status, fingerprint, limit, reloadTick]) + + const refresh = useCallback(() => setReloadTick((n) => n + 1), []) + + return ( +
    +
    +

    Events

    + + {events === null ? '…' : events.length} event(s) in the current view + +
    + +
    + + + + +
    + + {error !== null && ( +

    + events unavailable: {error}{' '} + +

    + )} + + {events === null && error === null && ( +

    Loading events…

    + )} + + {events !== null && events.length === 0 && ( +

    No events match the current filters.

    + )} + + {events !== null && events.length > 0 && ( +
    + + + + + + + + + + + + {events.map((row) => ( + setSelected(row)} tabIndex={0} + onKeyDown={(ev) => { if (ev.key === 'Enter') setSelected(row) }}> + + + + + + + ))} + +
    TimeFingerprintStatusRule vSource → Destination
    {formatTime(row.parsed_at)}{row.fingerprint_id} + {row.status} + {row.rule_version ?? '—'} + {endpointIp(row.ocsf?.src_endpoint ?? null)} →{' '} + {endpointIp(row.ocsf?.dst_endpoint ?? null)} +
    +
    + )} + + {selected !== null && ( + setSelected(null)} + /> + )} +
    + ) +} diff --git a/web/src/components/TraceDrawer.tsx b/web/src/components/TraceDrawer.tsx new file mode 100644 index 0000000..6a7d4fe --- /dev/null +++ b/web/src/components/TraceDrawer.tsx @@ -0,0 +1,89 @@ +// web/src/components/TraceDrawer.tsx — the shared raw↔normalized traceability +// drawer, extracted VERBATIM from App.tsx (M3 Task 9) so the Overview feed +// and the Events browser both drill into the raw line behind an event. +// Behavior, props and markup are unchanged — only the location moved. +import { useEffect, useState } from 'react' +import { getRaw } from '../api' +import type { EventRow, RawTrace } from '../types' + +/** Side drawer: full OCSF document plus the raw line behind the event + * (fetched by event_id; the fetch is aborted via AbortController if the + * drawer closes first). */ +function TraceDrawer({ row, onClose }: { row: EventRow; onClose: () => void }) { + const [trace, setTrace] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const controller = new AbortController() + getRaw(row.event_id, controller.signal) + .then((t) => setTrace(t)) + .catch((err: unknown) => { + if (!controller.signal.aborted) { + setError(err instanceof Error ? err.message : String(err)) + } + }) + return () => controller.abort() + }, [row.event_id]) + + useEffect(() => { + const onKey = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + return ( + + ) +} + +export default TraceDrawer From bbfec6f94d416509cf35961f27d9ef7f7f063907 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 28 Sep 2026 21:26:56 +0530 Subject: [PATCH 12/23] fix(web): share endpointIp/formatTime via cells.ts (T9 review) Co-Authored-By: Claude Code --- web/src/App.tsx | 11 ++--------- web/src/Events.tsx | 11 ++--------- web/src/cells.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 18 deletions(-) create mode 100644 web/src/cells.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 3b020cb..e5be3b9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,7 +6,8 @@ // prod: Caddy) — the gateway ships no CORS. import { useCallback, useEffect, useState } from 'react' import { getStats } from './api' -import type { EventRow, OcsfEndpoint, Stats } from './types' +import type { EventRow, Stats } from './types' +import { endpointIp, formatTime } from './cells' import { useEventStream } from './useEventStream' import ReviewQueue from './ReviewQueue' import Rules from './Rules' @@ -32,14 +33,6 @@ function parsedPercent(byStatus: Stats['by_status']): string { return `${Math.round((byStatus.parsed / total) * 100)}%` } -function endpointIp(ep: OcsfEndpoint | null): string { - return ep?.ip ?? '—' -} - -function formatTime(iso: string): string { - return new Date(iso).toLocaleTimeString() -} - interface StatCard { label: string value: string | number diff --git a/web/src/Events.tsx b/web/src/Events.tsx index 056b442..f0bd4be 100644 --- a/web/src/Events.tsx +++ b/web/src/Events.tsx @@ -12,17 +12,10 @@ // Air-gap rules: same-origin /api only, system fonts, zero external requests. import { useCallback, useEffect, useState } from 'react' import { getEvents } from './api' -import type { EventRow, OcsfEndpoint } from './types' +import type { EventRow } from './types' +import { endpointIp, formatTime } from './cells' import TraceDrawer from './components/TraceDrawer' -function endpointIp(ep: OcsfEndpoint | null): string { - return ep?.ip ?? '—' -} - -function formatTime(iso: string): string { - return new Date(iso).toLocaleTimeString() -} - /** The Events tab: filter bar + current-view table + traceability drawer. */ export default function Events() { const [status, setStatus] = useState('') diff --git a/web/src/cells.ts b/web/src/cells.ts new file mode 100644 index 0000000..49a159e --- /dev/null +++ b/web/src/cells.ts @@ -0,0 +1,12 @@ +// web/src/cells.ts — tiny pure cell formatters shared by more than one page +// (Overview in App.tsx, the Events browser). Kept here rather than copy-pasted +// per page so the fifth §12 page can reuse them too. +import type { OcsfEndpoint } from './types' + +export function endpointIp(ep: OcsfEndpoint | null): string { + return ep?.ip ?? '—' +} + +export function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString() +} From b08a4f4c9230a60337d5335ed9644d4ee0e0d17c Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 28 Sep 2026 21:50:00 +0530 Subject: [PATCH 13/23] feat(web): Drift & Health page + five-tab shell (T10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth and final §12 page (M3 Task 10): field health across the latest closed drift windows (severity badge, null/match/violation rates, shape top class, action), the ONE merged latest-first enforcement/alert feed (window rows ∪ audit actions, kind-discriminated, rendered verbatim per ruling P-4 — no client re-merge or re-sort), and unmapped.* extension opportunities as distinct 'new telemetry key observed' cards. The Un-quarantine button (actor 'ui') POSTs /api/rules/{fp}/unquarantine and refetches both feeds; gating is a latest-wins fold over the feed's field_quarantined/field_unquarantined audit rows, because GET /api/rules/{fp} does not actually return rules.quarantined_fields (gateway _RULE_COLUMNS carries 13 keys, none of them quarantined_fields; Python is out of T10's scope) — both mutations audit only real transitions, so the fold is exact within the endpoint's row cap. Shell: TABS gains Events (Task 9's page, previously unwired) and Drift & Health, in spec §12 order; new App.test.tsx pins five-tab switching (mocked './api', stubbed EventSource). types.ts mirrors the drift endpoints key for key (DriftMetricRow, DriftAlertRow, UnquarantineBody/Response); api.ts gains getDriftMetrics/getDriftAlerts. Co-Authored-By: Claude Code --- web/src/App.css | 79 ++++++++ web/src/App.test.tsx | 156 +++++++++++++++ web/src/App.tsx | 15 +- web/src/DriftHealth.test.tsx | 369 +++++++++++++++++++++++++++++++++++ web/src/DriftHealth.tsx | 353 +++++++++++++++++++++++++++++++++ web/src/api.ts | 22 ++- web/src/types.ts | 69 +++++++ 7 files changed, 1058 insertions(+), 5 deletions(-) create mode 100644 web/src/App.test.tsx create mode 100644 web/src/DriftHealth.test.tsx create mode 100644 web/src/DriftHealth.tsx diff --git a/web/src/App.css b/web/src/App.css index faa4a4a..d1bb62c 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -637,3 +637,82 @@ body { .filters button:hover { border-color: var(--accent); } + +/* --- drift & health (M3 Task 10) -------------------------------------------- + The field-health table reuses .feed-table and the badge-* tokens; the feed + reuses .audit-feed / .audit-sep; the opportunity cards reuse .cards / .card. + The additions below only cover what no existing class expresses: severity + badge colors, non-pointer rows, the un-quarantine button, the section + headers, the merged-feed items and the opportunity card copy. */ + +/* severity ladder: none is quiet, minor warns, moderate is the quarantine + band (echoing badge-quarantined's purple), severe is the deactivate band */ +.badge-none { color: var(--muted); } +.badge-minor { color: var(--warn); } +.badge-moderate { color: var(--quar); } +.badge-severe { color: var(--err); } + +/* section headers inside the page (Field health / Enforcement feed / + Extension opportunities) */ +.drift-section h2 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin: 22px 0 8px; +} + +/* drift rows are read-only — no row pointer/hover-click affordance */ +.drift-table tbody tr { + cursor: default; +} + +/* the human override button on currently-quarantined field rows */ +.drift-table .unquarantine { + background: rgba(163, 113, 247, 0.14); + border: 1px solid var(--quar); + border-radius: 6px; + color: var(--quar); + padding: 3px 10px; + font-size: 12px; + cursor: pointer; + margin-left: 8px; +} + +.drift-table .unquarantine:hover { + border-color: var(--text); +} + +.drift-table .unquarantine:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* the per-row un-quarantine error banner spans the whole table row */ +.drift-error-row td { + cursor: default; + background: var(--panel-2); +} + +/* one merged latest-first feed: window alerts and audit actions alike */ +.drift-feed-item { + display: flex; + align-items: baseline; + gap: 2px; + flex-wrap: wrap; +} + +/* opportunity cards: the "new telemetry key observed" copy stays lowercase + (it reads as a finding, not a header) */ +.opportunity h3 { + margin: 0 0 6px; + font-size: 14px; + text-transform: none; + letter-spacing: 0; + color: var(--text); +} + +.opportunity p { + margin: 4px 0; + font-size: 13px; +} diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..5c5c945 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +// web/src/App.test.tsx — the five-tab shell tests (M3 Task 10; spec §12's +// five pages: Overview, Review Queue, Events, Rules, Drift & Health). The +// './api' module is mocked wholesale (house rule: tests never touch the +// network) with empty-resolved fetchers for every page the tree can mount; +// EventSource is stubbed because the default Overview tab subscribes to the +// SSE stream and jsdom ships no EventSource. +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import App from './App' +import { + getDriftAlerts, + getDriftMetrics, + getEvents, + getRules, + getStats, +} from './api' +import type { Stats } from './types' + +vi.mock('./api', () => ({ + getStats: vi.fn(), + getEvents: vi.fn(), + getRaw: vi.fn(), + getRules: vi.fn(), + getRuleHistory: vi.fn(), + getAudit: vi.fn(), + getDriftMetrics: vi.fn(), + getDriftAlerts: vi.fn(), + postJson: vi.fn(), +})) + +const mockGetStats = vi.mocked(getStats) +const mockGetEvents = vi.mocked(getEvents) +const mockGetRules = vi.mocked(getRules) +const mockGetDriftMetrics = vi.mocked(getDriftMetrics) +const mockGetDriftAlerts = vi.mocked(getDriftAlerts) + +/** Minimal EventSource double — the Overview tab only needs construction and + * clean close on switch-away; no events are delivered in these tests. */ +class FakeEventSource { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + + url: string + readyState: number = FakeEventSource.OPEN + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: ((ev: Event) => void) | null = null + + constructor(url: string) { + this.url = url + } + + close(): void { + this.readyState = FakeEventSource.CLOSED + } +} + +const emptyStats: Stats = { + raw_total: 0, + by_status: { parsed: 0, unparsed: 0, parse_error: 0, quarantined: 0 }, + by_fingerprint: [], + events_last_minute: 0, + dlq_total: null, +} + +beforeEach(() => { + vi.stubGlobal('EventSource', FakeEventSource as unknown as typeof EventSource) + mockGetStats.mockResolvedValue(emptyStats) + mockGetEvents.mockResolvedValue({ events: [] }) + mockGetRules.mockResolvedValue({ rules: [] }) + mockGetDriftMetrics.mockResolvedValue({ metrics: [] }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) +}) + +afterEach(() => { + cleanup() // vitest without globals does not auto-cleanup RTL renders + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +describe('App shell — five tabs (spec §12)', () => { + it('renders the five §12 tabs with Overview as the default page', () => { + render() + + const tabs = screen.getAllByRole('tab') + expect(tabs.map((t) => t.textContent)).toEqual([ + 'Overview', + 'Review Queue', + 'Events', + 'Rules', + 'Drift & Health', + ]) + // Overview mounts by default (its SSE feed + stats fetch fired) + expect( + screen.getByRole('heading', { name: 'ULPF Overview' }), + ).not.toBeNull() + expect(mockGetStats).toHaveBeenCalled() + // …and none of the other pages is mounted + expect(screen.queryByRole('heading', { name: 'Events' })).toBeNull() + expect(screen.queryByRole('heading', { name: 'Rules' })).toBeNull() + }) + + it('switches to each of the other four pages, one at a time', async () => { + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Events' })) + expect( + await screen.findByRole('heading', { name: 'Events' }), + ).not.toBeNull() + expect(mockGetEvents).toHaveBeenCalledWith({ + status: undefined, + fingerprint: undefined, + limit: 50, + }) + expect( + screen.queryByRole('heading', { name: 'ULPF Overview' }), + ).toBeNull() + + fireEvent.click(screen.getByRole('tab', { name: 'Review Queue' })) + expect( + await screen.findByRole('heading', { name: 'Review Queue' }), + ).not.toBeNull() + expect(mockGetRules).toHaveBeenCalledWith({ status: 'pending_review' }) + expect(screen.queryByRole('heading', { name: 'Events' })).toBeNull() + + fireEvent.click(screen.getByRole('tab', { name: 'Rules' })) + expect( + await screen.findByRole('heading', { name: 'Rules' }), + ).not.toBeNull() + expect(mockGetRules).toHaveBeenLastCalledWith() + + fireEvent.click(screen.getByRole('tab', { name: 'Drift & Health' })) + expect( + await screen.findByRole('heading', { name: 'Drift & Health' }), + ).not.toBeNull() + expect(mockGetDriftMetrics).toHaveBeenCalled() + expect(mockGetDriftAlerts).toHaveBeenCalled() + expect(screen.queryByRole('heading', { name: 'Rules' })).toBeNull() + }) + + it('marks exactly the active tab aria-selected', () => { + render() + const tabs = screen.getAllByRole('tab') + expect(tabs[0]?.getAttribute('aria-selected')).toBe('true') + expect(tabs[4]?.getAttribute('aria-selected')).toBe('false') + + fireEvent.click(tabs[4]) + expect(tabs[4]?.getAttribute('aria-selected')).toBe('true') + expect(tabs[0]?.getAttribute('aria-selected')).toBe('false') + // the fifth page is Drift & Health + expect( + screen.getByRole('heading', { name: 'Drift & Health' }), + ).not.toBeNull() + }) +}) diff --git a/web/src/App.tsx b/web/src/App.tsx index e5be3b9..32a3e27 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,7 @@ -// web/src/App.tsx — the three-tab M2 shell: Overview (the M1 dashboard), -// Review Queue (Task 8 human review loop) and the Rules registry + audit -// trail (Task 9). +// web/src/App.tsx — the five-tab M3 shell (spec §12's five pages, in spec +// order): Overview (the M1 dashboard), Review Queue (Task 8 human review +// loop), the Events browser (Task 9), the Rules registry + audit trail +// (Task 9) and Drift & Health (Task 10, the fifth page). // Tab state is plain useState — no router (air-gapped dashboard: system // fonts, zero external requests). Same-origin /api only (dev: Vite proxy, // prod: Caddy) — the gateway ships no CORS. @@ -10,18 +11,22 @@ import type { EventRow, Stats } from './types' import { endpointIp, formatTime } from './cells' import { useEventStream } from './useEventStream' import ReviewQueue from './ReviewQueue' +import Events from './Events' import Rules from './Rules' +import DriftHealth from './DriftHealth' import TraceDrawer from './components/TraceDrawer' import './App.css' const STATS_REFRESH_MS = 5000 -type Tab = 'overview' | 'review' | 'rules' +type Tab = 'overview' | 'review' | 'events' | 'rules' | 'drift' const TABS: { id: Tab; label: string }[] = [ { id: 'overview', label: 'Overview' }, { id: 'review', label: 'Review Queue' }, + { id: 'events', label: 'Events' }, { id: 'rules', label: 'Rules' }, + { id: 'drift', label: 'Drift & Health' }, ] /** parsed / (parsed + unparsed + parse_error + quarantined); "—" before any @@ -171,7 +176,9 @@ export default function App() { {tab === 'overview' && } {tab === 'review' && } + {tab === 'events' && } {tab === 'rules' && } + {tab === 'drift' && } ) } diff --git a/web/src/DriftHealth.test.tsx b/web/src/DriftHealth.test.tsx new file mode 100644 index 0000000..8d653f6 --- /dev/null +++ b/web/src/DriftHealth.test.tsx @@ -0,0 +1,369 @@ +// @vitest-environment jsdom +// web/src/DriftHealth.test.tsx — the Drift & Health page tests (M3 Task 10, +// the fifth §12 page). The './api' module is mocked wholesale (house rule: +// tests never touch the network); this file's tree imports getDriftMetrics, +// getDriftAlerts (the page) and postJson (the un-quarantine button). +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import DriftHealth from './DriftHealth' +import { getDriftAlerts, getDriftMetrics, postJson } from './api' +import type { + AuditRow, + DriftAlertRow, + DriftMetricRow, + UnquarantineResponse, +} from './types' + +vi.mock('./api', () => ({ + getDriftMetrics: vi.fn(), + getDriftAlerts: vi.fn(), + postJson: vi.fn(), +})) + +const mockGetDriftMetrics = vi.mocked(getDriftMetrics) +const mockGetDriftAlerts = vi.mocked(getDriftAlerts) +const mockPostJson = vi.mocked(postJson) + +/** A severe window row for fp-acme-fw / src_endpoint.ip — the firmware-drift + * demo shape: violation rate through the tier-1 ladder, ipv4-dominant. */ +function metricRow(overrides: Partial = {}): DriftMetricRow { + return { + fingerprint_id: 'fp-acme-fw', + rule_version: 3, + field: 'src_endpoint.ip', + window_start: '2026-09-22T10:00:00Z', + window_end: '2026-09-22T10:05:00Z', + events_count: 240, + null_rate: 0.42, + match_rate: null, + violation_rate: 0.61, + shape_dist: { ipv4: 90, int: 10 }, + severity: 'severe', + action_taken: 'rule_deactivated', + ...overrides, + } +} + +/** A window-kind alert row (the row IS the alert). */ +function windowAlert(overrides: Partial = {}): DriftAlertRow { + return { ...metricRow(overrides), kind: 'window' } +} + +/** An audit-kind alert row from the enforcement vocabulary. */ +function auditAlert(overrides: Partial = {}): DriftAlertRow { + return { + id: 41, + ts: '2026-09-22T10:06:00Z', + actor: 'drift', + action: 'field_quarantined', + entity: 'fp-acme-fw', + detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, + kind: 'audit', + ...overrides, + } +} + +/** POST /api/rules/{fp}/unquarantine 200 response shape. */ +const unquarantineOk: UnquarantineResponse = { + fingerprint_id: 'fp-acme-fw', + field: 'dst_endpoint.ip', + quarantined_fields: [], +} + +afterEach(() => { + cleanup() // vitest without globals does not auto-cleanup RTL renders + vi.clearAllMocks() +}) + +describe('Drift & Health — Field health', () => { + it('renders one row per (fp, field) metric: severity badge, rates, shape top class, action, topbar counts', async () => { + mockGetDriftMetrics.mockResolvedValue({ + metrics: [ + metricRow(), + metricRow({ + field: 'dst_endpoint.ip', + window_start: '2026-09-22T09:55:00Z', + window_end: '2026-09-22T10:00:00Z', + null_rate: 0, + violation_rate: 0.05, + shape_dist: {}, // empty histogram — shape top renders '—' + severity: 'moderate', + action_taken: 'field_quarantined', + }), + ], + }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + const { container } = render() + + // both feeds load on mount + await screen.findAllByText('fp-acme-fw') // one cell per row (same fp) + expect(mockGetDriftMetrics).toHaveBeenCalledWith() + expect(mockGetDriftAlerts).toHaveBeenCalledWith() + + // identity cells are mono; severity badges carry badge- + expect(screen.getAllByText('fp-acme-fw').length).toBeGreaterThanOrEqual(2) + expect(container.querySelector('.badge-severe')).not.toBeNull() + expect(container.querySelector('.badge-moderate')).not.toBeNull() + // rates render as rounded percentages ('—' where the window measures none) + expect(screen.getByText('42%')).not.toBeNull() // null_rate + expect(screen.getByText('61%')).not.toBeNull() // violation_rate + expect(screen.getByText('0%')).not.toBeNull() // second row null_rate + // shape top class = argmax of shape_dist; '—' when the histogram is empty + // (also '—' for the null match_rate — __rule__-only measurement) + expect(screen.getByText('ipv4')).not.toBeNull() + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(2) + // action strings render verbatim (the string IS the label) + expect(screen.getByText('rule_deactivated')).not.toBeNull() + expect(screen.getByText('field_quarantined')).not.toBeNull() + // events count rides each row (both fixtures carry 240) + expect(screen.getAllByText('240')).toHaveLength(2) + // topbar counts both feeds + expect( + screen.getByText('2 field signal(s) · 0 alert(s)'), + ).not.toBeNull() + }) + + it('renders __rule__ sentinel rows with their match_rate', async () => { + mockGetDriftMetrics.mockResolvedValue({ + metrics: [ + metricRow({ + field: '__rule__', + null_rate: null, + match_rate: 0.93, + violation_rate: null, + shape_dist: null, + severity: 'minor', + action_taken: 'alert', + }), + ], + }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + render() + await screen.findByText('__rule__') + + expect(screen.getByText('93%')).not.toBeNull() + // null_rate / violation_rate / shape_dist are all null on a sentinel row + expect(screen.getAllByText('—')).toHaveLength(3) + }) + + it('shows the empty states when no windows have closed and no alerts exist', async () => { + mockGetDriftMetrics.mockResolvedValue({ metrics: [] }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + render() + await screen.findByText(/no closed drift windows yet/i) + expect(screen.getByText(/no drift alerts/i)).not.toBeNull() + expect(screen.getByText(/no unmapped telemetry keys observed/i)).not.toBeNull() + }) +}) + +describe('Drift & Health — Enforcement feed', () => { + it('renders the ONE merged feed in endpoint order, discriminated by kind (no client re-sort)', async () => { + // Deliberately NOT time-sorted: the endpoint's latest-first order is the + // contract (P-4) and the page must render the array verbatim — a client + // re-sort would reorder these rows. + mockGetDriftMetrics.mockResolvedValue({ metrics: [metricRow()] }) + mockGetDriftAlerts.mockResolvedValue({ + alerts: [ + windowAlert({ window_start: '2026-09-22T10:00:00Z', severity: 'severe' }), + auditAlert({ + id: 44, + ts: '2026-09-22T10:30:00Z', + actor: 'karthik', + action: 'field_unquarantined', + detail: { field: 'src_endpoint.ip', rule_id: 3, version: 3, reason: 'fixed' }, + }), + windowAlert({ + field: 'dst_endpoint.ip', + window_start: '2026-09-22T09:00:00Z', + severity: 'minor', + action_taken: 'alert', + shape_dist: {}, + }), + auditAlert({ + id: 40, + ts: '2026-09-22T09:10:00Z', + action: 'rule_deactivated', + detail: { rule_id: 3, version: 3, window_start: '2026-09-22T09:00:00Z' }, + }), + ], + }) + const { container } = render() + await screen.findByText(/alert\(s\)/) + + const items = container.querySelectorAll('.drift-feed li') + expect(items).toHaveLength(4) + // item order is the endpoint's array order, verbatim + expect(items[0]?.textContent).toContain('fp-acme-fw') + expect(items[0]?.textContent).toContain('src_endpoint.ip') + expect(items[0]?.textContent).toContain('severe') + // window rows carry no actor — the audit rows do + expect(items[0]?.textContent).not.toContain('karthik') + expect(items[1]?.textContent).toContain('karthik') + expect(items[1]?.textContent).toContain('field_unquarantined') + expect(items[1]?.textContent).toContain('src_endpoint.ip') + expect(items[2]?.textContent).toContain('dst_endpoint.ip') + expect(items[2]?.textContent).toContain('minor') + expect(items[2]?.textContent).toContain('alert') + // rule_deactivated audit rows have no field in detail — '—' + expect(items[3]?.textContent).toContain('drift') + expect(items[3]?.textContent).toContain('rule_deactivated') + expect(items[3]?.textContent).toContain('—') + // topbar counts the merged feed + expect( + screen.getByText('1 field signal(s) · 4 alert(s)'), + ).not.toBeNull() + }) +}) + +describe('Drift & Health — Un-quarantine', () => { + it('shows the button only on currently-quarantined fields (latest audit transition wins), POSTs {field, actor:"ui"} and refetches both feeds', async () => { + const metrics = [ + metricRow({ field: 'src_endpoint.ip' }), + metricRow({ field: 'dst_endpoint.ip' }), + ] + // Latest-first: src was un-quarantined AFTER its quarantine (not + // quarantined anymore); dst's latest transition is the quarantine. + const initialAlerts: DriftAlertRow[] = [ + auditAlert({ + id: 46, + ts: '2026-09-22T11:00:00Z', + actor: 'karthik', + action: 'field_unquarantined', + detail: { field: 'src_endpoint.ip', rule_id: 3, version: 3 }, + }), + auditAlert({ + id: 45, + ts: '2026-09-22T10:30:00Z', + action: 'field_quarantined', + detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, + }), + auditAlert({ + id: 44, + ts: '2026-09-22T10:00:00Z', + action: 'field_quarantined', + detail: { field: 'src_endpoint.ip', rule_id: 3, version: 3 }, + }), + ] + mockGetDriftMetrics.mockResolvedValue({ metrics }) + mockGetDriftAlerts.mockResolvedValueOnce({ alerts: initialAlerts }) + // post-click refetch: the latest dst transition is now the un-quarantine + mockGetDriftAlerts.mockResolvedValueOnce({ + alerts: [ + auditAlert({ + id: 47, + ts: '2026-09-22T11:05:00Z', + actor: 'ui', + action: 'field_unquarantined', + detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, + }), + ...initialAlerts, + ], + }) + mockPostJson.mockResolvedValueOnce(unquarantineOk) + render() + // the field shows in its table row AND the enforcement feed + await screen.findAllByText('dst_endpoint.ip') + + // exactly one button — on the dst row, not the src row + const buttons = screen.getAllByRole('button', { name: 'Un-quarantine' }) + expect(buttons).toHaveLength(1) + expect(buttons[0]?.closest('tr')?.textContent).toContain('dst_endpoint.ip') + + fireEvent.click(buttons[0]) + await waitFor(() => { + expect(mockPostJson).toHaveBeenCalledWith( + '/api/rules/fp-acme-fw/unquarantine', + { field: 'dst_endpoint.ip', actor: 'ui' }, + ) + }) + // both feeds were refetched and the button left with the new fold + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Un-quarantine' })).toBeNull() + }) + expect(mockGetDriftMetrics).toHaveBeenCalledTimes(2) + expect(mockGetDriftAlerts).toHaveBeenCalledTimes(2) + }) + + it('shows a row-level error banner when the POST fails (409: not quarantined)', async () => { + mockGetDriftMetrics.mockResolvedValue({ + metrics: [metricRow({ field: 'dst_endpoint.ip' })], + }) + mockGetDriftAlerts.mockResolvedValue({ + alerts: [ + auditAlert({ + action: 'field_quarantined', + detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, + }), + ], + }) + mockPostJson.mockRejectedValueOnce( + new Error( + 'POST /api/rules/fp-acme-fw/unquarantine failed: field ' + + "'dst_endpoint.ip' is not quarantined on the active rule", + ), + ) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Un-quarantine' })) + + const banner = await screen.findByRole('alert') + expect(banner.textContent).toMatch(/un-quarantine failed/) + expect(banner.textContent).toMatch(/not quarantined/) + }) +}) + +describe('Drift & Health — Extension opportunities', () => { + it('renders one distinct card per unmapped.* metric row, titled "new telemetry key observed"', async () => { + mockGetDriftMetrics.mockResolvedValue({ + metrics: [ + metricRow({ + field: 'unmapped.SRCADDR', + null_rate: 0.98, + violation_rate: null, + shape_dist: { free_text: 5 }, + severity: 'minor', + action_taken: 'alert', + events_count: 50, + }), + metricRow({ + field: 'unmapped.fw_build', + null_rate: 0, + violation_rate: null, + shape_dist: {}, + severity: 'none', + action_taken: null, + events_count: 50, + }), + metricRow(), // a mapped field — no card + ], + }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + render() + // the key shows in its table row AND its opportunity card + await screen.findAllByText('unmapped.SRCADDR') + + const titles = screen.getAllByText('new telemetry key observed') + expect(titles).toHaveLength(2) + // each unmapped key shows in its table row AND its card + expect(screen.getAllByText('unmapped.fw_build')).toHaveLength(2) + // the mapped field stays in the field-health table but gets no card copy + expect(screen.getAllByText('src_endpoint.ip').length).toBe(1) + }) +}) + +describe('Drift & Health — load failure', () => { + it('shows an error banner when the feeds fail, and Retry recovers', async () => { + mockGetDriftMetrics.mockRejectedValueOnce( + new Error('GET /api/drift/metrics failed: 500 Internal Server Error'), + ) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + render() + const banner = await screen.findByRole('alert') + expect(banner.textContent).toMatch(/drift data unavailable/) + expect(banner.textContent).toMatch(/500/) + + mockGetDriftMetrics.mockResolvedValueOnce({ metrics: [metricRow()] }) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('fp-acme-fw')).not.toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/web/src/DriftHealth.tsx b/web/src/DriftHealth.tsx new file mode 100644 index 0000000..dde130c --- /dev/null +++ b/web/src/DriftHealth.tsx @@ -0,0 +1,353 @@ +// web/src/DriftHealth.tsx — the Drift & Health page (M3 Task 10, the fifth +// and final §12 page): field health across the latest closed drift windows, +// the merged enforcement/alert feed, and unmapped.* extension opportunities. +// +// Two fetches fire together on mount and after every action (one effect, one +// alive flag, Promise.all — the page's un-quarantine gating needs BOTH feeds, +// so they load or fail as a unit with a single Retry banner). The alerts +// endpoint (ruling P-4) already merges window rows and audit actions into ONE +// latest-first list — this page renders that array verbatim, discriminated +// only by the `kind` field, and never re-merges or re-sorts client-side. +// +// Un-quarantine gating — DEVIATION from the task context, documented in the +// Task-10 report: GET /api/rules/{fp} (getRuleHistory) does NOT actually +// return rules.quarantined_fields (gateway _RULE_COLUMNS has 13 keys, none of +// them quarantined_fields; Python is out of this task's scope). Instead the +// button is gated by a latest-wins fold over the alert feed's audit rows: +// enforce.py and writes.py write field_quarantined / field_unquarantined +// audit rows ONLY on real transitions (both mutations carry TOCTOU predicates +// and never audit a no-op), so the most recent transition per +// (fingerprint, field) — the first one seen in the latest-first list — IS the +// field's current quarantine state. Bounded by the endpoint's row cap: a +// quarantine whose transitions have scrolled out of the feed cannot be +// un-quarantined from this page. +// +// Air-gap rules: same-origin /api only, system fonts, zero external requests. +import { useCallback, useEffect, useState } from 'react' +import { getDriftAlerts, getDriftMetrics, postJson } from './api' +import type { AuditRow, DriftAlertRow, DriftMetricRow } from './types' +import { formatTime } from './cells' + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** 0..1 rate -> rounded percentage; '—' where the window measures no rate. */ +function pctOrNull(rate: number | null): string { + return rate === null ? '—' : `${Math.round(rate * 100)}%` +} + +/** "Shape top class" = the argmax key of shape_dist; '—' when the histogram + * is empty or null (no observed values / __rule__ rows). */ +function shapeTop(dist: Record | null): string { + if (dist === null) return '—' + let best: string | null = null + let bestCount = -1 + for (const [cls, count] of Object.entries(dist)) { + if (count > bestCount) { + best = cls + bestCount = count + } + } + return best ?? '—' +} + +/** detail.field of an audit row, when the action carried one + * (rule_deactivated details name the rule, not a field). */ +function auditField(row: AuditRow): string | undefined { + if (row.detail === null) return undefined + const detail = row.detail as { field?: unknown } + return typeof detail.field === 'string' ? detail.field : undefined +} + +/** Currently-quarantined fields per fingerprint — latest-wins fold over the + * merged feed's audit rows (see the header comment: the first transition per + * (fingerprint, field) in the latest-first list is the current state). */ +function quarantinedFields( + alerts: DriftAlertRow[], +): Map> { + const seen = new Set() // 'fp\u0000field' already folded (latest) + const quarantined = new Map>() + for (const row of alerts) { + if (row.kind !== 'audit') continue + const field = auditField(row) + if (field === undefined) continue + const key = `${row.entity}\u0000${field}` + if (seen.has(key)) continue // an older transition — already superseded + seen.add(key) + if (row.action !== 'field_quarantined') continue // latest = unquarantined + let fields = quarantined.get(row.entity) + if (fields === undefined) { + fields = new Set() + quarantined.set(row.entity, fields) + } + fields.add(field) + } + return quarantined +} + +/** Stable React key for a merged-feed row (window rows key on the + * deterministic drift_windows key; audit rows on their audit id). */ +function alertKey(row: DriftAlertRow): string { + return row.kind === 'window' + ? `window:${row.fingerprint_id}:${row.rule_version}:${row.field}:${row.window_start}` + : `audit:${row.id}` +} + +/** One Field-health row: the latest closed window for a (fingerprint, field). + * The Un-quarantine button (human override, actor "ui") shows only while the + * field is currently quarantined; its POST refetches both feeds so the fold, + * the badges and the enforcement feed all move together. */ +function FieldHealthRow({ + row, + quarantined, + refresh, +}: { + row: DriftMetricRow + quarantined: boolean + refresh: () => void +}) { + const [busy, setBusy] = useState(false) + const [actionError, setActionError] = useState(null) + + const unquarantine = () => { + setBusy(true) + setActionError(null) + postJson(`/api/rules/${encodeURIComponent(row.fingerprint_id)}/unquarantine`, { + field: row.field, + actor: 'ui', + }) + .then(() => refresh()) // refetch — the fold recomputes, the button leaves + .catch((err: unknown) => setActionError(errorMessage(err))) + .finally(() => setBusy(false)) + } + + return ( + <> + + {row.fingerprint_id} + {row.field} + + {row.severity} + + {pctOrNull(row.null_rate)} + {pctOrNull(row.match_rate)} + {pctOrNull(row.violation_rate)} + {shapeTop(row.shape_dist)} + {row.events_count} + {formatTime(row.window_end)} + + {row.action_taken ?? '—'} + {quarantined && ( + + )} + + + {actionError !== null && ( + + +

    + un-quarantine failed: {actionError} +

    + + + )} + + ) +} + +/** The Drift & Health tab: field health, the merged enforcement feed, and + * unmapped.* extension opportunities. */ +export default function DriftHealth() { + const [metrics, setMetrics] = useState(null) + const [alerts, setAlerts] = useState(null) + const [error, setError] = useState(null) + const [reloadTick, setReloadTick] = useState(0) + + useEffect(() => { + let alive = true + // Both feeds as a unit: the un-quarantine gating (the audit fold) needs + // the alerts even though the button lives on the metrics table. + Promise.all([getDriftMetrics(), getDriftAlerts()]) + .then(([metricsRes, alertsRes]) => { + if (!alive) return + setMetrics(metricsRes.metrics) + setAlerts(alertsRes.alerts) + setError(null) + }) + .catch((err: unknown) => { + if (!alive) return + setError(errorMessage(err)) + }) + return () => { + alive = false + } + }, [reloadTick]) + + const refresh = useCallback(() => setReloadTick((n) => n + 1), []) + + const quarantined = + alerts === null ? new Map>() : quarantinedFields(alerts) + const opportunities = + metrics === null ? null : metrics.filter((r) => r.field.startsWith('unmapped.')) + + return ( +
    +
    +

    Drift & Health

    + + {metrics === null ? '…' : metrics.length} field signal(s) ·{' '} + {alerts === null ? '…' : alerts.length} alert(s) + +
    + + {error !== null && ( +

    + drift data unavailable: {error}{' '} + +

    + )} + + {metrics === null && alerts === null && error === null && ( +

    Loading drift data…

    + )} + +
    +

    Field health

    + {metrics === null && error === null && ( +

    Loading windows…

    + )} + {metrics !== null && metrics.length === 0 && ( +

    + No closed drift windows yet — field health appears once the drift + loop has closed a window. +

    + )} + {metrics !== null && metrics.length > 0 && ( +
    + + + + + + + + + + + + + + + + + {metrics.map((row) => ( + + ))} + +
    FingerprintFieldSeverityNullMatchViolationShape topEventsWindow endAction
    +
    + )} +
    + +
    +

    Enforcement feed

    + {alerts === null && error === null && ( +

    Loading alerts…

    + )} + {alerts !== null && alerts.length === 0 && ( +

    + No drift alerts or enforcement actions yet — windows below the + severity thresholds and quiet enforcement show nothing here. +

    + )} + {alerts !== null && alerts.length > 0 && ( +
      + {alerts.map((row) => ( +
    • + {row.kind === 'window' ? ( + <> + {row.window_start} + · + {row.fingerprint_id} + · + {row.field} + · + + {row.severity} + + · + {row.action_taken ?? '—'} + + ) : ( + <> + {row.ts} + · + {row.actor} + · + {row.action} + · + {row.entity} + · + {auditField(row) ?? '—'} + + )} +
    • + ))} +
    + )} +
    + +
    +

    Extension opportunities

    + {opportunities === null && error === null && ( +

    Loading opportunities…

    + )} + {opportunities !== null && opportunities.length === 0 && ( +

    No unmapped telemetry keys observed.

    + )} + {opportunities !== null && opportunities.length > 0 && ( +
    + {opportunities.map((row) => ( +
    +

    new telemetry key observed

    +

    {row.field}

    +

    + {row.fingerprint_id} ·{' '} + {row.events_count} events · window ended{' '} + {formatTime(row.window_end)} +

    +

    + + {row.severity} + {' '} + {row.action_taken ?? '—'} +

    +
    + ))} +
    + )} +
    +
    + ) +} diff --git a/web/src/api.ts b/web/src/api.ts index e1e9bf0..797eb36 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -5,9 +5,14 @@ // validation failures) so the Review Queue can render the gate's verdict. // // The Overview page is fed by the SSE stream; getRules/getSamples serve the -// M2 review surfaces (Tasks 8/9), mirroring the gateway endpoint shapes. +// M2 review surfaces (Tasks 8/9) and getDriftMetrics/getDriftAlerts the M3 +// Drift & Health page (Task 10), mirroring the gateway endpoint shapes. The +// un-quarantine POST rides the exported postJson directly (Rules-deactivate +// style) rather than a dedicated wrapper. import type { AuditResponse, + DriftAlertsResponse, + DriftMetricsResponse, EventsResponse, RawTrace, RuleHistoryResponse, @@ -128,3 +133,18 @@ export function getSamples( `/api/onboarding/samples${qs}`, ) } + +// --- M3 drift surfaces (Task 7 endpoints; consumed by Task 10) --------------- + +/** GET /api/drift/metrics — the latest closed window per (fingerprint_id, + * field); the page's Field-health table renders rows verbatim. */ +export function getDriftMetrics(): Promise { + return getJson('/api/drift/metrics') +} + +/** GET /api/drift/alerts — ONE latest-first merged list (window rows ∪ audit + * rows, discriminated by `kind`) that the page renders verbatim without + * re-merging or re-sorting (ruling P-4). */ +export function getDriftAlerts(): Promise { + return getJson('/api/drift/alerts') +} diff --git a/web/src/types.ts b/web/src/types.ts index c75ccbd..4d2b9eb 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -207,3 +207,72 @@ export interface SamplesStatus { export interface SamplesListResponse { samples: SamplesStatus[] } + +// --- M3 drift surfaces (Task 7 shapes, mirrored key for key; Task 10) --------- + +/** drift_windows.severity CHECK vocabulary (migration 007 / SEVERITIES). */ +export type DriftSeverity = 'none' | 'minor' | 'moderate' | 'severe' + +/** drift_windows.action_taken CHECK vocabulary; null when the window's + * severity warranted no action ("none", or an already-quarantined field — + * the idempotent skip records no action). */ +export type DriftAction = 'alert' | 'field_quarantined' | 'rule_deactivated' + +/** One drift_windows row — GET /api/drift/metrics rows carry EXACTLY these + * 12 keys (gateway _DRIFT_WINDOW_COLUMNS): the latest closed window per + * (fingerprint_id, field). `field` is an OCSF path, 'unmapped.', or + * the '__rule__' sentinel; rates are 0..1 REALs, null where the window does + * not measure that rate for the field (match_rate is __rule__-only; + * violation_rate is null on unmapped rows); shape_dist is the JSONB + * shape-class histogram over non-null scalar values ({} when the field + * observed no values, null on __rule__ rows); timestamps ISO-8601 strings. */ +export interface DriftMetricRow { + fingerprint_id: string + rule_version: number + field: string + window_start: string + window_end: string + events_count: number + null_rate: number | null + match_rate: number | null + violation_rate: number | null + shape_dist: Record | null + severity: DriftSeverity + action_taken: DriftAction | null +} + +/** GET /api/drift/metrics response. */ +export interface DriftMetricsResponse { + metrics: DriftMetricRow[] +} + +/** One GET /api/drift/alerts row — ruling P-4's kind-discriminated union: + * ONE latest-first merged list the endpoint already ordered (the page + * renders it verbatim and never re-merges or re-sorts client-side). + * "window" rows carry the drift_windows columns (minor/moderate/severe — + * the row IS the alert); "audit" rows carry the audit_log shape with action + * field_quarantined / field_unquarantined / rule_deactivated (any actor). */ +export type DriftAlertRow = + | (DriftMetricRow & { kind: 'window' }) + | (AuditRow & { kind: 'audit' }) + +/** GET /api/drift/alerts response. */ +export interface DriftAlertsResponse { + alerts: DriftAlertRow[] +} + +/** POST /api/rules/{fp}/unquarantine body — the human override that removes + * one field from the active rule's quarantined_fields (audited, paired in + * the same transaction as the mutation). */ +export interface UnquarantineBody { + field: string + actor?: string + reason?: string +} + +/** POST /api/rules/{fp}/unquarantine 200 response — the post-state list. */ +export interface UnquarantineResponse { + fingerprint_id: string + field: string + quarantined_fields: string[] +} From cd1da5d8d6bed685b21f078d2017cf7ad1ae3817 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 28 Sep 2026 22:01:36 +0530 Subject: [PATCH 14/23] feat(gateway+web): expose quarantined_fields on rule surfaces; DriftHealth gates un-quarantine via getRuleHistory (T10, P-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway (additive only): _RULE_COLUMNS gains quarantined_fields (14th key, column order mirroring migration 001's rules table); both surfaces built from it — GET /api/rules and GET /api/rules/{fp} — carry the key. No URL, status-code, or existing-key changes; the DB column and gateway_role SELECT predate this (migration 001). Fake-conn pins updated: RULE_ROW_KEYS + rule_row() carry the key, both fetch_rules/fetch_rule_history SQL tests pin the full literal SELECT list, and test_rule_history_shape pins TEXT[] reaching the wire as a JSON array of strings. Web: DriftHealth's Un-quarantine button now gates on the ACTIVE version's quarantined_fields fetched per DISTINCT metrics fingerprint via the existing getRuleHistory (P-8); the audit-feed latest-wins fold is removed (dead code). types.ts RuleRow mirrors the 14th key exactly (string[]). Tests reworked: gate pins (active-version gating incl. newest-inactive case, one-history-per-distinct-fingerprint, POST {field, actor:'ui'} + full refetch, 409 banner); Rules/ReviewQueue fixtures gain the additive key. Co-Authored-By: Claude Code --- services/gateway/queries.py | 9 +- services/gateway/tests/test_gateway.py | 25 ++++- web/src/DriftHealth.test.tsx | 145 ++++++++++++++++--------- web/src/DriftHealth.tsx | 96 ++++++++-------- web/src/ReviewQueue.test.tsx | 1 + web/src/Rules.test.tsx | 1 + web/src/types.ts | 8 +- 7 files changed, 183 insertions(+), 102 deletions(-) diff --git a/services/gateway/queries.py b/services/gateway/queries.py index 7940bb1..65cb064 100644 --- a/services/gateway/queries.py +++ b/services/gateway/queries.py @@ -170,8 +170,15 @@ def fetch_chain_heads() -> list[dict]: _RULE_COLUMNS = ( "id, fingerprint_id, version, pattern, mappings, provenance, confidence, " - "status, created_by, created_at, activated_at, deactivated_at, validation" + "status, quarantined_fields, created_by, created_at, activated_at, " + "deactivated_at, validation" ) +# quarantined_fields (ruling P-8): additive 14th key, column order mirroring +# migration 001's rules table (status, quarantined_fields, created_by). The +# column has existed since 001 (TEXT[] NOT NULL DEFAULT '{}') and gateway_role +# holds table-level SELECT, so both rule surfaces gain the key with no grant +# change — the Drift & Health page gates un-quarantine on the active +# version's list from GET /api/rules/{fp}. def fetch_rules(status=None): diff --git a/services/gateway/tests/test_gateway.py b/services/gateway/tests/test_gateway.py index 921871a..a1da393 100644 --- a/services/gateway/tests/test_gateway.py +++ b/services/gateway/tests/test_gateway.py @@ -283,13 +283,25 @@ def test_r9_current_view_predicate_in_every_normalized_query(queries): RULE_ROW_KEYS = { "id", "fingerprint_id", "version", "pattern", "mappings", "provenance", "confidence", - "status", "created_by", "created_at", "activated_at", "deactivated_at", "validation", + "status", "quarantined_fields", "created_by", "created_at", "activated_at", + "deactivated_at", "validation", } AUDIT_ROW_KEYS = {"id", "ts", "actor", "action", "entity", "detail"} +# P-8: the additive quarantined_fields column rides BOTH rule surfaces +# (fetch_rules, fetch_rule_history) — the full SELECT list is pinned literally +# (the services/pipeline/tests/test_rules.py style) so a regression that drops +# the key fails here, not in the Drift & Health page's gating. +RULE_COLUMNS_SQL = ( + "SELECT id, fingerprint_id, version, pattern, mappings, provenance, " + "confidence, status, quarantined_fields, created_by, created_at, " + "activated_at, deactivated_at, validation FROM rules" +) + def rule_row(**overrides) -> dict: - """One RuleRow as psycopg dict_row returns it (JSONB loaded, real datetimes).""" + """One RuleRow as psycopg dict_row returns it (JSONB loaded, TEXT[] as a + Python list, real datetimes).""" row = { "id": 7, "fingerprint_id": "fp_ssh_denied", @@ -299,6 +311,7 @@ def rule_row(**overrides) -> dict: "provenance": "slm", "confidence": 0.93, "status": "active", + "quarantined_fields": [], "created_by": "onboarding", "created_at": datetime(2026, 9, 19, 9, 0, 0, tzinfo=UTC), "activated_at": datetime(2026, 9, 19, 9, 5, 0, tzinfo=UTC), @@ -397,7 +410,8 @@ def fake(status=None): def test_rule_history_shape(client, queries, monkeypatch): - body = {"rules": [rule_row(version=3), rule_row(version=2, status="superseded")], + body = {"rules": [rule_row(version=3, quarantined_fields=["dst_endpoint.ip"]), + rule_row(version=2, status="superseded")], "audit": [audit_row()]} monkeypatch.setattr(queries, "fetch_rule_history", lambda fp: body) r = client.get("/api/rules/fp_ssh_denied") @@ -406,6 +420,9 @@ def test_rule_history_shape(client, queries, monkeypatch): "rules": [jsonable(row) for row in body["rules"]], "audit": [jsonable(row) for row in body["audit"]], } + # P-8: TEXT[] reaches the wire as a JSON array of strings — the Drift & + # Health page's un-quarantine gate consumes this exact value. + assert r.json()["rules"][0]["quarantined_fields"] == ["dst_endpoint.ip"] # ---------- GET /api/onboarding/samples ---------- @@ -466,6 +483,7 @@ def test_fetch_rules_filters_status_and_orders(queries, monkeypatch): conn = _FakeRowsConn(fetchall=[[rule_row()]]) monkeypatch.setattr(queries, "_connect", lambda: conn) assert queries.fetch_rules("pending_review") == [rule_row()] + assert RULE_COLUMNS_SQL in conn.sql[0] # P-8: quarantined_fields rides the list surface assert "WHERE (%s::text IS NULL OR status = %s)" in conn.sql[0] assert "ORDER BY fingerprint_id, version DESC" in conn.sql[0] assert conn.params[0] == ("pending_review", "pending_review") @@ -480,6 +498,7 @@ def test_fetch_rule_history_returns_rules_and_scoped_audit(queries, monkeypatch) body = queries.fetch_rule_history("fp_ssh_denied") assert body["rules"][0]["version"] == 3 # versions newest-first assert body["audit"] == [audit_row()] + assert RULE_COLUMNS_SQL in conn.sql[0] # P-8: ...and the history surface assert "ORDER BY version DESC" in conn.sql[0] assert "FROM audit_log" in conn.sql[1] and "ORDER BY id DESC LIMIT 200" in conn.sql[1] assert conn.params[1] == ("fp_ssh_denied",) diff --git a/web/src/DriftHealth.test.tsx b/web/src/DriftHealth.test.tsx index 8d653f6..134b97c 100644 --- a/web/src/DriftHealth.test.tsx +++ b/web/src/DriftHealth.test.tsx @@ -2,26 +2,31 @@ // web/src/DriftHealth.test.tsx — the Drift & Health page tests (M3 Task 10, // the fifth §12 page). The './api' module is mocked wholesale (house rule: // tests never touch the network); this file's tree imports getDriftMetrics, -// getDriftAlerts (the page) and postJson (the un-quarantine button). +// getDriftAlerts, getRuleHistory (the page — the last is the P-8 un-quarantine +// gate source) and postJson (the un-quarantine button). import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import DriftHealth from './DriftHealth' -import { getDriftAlerts, getDriftMetrics, postJson } from './api' +import { getDriftAlerts, getDriftMetrics, getRuleHistory, postJson } from './api' import type { AuditRow, DriftAlertRow, DriftMetricRow, + RuleHistoryResponse, + RuleRow, UnquarantineResponse, } from './types' vi.mock('./api', () => ({ getDriftMetrics: vi.fn(), getDriftAlerts: vi.fn(), + getRuleHistory: vi.fn(), postJson: vi.fn(), })) const mockGetDriftMetrics = vi.mocked(getDriftMetrics) const mockGetDriftAlerts = vi.mocked(getDriftAlerts) +const mockGetRuleHistory = vi.mocked(getRuleHistory) const mockPostJson = vi.mocked(postJson) /** A severe window row for fp-acme-fw / src_endpoint.ip — the firmware-drift @@ -70,6 +75,35 @@ const unquarantineOk: UnquarantineResponse = { quarantined_fields: [], } +/** One GET /api/rules/{fp} rule row (all 14 gateway _RULE_COLUMNS keys); the + * default is the fp-acme-fw ACTIVE v3 whose quarantined_fields gate the + * Un-quarantine button (P-8). */ +function historyRule(overrides: Partial = {}): RuleRow { + return { + id: 7, + fingerprint_id: 'fp-acme-fw', + version: 3, + pattern: '^.*?(?P.*)$', + mappings: [{ source_field: 'SRCADDR', ocsf_path: 'src_endpoint.ip' }], + provenance: 'slm', + confidence: 0.93, + status: 'active', + quarantined_fields: [], + created_by: 'onboarding', + created_at: '2026-09-19T09:00:00Z', + activated_at: '2026-09-19T09:05:00Z', + deactivated_at: null, + validation: null, + ...overrides, + } +} + +/** GET /api/rules/{fp} response for a fingerprint whose ACTIVE rule + * quarantines exactly `quarantined` (and nothing else). */ +function ruleHistory(quarantined: string[] = []): RuleHistoryResponse { + return { rules: [historyRule({ quarantined_fields: quarantined })], audit: [] } +} + afterEach(() => { cleanup() // vitest without globals does not auto-cleanup RTL renders vi.clearAllMocks() @@ -93,6 +127,7 @@ describe('Drift & Health — Field health', () => { ], }) mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory()) const { container } = render() // both feeds load on mount @@ -138,6 +173,7 @@ describe('Drift & Health — Field health', () => { ], }) mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory()) render() await screen.findByText('__rule__') @@ -162,6 +198,7 @@ describe('Drift & Health — Enforcement feed', () => { // contract (P-4) and the page must render the array verbatim — a client // re-sort would reorder these rows. mockGetDriftMetrics.mockResolvedValue({ metrics: [metricRow()] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory()) mockGetDriftAlerts.mockResolvedValue({ alerts: [ windowAlert({ window_start: '2026-09-22T10:00:00Z', severity: 'severe' }), @@ -216,58 +253,30 @@ describe('Drift & Health — Enforcement feed', () => { }) describe('Drift & Health — Un-quarantine', () => { - it('shows the button only on currently-quarantined fields (latest audit transition wins), POSTs {field, actor:"ui"} and refetches both feeds', async () => { + it("gates on the ACTIVE rule version's quarantined_fields (P-8), POSTs {field, actor:'ui'} and refetches feeds + histories", async () => { const metrics = [ metricRow({ field: 'src_endpoint.ip' }), metricRow({ field: 'dst_endpoint.ip' }), ] - // Latest-first: src was un-quarantined AFTER its quarantine (not - // quarantined anymore); dst's latest transition is the quarantine. - const initialAlerts: DriftAlertRow[] = [ - auditAlert({ - id: 46, - ts: '2026-09-22T11:00:00Z', - actor: 'karthik', - action: 'field_unquarantined', - detail: { field: 'src_endpoint.ip', rule_id: 3, version: 3 }, - }), - auditAlert({ - id: 45, - ts: '2026-09-22T10:30:00Z', - action: 'field_quarantined', - detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, - }), - auditAlert({ - id: 44, - ts: '2026-09-22T10:00:00Z', - action: 'field_quarantined', - detail: { field: 'src_endpoint.ip', rule_id: 3, version: 3 }, - }), - ] mockGetDriftMetrics.mockResolvedValue({ metrics }) - mockGetDriftAlerts.mockResolvedValueOnce({ alerts: initialAlerts }) - // post-click refetch: the latest dst transition is now the un-quarantine - mockGetDriftAlerts.mockResolvedValueOnce({ - alerts: [ - auditAlert({ - id: 47, - ts: '2026-09-22T11:05:00Z', - actor: 'ui', - action: 'field_unquarantined', - detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, - }), - ...initialAlerts, - ], - }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) // display-only now + // the ACTIVE rule quarantines exactly dst_endpoint.ip (newest first) + mockGetRuleHistory.mockResolvedValueOnce( + ruleHistory(['dst_endpoint.ip']), + ) + // post-click refetch: the active rule quarantines nothing anymore + mockGetRuleHistory.mockResolvedValueOnce(ruleHistory([])) mockPostJson.mockResolvedValueOnce(unquarantineOk) render() - // the field shows in its table row AND the enforcement feed + // the field shows in its table row await screen.findAllByText('dst_endpoint.ip') // exactly one button — on the dst row, not the src row const buttons = screen.getAllByRole('button', { name: 'Un-quarantine' }) expect(buttons).toHaveLength(1) expect(buttons[0]?.closest('tr')?.textContent).toContain('dst_endpoint.ip') + // the gate asked for the (single) metrics fingerprint's rule history + expect(mockGetRuleHistory).toHaveBeenCalledWith('fp-acme-fw') fireEvent.click(buttons[0]) await waitFor(() => { @@ -276,26 +285,60 @@ describe('Drift & Health — Un-quarantine', () => { { field: 'dst_endpoint.ip', actor: 'ui' }, ) }) - // both feeds were refetched and the button left with the new fold + // everything was refetched and the button left with the recomputed gate await waitFor(() => { expect(screen.queryByRole('button', { name: 'Un-quarantine' })).toBeNull() }) expect(mockGetDriftMetrics).toHaveBeenCalledTimes(2) expect(mockGetDriftAlerts).toHaveBeenCalledTimes(2) + expect(mockGetRuleHistory).toHaveBeenCalledTimes(2) }) - it('shows a row-level error banner when the POST fails (409: not quarantined)', async () => { + it('fetches one rule history per DISTINCT metrics fingerprint', async () => { mockGetDriftMetrics.mockResolvedValue({ - metrics: [metricRow({ field: 'dst_endpoint.ip' })], + metrics: [ + metricRow({ field: 'src_endpoint.ip' }), + metricRow({ field: 'dst_endpoint.ip' }), // same fp — deduped + metricRow({ fingerprint_id: 'fp-acme-db', field: 'src_endpoint.ip' }), + ], }) - mockGetDriftAlerts.mockResolvedValue({ - alerts: [ - auditAlert({ - action: 'field_quarantined', - detail: { field: 'dst_endpoint.ip', rule_id: 3, version: 3 }, - }), + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory()) + render() + await screen.findAllByText('fp-acme-db') + + expect(mockGetRuleHistory.mock.calls).toEqual([ + ['fp-acme-fw'], + ['fp-acme-db'], + ]) + }) + + it('gates nothing when the newest version is inactive — the ACTIVE one decides', async () => { + mockGetDriftMetrics.mockResolvedValue({ + metrics: [metricRow({ field: 'src_endpoint.ip' })], + }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + mockGetRuleHistory.mockResolvedValue({ + rules: [ + // newest (v4) deactivated but still carrying the field in its list; + // the active v3 quarantines nothing — the gate follows the ACTIVE one + historyRule({ version: 4, status: 'deactivated', quarantined_fields: ['src_endpoint.ip'] }), + historyRule({ version: 3 }), ], + audit: [], + }) + render() + await screen.findAllByText('src_endpoint.ip') + + expect(screen.queryByRole('button', { name: 'Un-quarantine' })).toBeNull() + }) + + it('shows a row-level error banner when the POST fails (409: not quarantined)', async () => { + mockGetDriftMetrics.mockResolvedValue({ + metrics: [metricRow({ field: 'dst_endpoint.ip' })], }) + mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory(['dst_endpoint.ip'])) mockPostJson.mockRejectedValueOnce( new Error( 'POST /api/rules/fp-acme-fw/unquarantine failed: field ' + @@ -337,6 +380,7 @@ describe('Drift & Health — Extension opportunities', () => { ], }) mockGetDriftAlerts.mockResolvedValue({ alerts: [] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory()) render() // the key shows in its table row AND its opportunity card await screen.findAllByText('unmapped.SRCADDR') @@ -362,6 +406,7 @@ describe('Drift & Health — load failure', () => { expect(banner.textContent).toMatch(/500/) mockGetDriftMetrics.mockResolvedValueOnce({ metrics: [metricRow()] }) + mockGetRuleHistory.mockResolvedValue(ruleHistory()) fireEvent.click(screen.getByRole('button', { name: 'Retry' })) expect(await screen.findByText('fp-acme-fw')).not.toBeNull() expect(screen.queryByRole('alert')).toBeNull() diff --git a/web/src/DriftHealth.tsx b/web/src/DriftHealth.tsx index dde130c..bf076e8 100644 --- a/web/src/DriftHealth.tsx +++ b/web/src/DriftHealth.tsx @@ -3,29 +3,30 @@ // the merged enforcement/alert feed, and unmapped.* extension opportunities. // // Two fetches fire together on mount and after every action (one effect, one -// alive flag, Promise.all — the page's un-quarantine gating needs BOTH feeds, -// so they load or fail as a unit with a single Retry banner). The alerts -// endpoint (ruling P-4) already merges window rows and audit actions into ONE -// latest-first list — this page renders that array verbatim, discriminated -// only by the `kind` field, and never re-merges or re-sorts client-side. +// alive flag, Promise.all — one shared Retry banner when either fails). The +// alerts endpoint (ruling P-4) already merges window rows and audit actions +// into ONE latest-first list — this page renders that array verbatim, +// discriminated only by the `kind` field, and never re-merges or re-sorts +// client-side. // -// Un-quarantine gating — DEVIATION from the task context, documented in the -// Task-10 report: GET /api/rules/{fp} (getRuleHistory) does NOT actually -// return rules.quarantined_fields (gateway _RULE_COLUMNS has 13 keys, none of -// them quarantined_fields; Python is out of this task's scope). Instead the -// button is gated by a latest-wins fold over the alert feed's audit rows: -// enforce.py and writes.py write field_quarantined / field_unquarantined -// audit rows ONLY on real transitions (both mutations carry TOCTOU predicates -// and never audit a no-op), so the most recent transition per -// (fingerprint, field) — the first one seen in the latest-first list — IS the -// field's current quarantine state. Bounded by the endpoint's row cap: a -// quarantine whose transitions have scrolled out of the feed cannot be -// un-quarantined from this page. +// Un-quarantine gating (ruling P-8): once the metrics land, one +// getRuleHistory(fp) per DISTINCT fingerprint they name supplies the ACTIVE +// version's quarantined_fields — the authoritative quarantine state (the +// gateway now exposes the column on every rule row; migration 001's +// rules_one_active partial unique index guarantees at most one active +// version per fingerprint, and the un-quarantine POST itself 409s unless the +// rule is active, so a fingerprint with no active version gates nothing). +// The histories load with the feeds or fail the page as a unit. // // Air-gap rules: same-origin /api only, system fonts, zero external requests. import { useCallback, useEffect, useState } from 'react' -import { getDriftAlerts, getDriftMetrics, postJson } from './api' -import type { AuditRow, DriftAlertRow, DriftMetricRow } from './types' +import { getDriftAlerts, getDriftMetrics, getRuleHistory, postJson } from './api' +import type { + AuditRow, + DriftAlertRow, + DriftMetricRow, + RuleHistoryResponse, +} from './types' import { formatTime } from './cells' function errorMessage(err: unknown): string { @@ -60,28 +61,19 @@ function auditField(row: AuditRow): string | undefined { return typeof detail.field === 'string' ? detail.field : undefined } -/** Currently-quarantined fields per fingerprint — latest-wins fold over the - * merged feed's audit rows (see the header comment: the first transition per - * (fingerprint, field) in the latest-first list is the current state). */ -function quarantinedFields( - alerts: DriftAlertRow[], +/** Currently-quarantined fields per fingerprint, from each fingerprint's + * rule history (ruling P-8): the ACTIVE version's quarantined_fields — not + * the newest version's, which may be a deactivated row still carrying the + * list. Fingerprints with no active version gate nothing. */ +function quarantinedByFingerprint( + histories: RuleHistoryResponse[], ): Map> { - const seen = new Set() // 'fp\u0000field' already folded (latest) const quarantined = new Map>() - for (const row of alerts) { - if (row.kind !== 'audit') continue - const field = auditField(row) - if (field === undefined) continue - const key = `${row.entity}\u0000${field}` - if (seen.has(key)) continue // an older transition — already superseded - seen.add(key) - if (row.action !== 'field_quarantined') continue // latest = unquarantined - let fields = quarantined.get(row.entity) - if (fields === undefined) { - fields = new Set() - quarantined.set(row.entity, fields) + for (const { rules } of histories) { + const active = rules.find((rule) => rule.status === 'active') + if (active !== undefined) { + quarantined.set(active.fingerprint_id, new Set(active.quarantined_fields)) } - fields.add(field) } return quarantined } @@ -96,8 +88,9 @@ function alertKey(row: DriftAlertRow): string { /** One Field-health row: the latest closed window for a (fingerprint, field). * The Un-quarantine button (human override, actor "ui") shows only while the - * field is currently quarantined; its POST refetches both feeds so the fold, - * the badges and the enforcement feed all move together. */ + * field sits in the active rule's quarantined_fields (P-8); its POST + * refetches the feeds and histories so the gate, the badges and the + * enforcement feed all move together. */ function FieldHealthRow({ row, quarantined, @@ -117,7 +110,7 @@ function FieldHealthRow({ field: row.field, actor: 'ui', }) - .then(() => refresh()) // refetch — the fold recomputes, the button leaves + .then(() => refresh()) // refetch — the gate recomputes, the button leaves .catch((err: unknown) => setActionError(errorMessage(err))) .finally(() => setBusy(false)) } @@ -168,18 +161,31 @@ function FieldHealthRow({ export default function DriftHealth() { const [metrics, setMetrics] = useState(null) const [alerts, setAlerts] = useState(null) + const [quarantined, setQuarantined] = useState> | null>( + null, + ) const [error, setError] = useState(null) const [reloadTick, setReloadTick] = useState(0) useEffect(() => { let alive = true - // Both feeds as a unit: the un-quarantine gating (the audit fold) needs - // the alerts even though the button lives on the metrics table. + // Both feeds as a unit; the rule histories follow the metrics (their + // fingerprints come from them) and the un-quarantine gate (P-8) loads + // with the rest or fails the page — one shared Retry banner. Promise.all([getDriftMetrics(), getDriftAlerts()]) .then(([metricsRes, alertsRes]) => { + const fingerprints = [ + ...new Set(metricsRes.metrics.map((row) => row.fingerprint_id)), + ] + return Promise.all(fingerprints.map((fp) => getRuleHistory(fp))).then( + (histories) => [metricsRes, alertsRes, histories] as const, + ) + }) + .then(([metricsRes, alertsRes, histories]) => { if (!alive) return setMetrics(metricsRes.metrics) setAlerts(alertsRes.alerts) + setQuarantined(quarantinedByFingerprint(histories)) setError(null) }) .catch((err: unknown) => { @@ -193,8 +199,6 @@ export default function DriftHealth() { const refresh = useCallback(() => setReloadTick((n) => n + 1), []) - const quarantined = - alerts === null ? new Map>() : quarantinedFields(alerts) const opportunities = metrics === null ? null : metrics.filter((r) => r.field.startsWith('unmapped.')) @@ -255,7 +259,7 @@ export default function DriftHealth() { key={`${row.fingerprint_id}:${row.rule_version}:${row.field}`} row={row} quarantined={quarantined - .get(row.fingerprint_id) + ?.get(row.fingerprint_id) ?.has(row.field) ?? false} refresh={refresh} /> diff --git a/web/src/ReviewQueue.test.tsx b/web/src/ReviewQueue.test.tsx index 0b13ffb..027c109 100644 --- a/web/src/ReviewQueue.test.tsx +++ b/web/src/ReviewQueue.test.tsx @@ -81,6 +81,7 @@ function pendingRule(overrides: Partial = {}): RuleRow { provenance: 'slm', confidence: 0.87, status: 'pending_review', + quarantined_fields: [], // P-8 additive key — empty on this fixture created_by: 'onboarding', created_at: '2026-09-19T09:05:00Z', activated_at: null, diff --git a/web/src/Rules.test.tsx b/web/src/Rules.test.tsx index 9731af0..f26e8fb 100644 --- a/web/src/Rules.test.tsx +++ b/web/src/Rules.test.tsx @@ -33,6 +33,7 @@ function ruleRow(overrides: Partial = {}): RuleRow { provenance: 'slm', confidence: 0.87, status: 'active', + quarantined_fields: [], // P-8 additive key — empty on this fixture created_by: 'onboarding', created_at: '2026-09-18T09:00:00Z', activated_at: '2026-09-19T10:00:00Z', diff --git a/web/src/types.ts b/web/src/types.ts index 4d2b9eb..b999608 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -114,9 +114,12 @@ export type RuleStatus = /** rules.provenance CHECK vocabulary. "slm-edited" marks approve-with-edits. */ export type RuleProvenance = 'slm' | 'slm-edited' | 'human' -/** GET /api/rules row — EXACTLY these 13 keys (gateway _RULE_COLUMNS). +/** GET /api/rules row — EXACTLY these 14 keys (gateway _RULE_COLUMNS). * `id` doubles as the candidate id in the approve/reject URLs; UUID-ish ids - * are strings, timestamps ISO-8601 strings. */ + * are strings, timestamps ISO-8601 strings. `quarantined_fields` mirrors + * rules.quarantined_fields TEXT[] (migration 001; additive P-8) — the fields + * drift enforcement disabled on this version; the Drift & Health page gates + * un-quarantine on the ACTIVE version's list. */ export interface RuleRow { id: number fingerprint_id: string @@ -126,6 +129,7 @@ export interface RuleRow { provenance: RuleProvenance confidence: number | null status: RuleStatus + quarantined_fields: string[] created_by: string created_at: string activated_at: string | null From ccb7561c73d9e179758fce34fe24313450d8d70c Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Mon, 28 Sep 2026 22:22:16 +0530 Subject: [PATCH 15/23] =?UTF-8?q?feat(simulator):=20firmware-drift=20mode?= =?UTF-8?q?=20=E2=80=94=20deterministic=20escalating=20syslog=20corruption?= =?UTF-8?q?=20(T11,=20R-P3/P-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --mode firmware-drift (+ --drift-after SECONDS, default 5): after the drift boundary, syslog lines handed to the sender pass through corrupt_syslog() with a phase escalating 0->1 over the remaining run budget. Selection is R-P3's Knuth-hash predicate on the line index (strict <; seed = index, no RNG state); selected lines split 1:2 per R-P9: SRC->SRCADDR rename (null-rate spike) or SRC=uplink-trust-0x4f (tier-1 violation). Only line content mutates — counts, the other corpora, and the M1 stdout contract are unchanged; steady/new-appliance paths untouched (drift_after=None). 19 new tests in simulator/tests/test_drift.py; simulator 49 passed, full suite 428 passed (baseline 409), ruff clean. Co-Authored-By: Claude Code --- simulator/simulate.py | 91 ++++++++++- simulator/tests/test_drift.py | 291 ++++++++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 simulator/tests/test_drift.py diff --git a/simulator/simulate.py b/simulator/simulate.py index 8bfcd09..d87ca63 100644 --- a/simulator/simulate.py +++ b/simulator/simulate.py @@ -20,6 +20,16 @@ # is idle — no send, no cursor advance — so the already-running corpora are # untouched while it waits. # +# Modes (T11): "firmware-drift" keeps the steady corpus set but degrades it on +# the wire: after --drift-after seconds, every syslog line handed to the sender +# passes through corrupt_syslog() with a phase that escalates from 0 to 1 over +# the remaining run budget (R-P3). Corrupted lines split exactly 1:2 (R-P9): +# SRC is renamed to SRCADDR (the collector's SRC parse reads null — null-rate +# spike) or its value becomes the invalid SRC=uplink-trust-0x4f (tier-1 +# violation) — the drift-engine demo's live-rule degradation on real traffic. +# Only line CONTENT mutates: per-corpus counts, the other corpora, and the +# stdout contract below are unchanged. +# # Stdout contract (parsed by the M1 smoke test): exactly one line per corpus, # sent= # then a final "total sent=". Everything else goes to stderr. Exit 0 unless @@ -31,6 +41,7 @@ import asyncio import inspect import os +import re import socket import sys import time @@ -50,6 +61,14 @@ HTTP_KEYS = frozenset({"json"}) CORPUS_KEYS = sorted(UDP_KEYS | HTTP_KEYS) NEWAPP_KEY = "newapp" # loaded only in --mode new-appliance (see load_corpora) +SYSLOG_KEY = "syslog" # the only corpus --mode firmware-drift mutates (T11) + +# firmware-drift corruption profile (T11; controller rulings R-P3 / R-P9). +DRIFT_HASH_MULT = 2654435761 # Knuth multiplicative hash: the seed IS the line index +DRIFT_HASH_MOD = 1000 +ESCALATION_SPAN_FLOOR_S = 1.0 # escalation window floor: never divide by zero +_SRC_FIELD_RE = re.compile(r"\bSRC=") +_SRC_VALUE_RE = re.compile(r"\bSRC=\S*") def transport_for(key: str) -> str: @@ -200,13 +219,59 @@ def default_senders() -> dict: return senders +def escalation_span(duration: float, drift_after: float) -> float: + """Seconds over which corruption ramps to 100% once drift starts (R-P3). + + Defaults to the remaining run budget (duration - drift_after), floored at + ESCALATION_SPAN_FLOOR_S so a drift that starts at (or after) the deadline + still escalates over a sane window instead of dividing by zero. + """ + return max(duration - drift_after, ESCALATION_SPAN_FLOOR_S) + + +def drift_phase(elapsed_since_drift: float, span: float) -> float: + """Corruption fraction in [0, 1]; monotonic non-decreasing in elapsed (R-P3).""" + return min(max(elapsed_since_drift / span, 0.0), 1.0) + + +def corrupt_syslog(line: str, index: int, phase: float) -> str: + """Mutate one syslog line per the firmware-drift profile (T11, R-P3/R-P9). + + R-P3 selection (seed = line index; no RNG state anywhere): the line is + corrupted iff (index * 2654435761 % 1000) / 1000 < phase — strictly, so + phase 0 corrupts nothing and phase 1 corrupts everything (2654435761 % 1000 + = 761 is coprime with 1000, making the selector a permutation of [0, 1): + phase p hits exactly p of any 1000 consecutive indices). R-P9 then splits + the corrupted lines exactly 1:2 by index mod 3: index % 3 == 0 renames the + field SRC -> SRCADDR, value kept (the collector's SRC parse reads null — + the null-rate spike); every other corrupted line gets its SRC value + replaced with the invalid token uplink-trust-0x4f (tier-1 violation). A + line with no SRC= field passes through unchanged. + """ + selector = (index * DRIFT_HASH_MULT % DRIFT_HASH_MOD) / DRIFT_HASH_MOD + if selector >= phase: # R-P3: strict <; a selector equal to phase is NOT hit + return line + if "SRC=" not in line: + return line # nothing to mutate; the line passes through byte-identical + if index % 3 == 0: + return _SRC_FIELD_RE.sub("SRCADDR=", line, count=1) + return _SRC_VALUE_RE.sub("SRC=uplink-trust-0x4f", line, count=1) + + async def run(corpora: list[Corpus], *, eps: float, duration: float, - loop: bool = False) -> tuple[dict, int]: + loop: bool = False, drift_after: float | None = None) -> tuple[dict, int]: """Send the corpora round-robin at eps total events/sec. A corpus with start_at > 0 stays idle (no send, no cursor advance) until that many seconds have elapsed, then joins the rotation in place. + drift_after (firmware-drift mode, T11): once that many seconds have + elapsed, syslog lines pass through corrupt_syslog() with a phase that + escalates from 0 to 1 over escalation_span(duration, drift_after); the + decision is made per send (send-start time), so a pass that straddles the + boundary sends clean lines before it and mutated lines after it. None + (steady / new-appliance) never mutates a line. + Returns (sent_per_corpus, error_count). A send counts only when the transport accepted it (UDP sendto returned; HTTP got a 202). """ @@ -220,6 +285,9 @@ async def run(corpora: list[Corpus], *, eps: float, duration: float, start = time.monotonic() deadline = start + max(duration, 0.0) cursors = [0] * n + if drift_after is not None: + drift_after = max(drift_after, 0.0) + span = escalation_span(duration, drift_after) pass_no = 0 while True: @@ -239,7 +307,13 @@ async def run(corpora: list[Corpus], *, eps: float, duration: float, continue cursors[i] = 0 line = corpus.lines[cursors[i]] + index = cursors[i] # the corpus line index is the R-P3 seed cursors[i] += 1 + if drift_after is not None and corpus.name == SYSLOG_KEY: + elapsed = time.monotonic() - start + if elapsed >= drift_after: # boundary respected mid-pass, per send + line = corrupt_syslog(line, index, + drift_phase(elapsed - drift_after, span)) try: sent[corpus.name] += await corpus.sender.send(line) except Exception as exc: # noqa: BLE001 - one failed send counts and moves on @@ -282,12 +356,19 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: help="directory holding raw_logs_*.txt (default: simulator/data)") p.add_argument("--loop", action="store_true", help="repeat the corpora until --duration elapses") - p.add_argument("--mode", choices=("steady", "new-appliance"), default="steady", + p.add_argument("--mode", choices=("steady", "new-appliance", "firmware-drift"), + default="steady", help="steady: golden corpora only (default); new-appliance: " - "also replay the unknown-format newapp corpus (T10)") + "also replay the unknown-format newapp corpus (T10); " + "firmware-drift: syslog lines mutate after --drift-after, " + "escalating to 100% by the end of the run (T11)") p.add_argument("--new-after", type=float, default=0.0, dest="new_after", help="new-appliance only: seconds before the newapp corpus " "joins the rotation (default 0; steady ignores this)") + p.add_argument("--drift-after", type=float, default=5.0, dest="drift_after", + help="firmware-drift only: seconds before syslog lines start " + "mutating (corruption then escalates over the remaining " + "run budget) (default 5; other modes ignore this)") return p.parse_args(argv) @@ -313,10 +394,12 @@ def main(argv: list[str] | None = None) -> int: for corpus in corpora: if corpus.name == NEWAPP_KEY: corpus.start_at = max(args.new_after, 0.0) + drift_after = max(args.drift_after, 0.0) if args.mode == "firmware-drift" else None async def drive(): try: - return await run(corpora, eps=args.eps, duration=args.duration, loop=args.loop) + return await run(corpora, eps=args.eps, duration=args.duration, + loop=args.loop, drift_after=drift_after) finally: await _close_all(senders) diff --git a/simulator/tests/test_drift.py b/simulator/tests/test_drift.py new file mode 100644 index 0000000..df75796 --- /dev/null +++ b/simulator/tests/test_drift.py @@ -0,0 +1,291 @@ +# simulator/tests/test_drift.py — firmware-drift mode (T11): deterministic +# escalating syslog corruption on the wire. R-P3 selection (seed = line index, +# strict <), R-P9 variant split (index % 3). Pure-function pins first, then +# run()/main() wiring via the fake senders from test_simulate (imported, not +# duplicated — that file must stay byte-identical per the M2 regression pins). +import math +import re + +import simulator.tests.test_simulate as ts +from simulator.simulate import ( + ESCALATION_SPAN_FLOOR_S, + _parse_args, + corrupt_syslog, + drift_phase, + escalation_span, + main, +) +from simulator.tests.test_simulate import Corpus, FakeHttp, FakeUdp + +# --- the R-P3 ruling restated verbatim (independent of the implementation) ------- + +def selector(index): + """R-P3: (line_index * 2654435761 % 1000) / 1000 — per-line value in [0, 1).""" + return (index * 2654435761 % 1000) / 1000 + + +def syslog_line(i): + return (f"Aug 27 14:32:0{i} fw01 kernel: IPTABLES-DROP: IN=eth0 OUT= " + f"SRC=203.0.113.{i} DST=192.168.1.{i} LEN=60 PROTO=TCP SPT=5142{i} " + f"DPT=22 FLAGS=SYN") + + +def renamed(line): + """R-P9 variant A (index % 3 == 0): field SRC renamed to SRCADDR, value kept.""" + return line.replace("SRC=", "SRCADDR=", 1) + + +def poisoned(line): + """R-P9 variant B (other indices): the SRC value becomes an invalid token.""" + return re.sub(r"\bSRC=\S*", "SRC=uplink-trust-0x4f", line, count=1) + + +# --- corrupt_syslog: R-P3 selection + R-P9 variants ------------------------------- + +def test_p3_selection_covers_a_uniform_fraction_of_indices(): + # 2654435761 % 1000 == 761 is coprime with 1000, so the selector is a + # permutation of k/1000: phase p selects exactly p of any 1000 consecutive + # indices — the escalation is smooth and phase 1.0 selects EVERYTHING. + for phase in (0.1, 0.25, 0.5, 0.9, 1.0): + selected = sum(1 for i in range(1000) if selector(i) < phase) + assert selected == int(phase * 1000) + + +def test_phase_zero_leaves_lines_unchanged(): + for i in range(15): + assert corrupt_syslog(syslog_line(i), i, 0.0) == syslog_line(i) + + +def test_phase_one_corrupts_every_line_with_exact_variant_split(): + lines = [syslog_line(i) for i in range(15)] + outs = [corrupt_syslog(line, i, 1.0) for i, line in enumerate(lines)] + assert all(out != line for out, line in zip(outs, lines)) # every line hit + renames = 0 + for i, (line, out) in enumerate(zip(lines, outs)): + if i % 3 == 0: + renames += 1 + assert out == renamed(line) # SRC -> SRCADDR (null-rate spike) + else: + assert out == poisoned(line) # SRC=uplink-trust-0x4f (tier-1) + assert renames == 5 # exact 1/3 rename : 2/3 value-replace over 15 lines + + +def test_phase_one_variants_pinned_as_full_literals(): + # Independent of any helper: the two variants, spelled out byte for byte. + assert corrupt_syslog(syslog_line(0), 0, 1.0) == ( + "Aug 27 14:32:00 fw01 kernel: IPTABLES-DROP: IN=eth0 OUT= " + "SRCADDR=203.0.113.0 DST=192.168.1.0 LEN=60 PROTO=TCP SPT=51420 " + "DPT=22 FLAGS=SYN") + assert corrupt_syslog(syslog_line(1), 1, 1.0) == ( + "Aug 27 14:32:01 fw01 kernel: IPTABLES-DROP: IN=eth0 OUT= " + "SRC=uplink-trust-0x4f DST=192.168.1.1 LEN=60 PROTO=TCP SPT=51421 " + "DPT=22 FLAGS=SYN") + + +def test_line_without_src_passes_through_even_at_phase_one(): + bare = "Aug 27 14:32:07 fw01 sshd[1901]: Accepted publickey for root" + for i in range(6): + assert corrupt_syslog(bare, i, 1.0) == bare + + +def test_corrupt_syslog_is_deterministic(): + # same inputs -> same outputs, always: no RNG state anywhere. + for phase in (0.0, 0.044, 0.283, 0.5, 0.761, 1.0): + for i in range(30): + line = syslog_line(i % 15) + assert corrupt_syslog(line, i, phase) == corrupt_syslog(line, i, phase) + + +def test_p3_selection_is_strictly_less_than_phase(): + i = 4 # selector(4) == 0.044: the smallest positive selector below index 5 + line = syslog_line(i) + boundary = selector(i) # exactly the R-P3 value, computed the same way + assert corrupt_syslog(line, i, boundary) == line # equality -> NOT corrupted + just_above = math.nextafter(boundary, 1.0) + assert corrupt_syslog(line, i, just_above) == poisoned(line) # any epsilon above + + +# --- escalation math (R-P3): phase only grows; span never divides by zero -------- + +def test_drift_phase_is_monotonic_and_clamped(): + for span in (1.0, 2.5, 55.0): + phases = [drift_phase(t, span) for t in (0.0, 0.1, 0.3, span / 2, span, span + 5)] + assert phases == sorted(phases) # only grows with elapsed time + assert phases[0] == 0.0 + assert phases[-1] == 1.0 # clamped once the escalation span has elapsed + assert all(0.0 <= p <= 1.0 for p in phases) + assert drift_phase(-1.0, 1.0) == 0.0 # pre-drift elapsed clamps to clean + + +def test_escalation_span_defaults_to_remaining_budget(): + assert escalation_span(60.0, 5.0) == 55.0 # duration - drift_after + assert escalation_span(10.0, 0.0) == 10.0 + + +def test_escalation_span_floors_when_drift_consumes_the_budget(): + assert escalation_span(10.0, 10.0) == ESCALATION_SPAN_FLOOR_S # equal -> floor + assert escalation_span(3.0, 9.0) == ESCALATION_SPAN_FLOOR_S # past the budget + assert ESCALATION_SPAN_FLOOR_S > 0 + assert drift_phase(0.5, escalation_span(10.0, 10.0)) == 0.5 # no ZeroDivision + + +# --- run(): drift wiring (clean before drift_after; only syslog mutates) --------- + +def test_drift_clean_until_drift_after_and_byte_identical_to_steady(): + pristine = {"cef": ["c1", "c2"], "acmegw": ["a1"], + "json": ["j1"], "syslog": [syslog_line(i) for i in range(4)]} + drift_log, steady_log = [], [] + + def corpora_for(log): + return [Corpus(name=k, lines=list(v), + sender=FakeHttp(k, log, batch_size=100) if k == "json" + else FakeUdp(k, log)) + for k, v in pristine.items()] + + _sent_d, err_d = ts.do_run(corpora_for(drift_log), eps=100, duration=0.5, + drift_after=60.0) # drift never due within the run + _sent_s, err_s = ts.do_run(corpora_for(steady_log), eps=100, duration=0.5) + assert err_d == err_s == 0 + assert drift_log == steady_log # same wire content as steady, byte for byte + assert [l for n, l in drift_log if n == "syslog"] == pristine["syslog"] + + +def test_drift_corrupts_only_syslog_after_the_boundary(): + pristine = {"cef": [f"c{i}" for i in range(6)], + "acmegw": [f"a{i}" for i in range(6)], + "json": [f"j{i}" for i in range(6)], + "syslog": [syslog_line(i) for i in range(6)]} + log = [] + corpora = [Corpus(name=k, lines=list(v), + sender=FakeHttp(k, log, batch_size=100) if k == "json" + else FakeUdp(k, log)) + for k, v in pristine.items()] + sent, errors = ts.do_run(corpora, eps=30, duration=0.6, loop=True, drift_after=0.0) + assert errors == 0 + assert sent["syslog"] >= 2 # enough sends to see the escalation + # the other corpora stay byte-identical to their pristine files + for k in ("cef", "acmegw", "json"): + assert {l for n, l in log if n == k} <= set(pristine[k]) + # every mutated syslog line is exactly one of the two R-P9 variants + variants = {renamed(l) if i % 3 == 0 else poisoned(l) + for i, l in enumerate(pristine["syslog"])} + syslog_out = [l for n, l in log if n == "syslog"] + corrupted = [l for l in syslog_out if l not in pristine["syslog"]] + assert corrupted # drift did fire + assert set(corrupted) <= variants + assert renamed(syslog_line(0)) in corrupted # selector(0)=0.0 < any phase>0 + assert poisoned(syslog_line(4)) in corrupted # selector(4)=0.044, phase>0.1 + + +def test_drift_boundary_is_respected_mid_pass(): + # SlowUdp spends 0.05s per send, so one pass straddles drift_after=0.09: + # corruption is decided per send (send-start time), not per pass. + log = [] + slow = ts.SlowUdp("syslog", log, 0.05) + lines = [syslog_line(i) for i in range(6)] + corpora = [Corpus("cef", ["c1"], FakeUdp("cef", log)), + Corpus("syslog", lines, slow)] + sent, errors = ts.do_run(corpora, eps=100, duration=1.0, drift_after=0.09) + assert errors == 0 + assert sent == {"cef": 1, "syslog": 6} # counts unchanged: content only mutates + syslog_sends = [l for n, l in log if n == "syslog"] + assert len(syslog_sends) == 6 + # decided at ~0.00s/~0.05s: before the boundary -> pristine + assert syslog_sends[0] == lines[0] + assert syslog_sends[1] == lines[1] + # past the boundary but selector(i) > phase -> still pristine + assert syslog_sends[2] == lines[2] # selector(2)=0.522 + assert syslog_sends[3] == lines[3] # selector(3)=0.283 + assert syslog_sends[5] == lines[5] # selector(5)=0.805 + # decided at >=0.20s: phase >= 0.11 > selector(4)=0.044 -> P-9 value variant + assert syslog_sends[4] == poisoned(lines[4]) + + +# --- main(): --mode firmware-drift wiring + the M1 stdout contract ---------------- + +def wire(tmp_path, monkeypatch, contents): + ts.make_corpora(tmp_path, contents) + log = [] + + def senders(): + return {k: (FakeHttp(k, log, batch_size=100) if k == "json" else FakeUdp(k, log)) + for k in contents} + + monkeypatch.setattr("simulator.simulate.default_senders", senders) + return log + + +SIM_LINE = re.compile(r"^(\w+) sent=(\d+)$", re.MULTILINE) # the M1 smoke regex + + +def test_main_firmware_drift_stdout_contract(tmp_path, capsys, monkeypatch): + log = wire(tmp_path, monkeypatch, + {"cef": "c1\nc2\n", "json": "j1\n", + "syslog": "\n".join(syslog_line(i) for i in range(3)) + "\n"}) + rc = main(["--eps", "100", "--duration", "1", "--mode", "firmware-drift", + "--drift-after", "60", "--corpora", str(tmp_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert out.strip().splitlines() == [ + "cef sent=2", "json sent=1", "syslog sent=3", "total sent=6"] + matches = SIM_LINE.findall(out) # still exactly one line per corpus + total + assert ("syslog", "3") in matches and ("total", "6") in matches + assert sum(1 for name, _ in matches if name == "syslog") == 1 + assert len(log) == 6 # counts unchanged: only line CONTENT may mutate + + +def test_main_firmware_drift_omits_newapp(tmp_path, capsys, monkeypatch): + wire(tmp_path, monkeypatch, {"cef": "c1\n", "newapp": "n1\n"}) + rc = main(["--eps", "100", "--duration", "1", "--mode", "firmware-drift", + "--corpora", str(tmp_path)]) + out = capsys.readouterr().out.strip().splitlines() + assert rc == 0 + assert out == ["cef sent=1", "total sent=1"] # steady corpus set: no newapp + + +def test_drift_after_flag_defaults_to_five_seconds(): + args = _parse_args(["--mode", "firmware-drift"]) + assert args.drift_after == 5.0 + args = _parse_args(["--mode", "firmware-drift", "--drift-after", "12.5"]) + assert args.drift_after == 12.5 + + +def test_main_firmware_drift_default_after_keeps_short_run_clean( + tmp_path, capsys, monkeypatch): + log = wire(tmp_path, monkeypatch, + {"syslog": "\n".join(syslog_line(i) for i in range(4)) + "\n"}) + rc = main(["--eps", "100", "--duration", "0.3", "--mode", "firmware-drift", + "--corpora", str(tmp_path)]) # default --drift-after 5 > run length + assert rc == 0 + assert [l for n, l in log] == [syslog_line(i) for i in range(4)] # pristine + + +def test_main_two_identical_drift_runs_produce_identical_bytes( + tmp_path, capsys, monkeypatch): + outs, logs = [], [] + for _ in range(2): + log = wire(tmp_path, monkeypatch, + {"cef": "c1\nc2\n", "json": "j1\n", + "syslog": "\n".join(syslog_line(i) for i in range(3)) + "\n"}) + rc = main(["--eps", "100", "--duration", "0.4", "--mode", "firmware-drift", + "--drift-after", "0", "--corpora", str(tmp_path)]) + assert rc == 0 + outs.append(capsys.readouterr().out) + logs.append(log) + assert outs[0] == outs[1] # stdout bytes identical across runs + assert logs[0] == logs[1] # wire content identical too + # index 0 (selector 0.0 < any positive phase) takes the rename variant; + # indices 1-2 (selectors 0.761 / 0.522) stay pristine at these tiny phases + assert [l for n, l in logs[0] if n == "syslog"] == [ + renamed(syslog_line(0)), syslog_line(1), syslog_line(2)] + + +def test_steady_modes_still_reject_no_drift_wiring(tmp_path, capsys, monkeypatch): + # steady/new-appliance never mutate a line even if --drift-after is passed: + # the flag is firmware-drift-only (mirrors --new-after being ignored). + log = wire(tmp_path, monkeypatch, + {"syslog": "\n".join(syslog_line(i) for i in range(3)) + "\n"}) + rc = main(["--eps", "100", "--duration", "0.3", "--drift-after", "0", + "--corpora", str(tmp_path)]) + assert rc == 0 + assert [l for n, l in log] == [syslog_line(i) for i in range(3)] # pristine From c3e2caf66a04071960855d250f75553bc757491d Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 18:02:08 +0530 Subject: [PATCH 16/23] fix(drift): ISO-serialize window_start in enforcement audit details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Task 12 gate finding: psycopg's Json() does not convert datetime, so the field_quarantined/rule_deactivated audit INSERT raised TypeError at execution time and rolled the paired mutation back — the deactivate never landed while the service retried every scan. Fake-conn unit tests never serialize, so the bug class was invisible to them; the two touched tests now pin that the detail survives plain json.dumps. Co-Authored-By: Claude Code --- services/drift/enforce.py | 8 ++++++-- services/drift/tests/test_enforce.py | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/services/drift/enforce.py b/services/drift/enforce.py index 34be3a6..8ad44f9 100644 --- a/services/drift/enforce.py +++ b/services/drift/enforce.py @@ -69,9 +69,13 @@ def quarantine_field(conn: psycopg.Connection, rule: ActiveDriftRule, field: str log.info("quarantine no-op for %s v%d field %s (already quarantined " "or rule inactive)", rule.fingerprint_id, rule.version, field) return False + # window_start is JSON-serialized as ISO-8601: the audit detail is + # JSONB, and psycopg's Json() does NOT convert datetime (observed live + # in Task 12: the deactivate audit raised TypeError and rolled the + # paired mutation back). _audit(cur, "field_quarantined", rule.fingerprint_id, {"field": field, "rule_id": rule.id, "version": rule.version, - "window_start": window_start}) + "window_start": window_start.isoformat()}) log.warning("field %s quarantined on %s v%d (window %s)", field, rule.fingerprint_id, rule.version, window_start) return True @@ -90,7 +94,7 @@ def deactivate_rule(conn: psycopg.Connection, rule: ActiveDriftRule, return False _audit(cur, "rule_deactivated", rule.fingerprint_id, {"rule_id": rule.id, "version": rule.version, - "window_start": window_start}) + "window_start": window_start.isoformat()}) log.warning("rule %d (%s v%d) DEACTIVATED by drift (window %s)", rule.id, rule.fingerprint_id, rule.version, window_start) return True diff --git a/services/drift/tests/test_enforce.py b/services/drift/tests/test_enforce.py index 417f2b3..bc0d751 100644 --- a/services/drift/tests/test_enforce.py +++ b/services/drift/tests/test_enforce.py @@ -4,6 +4,7 @@ # record_windows row assembly, and worst-first scan enforcement. Everything is # pinned through a recording fake conn/cursor or monkeypatched collaborators; # NEVER a live DB. +import json from datetime import UTC, datetime, timedelta import pytest @@ -133,7 +134,12 @@ def test_quarantine_field_true_audits_with_actor_drift_and_detail(): assert params[0] == "drift" and params[1] == "field_quarantined" assert params[2] == "fp_a" assert _unwrap(params[3]) == {"field": "dst_endpoint.ip", "rule_id": 9, - "version": 3, "window_start": _ts(90)} + "version": 3, "window_start": _ts(90).isoformat()} + # The detail is JSONB: every value must survive plain json.dumps — a raw + # datetime raised TypeError inside psycopg's Json() and rolled the paired + # mutation back (observed live in Task 12; fake conns never serialize, so + # this pin is the regression guard for that exact bug class). + json.dumps(_unwrap(params[3])) # UPDATE and its audit pair inside ONE transaction (the writes.py pairing). assert conn.txns_opened == 1 assert conn.sql[0] == "TXN-START" and conn.sql[-1] == "TXN-COMMIT" @@ -178,7 +184,11 @@ def test_deactivate_rule_true_audits_with_actor_drift(): if s.startswith("INSERT INTO audit_log")] assert params[0] == "drift" and params[1] == "rule_deactivated" assert params[2] == "fp_a" - assert _unwrap(params[3]) == {"rule_id": 11, "version": 4, "window_start": _ts(30)} + # window_start rides as ISO-8601 (JSONB detail — see the quarantine test). + detail = _unwrap(params[3]) + assert detail == {"rule_id": 11, "version": 4, + "window_start": _ts(30).isoformat()} + json.dumps(detail) # --- record_windows: findings overlaid onto the measured rows --------------------- From f38eb7e24ae890514f1e24cd4d8d75a8ba6161c1 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 18:02:08 +0530 Subject: [PATCH 17/23] fix(drift): COPY enforce.py into the service image Live Task 12 gate finding: Task 5 added services/drift/enforce.py but never extended the Dockerfile's explicit COPY list, so the built image crash-looped with ModuleNotFoundError: No module named 'drift.enforce'. Co-Authored-By: Claude Code --- services/drift/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/services/drift/Dockerfile b/services/drift/Dockerfile index 306e6a9..d4f4a48 100644 --- a/services/drift/Dockerfile +++ b/services/drift/Dockerfile @@ -21,6 +21,7 @@ COPY services/drift/__init__.py /app/drift/__init__.py COPY services/drift/config.py /app/drift/config.py COPY services/drift/windows.py /app/drift/windows.py COPY services/drift/detect.py /app/drift/detect.py +COPY services/drift/enforce.py /app/drift/enforce.py COPY services/drift/store.py /app/drift/store.py COPY services/drift/app.py /app/drift/app.py From 744169d565994803947bddbc7ee49f625b5a1076 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 18:02:32 +0530 Subject: [PATCH 18/23] =?UTF-8?q?test(smoke):=20M3=20asserts=20I-L=20?= =?UTF-8?q?=E2=80=94=20tuned=20firmware-drift=20demo=20+=20state=20restora?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-L run FIRST (before the unchanged A-H block): the drift demo needs the syslog baseline state to evolve during the firmware-drift run, and steady A-H traffic first would arm a degenerate baseline that makes J unreachable. - tune_drift_run replicates the simulator's deterministic corruption math host-side and plans --eps/--drift-after/--duration (one run at view offset 0; a minor establishing pre-run + a main run otherwise) so the J-window's tier-1 violation rate lands mid-band [0.27, 0.43] on a window that STARTS inside the run (I's window_start delta filter and J's poll both require it). - K stops the drift container before the human restore (severe windows are permanent rows of the version's view; a live loop re-deactivates the restored rule within one poll) and seeds a few steady lines after the deactivation is confirmed so the onboarding re-entry (sample_count growth) is race-free. - restore_syslog_rule: version-bump dance (successor without the SRC mapping) — the M2 sweep supersedes every poisoned v1 row, emptying v1's drift view before drift restarts, so consecutive runs pass on the accumulating DB. - Demo env: ULPF_DRIFT_WINDOW_COUNT=30 + a time horizon no demo gap can cross (count-close only; the brief's 15 s horizon slices the days-spread M1/M2 history into one-row windows and arms an all-zero baseline — observed live), and ULPF_SAMPLE_CAPTURE_CAP=2000 via a new compose passthrough (the accumulating DB saturates the 200/fingerprint cap and K's re-entry signal becomes unobservable — observed live). - Simulator runs now pass --build: profile-gated services are not built by up --build, so runs were executing an M2-era image without firmware-drift mode (observed live). Co-Authored-By: Claude Code --- deploy/.env.example | 12 +- deploy/docker-compose.yml | 5 + scripts/smoke.py | 740 +++++++++++++++++++++++++++++++++++++- 3 files changed, 746 insertions(+), 11 deletions(-) diff --git a/deploy/.env.example b/deploy/.env.example index d17a289..dfd8701 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -16,11 +16,21 @@ ULPF_OLLAMA_URL=http://ollama-4b:11434 ULPF_OLLAMA_MODEL=qwen3:4b # Drift engine knobs (M3) — read by services/drift's Config.from_env; -# docker compose passes them through to the drift service. +# docker compose passes them through to the drift service. The acceptance +# smoke (scripts/smoke.py) exports WINDOW_COUNT=30 plus a huge WINDOW_TIME_S +# (count-close only) in its own environment before compose up, so demo +# windows close on deterministic 30-row boundaries (a short time horizon +# slices the accumulated days-spread history into one-row windows — see the +# smoke header). ULPF_DRIFT_WINDOW_COUNT=1000 ULPF_DRIFT_WINDOW_TIME_S=600 ULPF_DRIFT_BASELINE_WINDOWS=10 ULPF_DRIFT_POLL_S=5 +# Onboarding sample-capture cap per fingerprint (pipeline). The accumulating +# acceptance runs saturate the default 200 — the smoke exports a larger value +# so a drift-deactivated fingerprint can still show sample growth (K). +ULPF_SAMPLE_CAPTURE_CAP=200 + # Per-service database DSNs (pipeline, gateway, admin) are compose-internal # constants defined in docker-compose.yml — M2 adds no user-facing DB knobs. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index bb46ae2..532c47f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -48,6 +48,11 @@ services: environment: KAFKA_BOOTSTRAP: redpanda:9092 DATABASE_URL: postgresql://pipeline_role:pipeline_dev@postgres:5432/ulpf + # Sample-capture headroom (M3 Task 12): the accumulating multi-run DB + # saturates the 200/fingerprint cap — after which a drift-deactivated + # fingerprint cannot accumulate onboarding samples and its re-entry is + # unobservable. Default stays 200; the smoke exports a larger value. + ULPF_SAMPLE_CAPTURE_CAP: ${ULPF_SAMPLE_CAPTURE_CAP:-200} restart: unless-stopped depends_on: { migrate: { condition: service_completed_successfully }, topics: { condition: service_completed_successfully }, seed: { condition: service_completed_successfully } } gateway: diff --git a/scripts/smoke.py b/scripts/smoke.py index 2039ae1..4b134b6 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""ULPF acceptance smoke (M1 Task 12 + M2 Task 12, spec section 15) — the -milestone exit gate. +"""ULPF acceptance smoke (M1 Task 12 + M2 Task 12 + M3 Task 12, spec §15) — +the milestone exit gate. End-to-end against the live compose stack: drives the simulator over the golden corpora, then verifies lossless ingest, the parsed/unparsed split, raw @@ -14,7 +14,54 @@ docs/m2-slm-sanity.md), G does the same deterministically for the zenwall corpus via /api/rules/manual, and H re-runs the replay dance with active rules, proving raw/normalized/stats/sample-count idempotency including the -onboarding sample dedup. +onboarding sample dedup. The M3 asserts (I-L) run FIRST, before the A-H block, +and drive the drift engine end to end on the live seeded syslog rule: I proves +tier-1 fires (a fresh drift window with severity >= minor and a nonzero +violation_rate), J that moderate drift quarantines src_endpoint.ip while the +SRC value survives under ocsf.unmapped.SRC (values preserved, mapping +disabled), K that severe drift deactivates the rule (fingerprint re-enters +onboarding — sample_count grows) and a human restores it via reactivate, and +L that a human un-quarantine re-enables the mapping for new parses. + +M3 demo env (exported below BEFORE compose up, per the Task 12 brief): the +drift service gets ULPF_DRIFT_WINDOW_COUNT=30 plus a time horizon no demo +gap can cross, so windows close on COUNT ONLY — deterministic 30-row +boundaries (the brief's 15 s time-close is unsound on the accumulating DB: +it slices the days-spread M1/M2 history into one-row windows and arms a +degenerate all-zero baseline before the demo even starts; see the constant +block below). The pipeline additionally gets ULPF_SAMPLE_CAPTURE_CAP=2000: +the accumulating multi-run DB saturates the 200/fingerprint sample cap, +after which a drift-deactivated fingerprint cannot accumulate onboarding +samples and K's re-entry signal (sample_count grows) is unobservable +(observed live in the Task 12 gate). + +M3 drift-run tuning (verified live, Task 12): the simulator's firmware-drift +corruption is fully deterministic (selector = (index * 2654435761 % 1000) / +1000 vs an escalating phase), so the smoke REPLICATES that math host-side +(tune_drift_run) and plans the drift simulator run(s) such that the J-window +— the 30-row drift window the quarantine decision rides on — carries a +tier-1 violation_rate in [0.27, 0.43]: moderate (>= 0.20 -> field quarantine, +Assert J) with margin on both sides (severe 0.50 would deactivate first and +skip the quarantine entirely). The quarantine lands at the FIRST scan that +sees that window; when that scan is also the baseline-establishing one the +decision is tier-1-only by construction (the profile arms only after the +scan's windows are recorded), which is what keeps the tier-2 null ladder from +ever preempting J. After the quarantine, every further syslog parse is null +at src_endpoint.ip (mapping disabled), so the next full window hits null_rate +1.0 against the fresh baseline — ratio >= 40x, severe -> rule_deactivated +(Assert K). With a view offset > 0 the plan uses a short PRE-RUN to complete +the establishing window before the main run supplies the J-window's rows. + +M3 state restoration (not asserts): severe windows are permanent rows of the +version's current view, and the drift loop re-evaluates the whole view every +scan — so after K's deactivation the reactivated rule would be re-deactivated +by the next scan. The smoke therefore (a) stops the drift container between +K's restore and the end of the M3 block, and (b) finishes with a human +version-bump dance (manual candidate without the SRC mapping -> approve -> the +M2 backlog sweep supersedes every poisoned v1 row at the new version -> +deactivate the new version -> reactivate v1, whose current view is now empty) +before restarting drift. This restores Assert B's seeded-fp precondition for +re-runs on the same accumulating DB (12/12 twice consecutive). Host requirements (Python 3.13 tested): psycopg[binary] plus the two libs importable (pip install -e libs/ulpf-core -e libs/ocsf-schema — the smoke @@ -30,6 +77,9 @@ Usage: python scripts/smoke.py (from the repo root) ULPF_SMOKE_SLM_WAIT_S=300 (seconds to wait for an SLM candidate before the hand-authored fallback) + ULPF_SMOKE_M3_ONLY=1 (DEV: run only the M3 I-L block, skip + the A-H block — the exit gate always + runs the full 12) Exit: 0 all checks PASS, 1 any FAIL. """ @@ -37,6 +87,7 @@ import hashlib import json +import math import os import random import re @@ -45,6 +96,7 @@ import time import urllib.error import urllib.request +from datetime import UTC, datetime from pathlib import Path import psycopg @@ -89,6 +141,7 @@ SAMPLES_WAIT_TIMEOUT_S = 60 # onboarding sample capture for a posted corpus REPARSE_TIMEOUT_S = 120 # approve -> reparse_complete audit row (R-T12-pre-d) AUDIT_PAGE = 200 # /api/audit page size (>= any fingerprint's M2-run trail) +M3_ONLY = os.environ.get("ULPF_SMOKE_M3_ONLY", "") == "1" # dev affordance COLLECTOR = "http://localhost:8080" NEWAPP_SOURCE = "newapp01" @@ -119,6 +172,71 @@ (("rule", "message"),), ) +# --- M3 (Task 12 asserts I-L): the firmware-drift demo on the seeded syslog rule -- + +SYSLOG_FP = "syslog" +QUARANTINE_FIELD = "src_endpoint.ip" # the syslog rule's SRC mapping (seed 001) + +# Demo drift knobs exported before compose up (Task 12 brief Step 1): windows +# close every 30 rows so the demo fits in minutes. The brief pinned +# WINDOW_TIME_S=15 as well, but on the ACCUMULATING DB that is unsound: the +# M1/M2-era rows are spread across days, and a 15 s time-close slices them +# into ~255 one-row windows at the very first scan — arming the syslog +# baseline from ten degenerate 1-row windows (null_rate 0.0), after which the +# tier-2 null ladder fires severe at ANY 5% null rate and deactivates the +# rule before the moderate quarantine (Assert J) can ever land (observed +# live). The smoke therefore pins a time horizon no demo gap can cross and +# lets windows close on COUNT only — deterministic 30-row boundaries the +# tuning math below models exactly. +DRIFT_WINDOW_COUNT = "30" +DRIFT_WINDOW_TIME_S = "1000000000" # ~31.7 years: count-close only + +# The drift-run tuning targets the FIRST FULLY-NEW 30-row window of the run +# (run rows offset+1 .. offset+30, where offset = current v1 view % 30) at a +# tier-1 violation_rate in [MIN, MAX] — comfortably moderate (> 0.20), safely +# below severe (0.50), with margin for +/-2 lines of send-time jitter. +J_WINDOW_MIN_VIOLATION = 0.27 +J_WINDOW_MAX_VIOLATION = 0.43 +J_WINDOW_TARGET_VIOLATION = 0.35 +W_OLD_MAX_VIOLATION = 0.18 # the partial-completing window must stay minor + +DRIFT_I_TIMEOUT_S = 120.0 # I: metrics row appears (tier-1 fires) +DRIFT_J_TIMEOUT_S = 120.0 # J: quarantine lands + a post-quarantine parsed event +DRIFT_K_TIMEOUT_S = 120.0 # K: deactivate + sample growth +DRIFT_L_TIMEOUT_S = 90.0 # L: un-quarantine + a clean mapped event +RULE_REFRESH_MARGIN_S = 6.0 # pipeline reloads active rules every batch (<= 2 s) + +# K-maker: post-quarantine syslog parses are ALL null at src_endpoint.ip (the +# mapping is disabled), so any full 30-row window of them hits null_rate 1.0 +# vs the ~0.017 baseline — ratio >= 40x, severe -> deactivate. Corruption +# content is irrelevant here (clean lines are nulls too once quarantined); the +# firmware-drift mode is used anyway to keep the demo narrative honest. +K_MAKER = {"eps": "25", "drift_after": "1", "duration": "15"} +# Probe/flush: brief steady runs (clean lines only). The probe supplies the +# post-quarantine parsed events J inspects; the flush proves L's mapping came +# back once clean lines flow after the human un-quarantine. +PROBE_RUN = {"eps": "4", "duration": "6"} +FLUSH_RUN = {"eps": "25", "duration": "8"} + +# Restoration dance rule (NOT an assert input): the seed syslog pattern with +# every mapping EXCEPT SRC — sane on any corpus line (corruption only touches +# SRC), so the candidate gate passes even against corrupted onboarding +# samples, and the M2 backlog sweep re-parses the poisoned v1 rows under a +# version that does not even map src_endpoint.ip (no violations recorded). +SYSLOG_NO_SRC_RULE = ( + r"^.*?IPTABLES-\w+:\s*(?P.*)$", + (("SPT", "src_endpoint.port"), ("DST", "dst_endpoint.ip"), + ("DPT", "dst_endpoint.port")), +) +SYSLOG_CORPUS = ROOT / "simulator" / "data" / "raw_logs_syslog.txt" + +# The simulator's deterministic corruption profile, replicated host-side for +# tuning (T11 constants; see simulator/simulate.py corrupt_syslog). The +# selector is a permutation of [0, 1) over any 1000 consecutive indices, so +# `selector < phase` corrupts exactly a `phase` fraction in expectation. +DRIFT_HASH_MULT = 2654435761 +DRIFT_HASH_MOD = 1000 + _SIM_LINE = re.compile(r"^(\w+) sent=(\d+)$", re.MULTILINE) _LAG_LINE = re.compile(r"^TOTAL-LAG\s+(\S+)\s*$", re.MULTILINE) @@ -282,13 +400,32 @@ def step1_up() -> None: sys.exit("FATAL: stack not ready (gateway/web/rules API/kafka group 'pipeline').") -def run_simulator() -> dict[str, int]: - print(f"== Step 2: simulator --eps {SIM_EPS} --duration {SIM_DURATION} ==") +def run_simulator(*, mode: str = "steady", eps: str | None = None, + duration: str | None = None, drift_after: str | None = None, + loop: bool = False, label: str = "Step 2") -> dict[str, int]: + """`docker compose run` the simulator; defaults reproduce the M2 steady + call byte-for-byte. M3 passes mode/eps/duration/drift_after/loop for the + firmware-drift runs and the brief steady probes. Returns the parsed + stdout contract dict ({corpus: sent, total: N}).""" + eps = SIM_EPS if eps is None else eps + duration = SIM_DURATION if duration is None else duration + args = ["--mode", mode, "--eps", eps, "--duration", duration] + if drift_after is not None: + args += ["--drift-after", drift_after] + if loop: + args.append("--loop") # M3 only: repeat corpora so one run yields 30+ syslog rows + print(f"== {label}: simulator --mode {mode} --eps {eps} --duration {duration}" + + (f" --drift-after {drift_after}" if drift_after is not None else "") + + (" --loop" if loop else "") + " ==") # --profile sim BEFORE run: the simulator service is profile-gated # (docker-compose.yml), and `docker compose run` refuses a gated service - # whose profile is not active (R-T12-pre-b). - out = compose("--profile", "sim", "run", "--rm", "-T", "simulator", - "--eps", SIM_EPS, "--duration", SIM_DURATION, timeout=SIM_TIMEOUT_S) + # whose profile is not active (R-T12-pre-b). --build: profile-gated + # services are NOT built by `up --build` (their profile is inactive + # there), so without this the run would use whatever image happened to + # be built last — observed live in M3 Task 12 as an M2-era image without + # firmware-drift mode. Cached layers make the rebuild cheap. + out = compose("--profile", "sim", "run", "--rm", "-T", "--build", "simulator", + *args, timeout=SIM_TIMEOUT_S) sent = {name: int(n) for name, n in _SIM_LINE.findall(out.stdout)} if out.returncode != 0 or "total" not in sent: print("stdout:", out.stdout[-2000:]) @@ -928,6 +1065,544 @@ def assert_h(fp: str, zfp: str) -> None: + ("" if not problems else "; PROBLEMS: " + "; ".join(problems))) +# --- M3: drift demo asserts I-L (Task 12 Steps 2-5) ------------------------------- + +_SEVERITY_RANK = {name: rank for rank, name in + enumerate(("none", "minor", "moderate", "severe"))} + + +def _parse_ts(value) -> datetime: + """API timestamp (ISO-8601 string or datetime) -> aware UTC datetime.""" + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=UTC) + if not value: + return datetime.min.replace(tzinfo=UTC) + parsed = datetime.fromisoformat(str(value)) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def _selector(index: int) -> float: + """simulator.corrupt_syslog's deterministic per-line corruption selector + (host-side replica for run tuning — the seed IS the corpus line index).""" + return (index * DRIFT_HASH_MULT % DRIFT_HASH_MOD) / DRIFT_HASH_MOD + + +def _row_outcome(k: int, rate: float, drift_after: str, duration: str, + corpus_lines: int) -> tuple[bool, bool]: + """(violates, nulls) for the k-th syslog send (1-based) of ONE simulator + run at `rate` syslog lines/s — replica of simulator.corrupt_syslog: send + time t = k/rate (one syslog line per round-robin pass), phase ramping + from drift_after over the escalation span, corrupted iff + selector((k-1) % corpus_lines) < phase, and among the corrupted 1 in 3 + (by corpus index) renames SRC->SRCADDR (a null) while the rest write the + invalid SRC value (a tier-1 violation).""" + t = k / rate + a = float(drift_after) + if t <= a: + return False, False + span = max(float(duration) - a, 1.0) + phase = min(max((t - a) / span, 0.0), 1.0) + index = (k - 1) % corpus_lines + if _selector(index) < phase: + return index % 3 != 0, index % 3 == 0 + return False, False + + +def _window_counts(segments, corpus_lines: int) -> tuple[int, int]: + """(violations, nulls) over one 30-row window assembled from consecutive + pieces of one or two simulator runs. Each segment is (first_k, n_rows, + eps, drift_after, duration): sends first_k..first_k+n_rows-1 of a run + with those parameters (send numbering restarts per run — each + `compose run` is a fresh process with fresh cursors).""" + viol = nulls = 0 + for first_k, n, eps, drift_after, duration in segments: + rate = float(eps) / 4.0 + for k in range(first_k, first_k + n): + v, nu = _row_outcome(k, rate, drift_after, duration, corpus_lines) + viol += int(v) + nulls += int(nu) + return viol, nulls + + +def tune_drift_run(offset: int, corpus_lines: int) -> dict: + """Plan the firmware-drift simulator run(s) for THIS run's view offset. + + The drift service closes a window every 30 current-view rows of the + ACTIVE version. offset = view%30 decides the shape: with offset == 0 the + run's first 30 rows are themselves the next closed window — which is also + the ESTABLISHING 10th window (the DB history has exactly 9 windows, a + verified M3 starting condition; once a baseline exists this window is + simply the next one and the same math holds, see below). With offset > 0 + the run's first E = 30-offset rows complete the older partial (the + establishing window), and the J-window is the NEXT one. + + The quarantine (J) must land on a window whose window_start is INSIDE + this run (I's delta filter and J's poll both key off fresh rows): with + offset == 0 the run's first 30 rows are that window. With offset > 0 the + run's first E = 30-offset rows complete the OLDER partial — that window + STARTS on a prior-run row, so it must stay MINOR (the quarantine must not + land there: its metrics row is invisible to I's window_start filter and + its scan races the null-severe deactivate) — and the NEXT window, built + from the main run's fresh rows, is the J-window. The plan guarantees: + + - the J-window's deterministic violation_rate lands in + [J_WINDOW_MIN, J_WINDOW_MAX] — moderate with margin on both sides, + robust to +/-2 lines of send-time jitter; + - the establishing window (offset > 0) stays minor (< W_OLD_MAX + violations), so the moderate decision — and I's fresh metrics row — + both arrive at the J-window's scan, before any severe window. + + Returns {"runs": [params...] to execute in order, "j_violation": float, + "j_nulls": int, "establishing_violation": float}; params dicts are + run_simulator kwargs (mode/loop added by the caller). + """ + def rate_of(eps): + return float(eps) / 4.0 + + space = [] + if offset == 0: + # One run: its rows 1..30 are the J-window (and the establishing one). + for eps in ("6", "8", "10"): + rate = rate_of(eps) + for drift_after in ("0", "1", "2"): + duration = 30 / rate + 1.0 + while duration <= 30 / rate + 10.0: + d = f"{duration:.1f}" + viol, nulls = _window_counts( + [(1, 30, eps, drift_after, d)], corpus_lines) + space.append({"runs": [{"eps": eps, "drift_after": drift_after, + "duration": d}], + "j_violation": viol / 30, "j_nulls": nulls, + "est_violation": viol / 30, "est_nulls": nulls, + "segments": [(1, 30, eps, drift_after, d)]}) + duration += 0.5 + else: + est_rows = 30 - offset + # Two runs: a short PRE-RUN completes the establishing window (kept + # minor); the MAIN run (fresh cursors) supplies the J-window's bulk. + # The pre-run may overflow by 0-2 rows into the J-window's head. + for pre_eps in ("4", "6", "8", "10"): + pre_rate = rate_of(pre_eps) + for pre_a in ("0", "1", "2", "3"): + pre_d = est_rows / pre_rate + 0.1 + while pre_d <= est_rows / pre_rate + 0.9: + pd = f"{pre_d:.1f}" + pre_rows = min(max(math.ceil(pre_rate * float(pd)), est_rows), + est_rows + 2) + est_viol, est_nulls = _window_counts( + [(1, est_rows, pre_eps, pre_a, pd)], corpus_lines) + overflow = pre_rows - est_rows + if est_viol >= W_OLD_MAX_VIOLATION * 30: + pre_d += 0.2 + continue + for eps in ("6", "8", "10"): + rate = rate_of(eps) + for drift_after in ("0", "1", "2"): + main_rows = 30 - overflow + duration = main_rows / rate + 1.0 + while duration <= main_rows / rate + 8.0: + d = f"{duration:.1f}" + segments = [] + if overflow: + segments.append((est_rows + 1, overflow, + pre_eps, pre_a, pd)) + segments.append((1, main_rows, eps, drift_after, d)) + viol, nulls = _window_counts(segments, corpus_lines) + space.append({ + "runs": [{"eps": pre_eps, "drift_after": pre_a, + "duration": pd}, + {"eps": eps, "drift_after": drift_after, + "duration": d}], + "j_violation": viol / 30, "j_nulls": nulls, + "est_violation": est_viol / 30, + "est_nulls": est_nulls, "segments": segments}) + duration += 0.5 + pre_d += 0.2 + tiers = [ + lambda c: J_WINDOW_MIN_VIOLATION <= c["j_violation"] <= J_WINDOW_MAX_VIOLATION, + lambda c: 0.22 <= c["j_violation"] <= 0.48, + lambda c: 0.20 <= c["j_violation"] < 0.50, + ] + if not space: + # No pre-run kept the establishing window minor — fall back to any + # pre-run params (the quarantine may land on the old-started window; + # I/J then depend on the main run's own windows). Loud, never silent. + print(" WARNING: no pre-run kept the establishing window minor; " + "falling back to untuned pre-run") + pre_eps, pre_a = "6", "2" + pd = f"{est_rows / rate_of(pre_eps) + 0.5:.1f}" + for eps in ("6", "8", "10"): + rate = rate_of(eps) + for drift_after in ("0", "1", "2"): + d = f"{30 / rate + 5.0:.1f}" + viol, nulls = _window_counts( + [(1, 30, eps, drift_after, d)], corpus_lines) + space.append({"runs": [{"eps": pre_eps, "drift_after": pre_a, + "duration": pd}, + {"eps": eps, "drift_after": drift_after, + "duration": d}], + "j_violation": viol / 30, "j_nulls": nulls, + "est_violation": 1.0, "est_nulls": 0, + "segments": [(1, 30, eps, drift_after, d)]}) + chosen = None + for tier_no, tier in enumerate(tiers): + pool = [c for c in space if tier(c)] + if pool: + chosen = min(pool, key=lambda c: abs(c["j_violation"] - J_WINDOW_TARGET_VIOLATION)) + if tier_no > 0: + print(f" WARNING: drift-run tuning relaxed to tier {tier_no + 1} " + f"(J-window violation={chosen['j_violation']:.3f})") + break + if chosen is None: + chosen = min(space, key=lambda c: abs(c["j_violation"] - J_WINDOW_TARGET_VIOLATION)) + print(f" WARNING: drift-run tuning fell back to closest " + f"violation_rate={chosen['j_violation']:.3f} (no candidate met the bands)") + return chosen + + +def active_rule(fp: str) -> dict | None: + status, body = http_json(f"{GATEWAY}/api/rules?status=active") + if status != 200 or not isinstance(body, dict): + return None + for rule in body.get("rules", []): + if rule.get("fingerprint_id") == fp: + return rule + return None + + +def syslog_view_count(version) -> int: + """Rows of the ACTIVE version's current view — the drift service's window + input; offset = count % 30 decides where this run's rows slice.""" + with psycopg.connect(DSN) as conn, conn.cursor() as cur: + return q1(cur, "SELECT count(*) FROM normalized_events " + "WHERE fingerprint_id=%s AND rule_version=%s " + "AND superseded_by_event_id IS NULL", (SYSLOG_FP, version)) + + +def audit_after(fp: str, action: str, *, actor: str | None = None, + after: datetime | None = None, want: dict | None = None) -> dict | None: + """Newest matching audit row, or None. `after` bounds ts strictly (delta + base: prior-run rows excluded); `want` matches detail key-values.""" + status, body = http_json(f"{GATEWAY}/api/audit?fingerprint={fp}&limit={AUDIT_PAGE}") + if status != 200 or not isinstance(body, dict): + return None + for row in body.get("audit", []): + detail = row.get("detail") or {} + if row.get("action") != action: + continue + if actor is not None and row.get("actor") != actor: + continue + if want and not all(detail.get(k) == v for k, v in want.items()): + continue + if after is not None and _parse_ts(row.get("ts")) <= after: + continue + return row + return None + + +def wait_parsed_event(after_ts: datetime, predicate, timeout_s: float) -> dict | None: + """Poll the newest 50 parsed syslog events until one satisfies + predicate(event, after_ts); None on timeout.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + status, body = http_json( + f"{GATEWAY}/api/events?fingerprint={SYSLOG_FP}&status=parsed&limit=50") + if status == 200 and isinstance(body, dict): + for ev in body.get("events") or []: + try: + if predicate(ev, after_ts): + return ev + except (KeyError, TypeError, ValueError): + continue + time.sleep(POLL_INTERVAL_S) + return None + + +def _j_event_ok(ev: dict, after_ts: datetime) -> bool: + """J's post-quarantine parse: the SRC value preserved under + ocsf.unmapped.SRC, src_endpoint.ip absent (mapping disabled).""" + ocsf = ev.get("ocsf") or {} + unmapped = ocsf.get("unmapped") or {} + src = ocsf.get("src_endpoint") or {} + return (_parse_ts(ev.get("parsed_at")) > after_ts + and unmapped.get("SRC") is not None + and "ip" not in src) + + +def _l_event_ok(ev: dict, after_ts: datetime) -> bool: + """L's post-un-quarantine parse: src_endpoint.ip mapped again, SRC no + longer dangling in unmapped.""" + ocsf = ev.get("ocsf") or {} + unmapped = ocsf.get("unmapped") or {} + src = ocsf.get("src_endpoint") or {} + return (_parse_ts(ev.get("parsed_at")) > after_ts + and isinstance(src.get("ip"), str) and src["ip"] != "" + and "SRC" not in unmapped) + + +def assert_i_tier1(run_start: datetime, plan: dict) -> None: + print("== Assert I: tier-1 fires (fresh syslog drift window with violations) ==") + sent_total = 0 + for i, run in enumerate(plan["runs"], 1): + sent = run_simulator(mode="firmware-drift", eps=run["eps"], + duration=run["duration"], drift_after=run["drift_after"], + loop=True, label=f"I drift run {i}/{len(plan['runs'])}") + sent_total += sent.get("syslog", 0) + deadline = time.monotonic() + DRIFT_I_TIMEOUT_S + hit, last = None, "no metrics response yet" + while time.monotonic() < deadline and hit is None: + status, body = http_json(f"{GATEWAY}/api/drift/metrics") + if status == 200 and isinstance(body, dict): + rows = body.get("metrics") or [] + last = f"{len(rows)} metric row(s), latest syslog window " \ + f"{next((r['window_start'] for r in rows if r['fingerprint_id'] == SYSLOG_FP), 'none')}" + for row in rows: + if (row.get("fingerprint_id") == SYSLOG_FP + and _SEVERITY_RANK.get(row.get("severity"), -1) >= _SEVERITY_RANK["minor"] + and (row.get("violation_rate") or 0) > 0 + and _parse_ts(row.get("window_start")) > run_start): + hit = row + break + if hit is None: + time.sleep(POLL_INTERVAL_S) + if hit is None: + check("I tier-1 fires", False, + f"no syslog metrics row with severity>=minor and violation_rate>0 " + f"within {DRIFT_I_TIMEOUT_S:.0f}s (last poll: {last})") + return + runs_desc = "; ".join(f"eps={r['eps']} drift-after={r['drift_after']} " + f"duration={r['duration']}s" for r in plan["runs"]) + check("I tier-1 fires", True, + f"syslog field {hit['field']!r} window {hit['window_start']}: " + f"severity={hit['severity']} violation_rate={hit['violation_rate']:.3f} " + f"null_rate={(hit['null_rate'] or 0):.3f} events_count={hit['events_count']} " + f"(tuned drift run(s): {runs_desc}; {sent_total} syslog lines sent; " + f"predicted J-window violation {plan['j_violation']:.3f})") + + +def _wait_rule_quarantined(timeout_s: float) -> dict | None: + """Poll GET /api/rules/syslog until the ACTIVE version's quarantined_fields + contains the SRC mapping's target.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + status, body = http_json(f"{GATEWAY}/api/rules/{SYSLOG_FP}") + if status == 200 and isinstance(body, dict): + for rule in body.get("rules") or []: + if rule.get("status") == "active": + if QUARANTINE_FIELD in (rule.get("quarantined_fields") or []): + return rule + break # active version exists, field not quarantined yet + time.sleep(POLL_INTERVAL_S) + return None + + +def assert_j_quarantine(run_start: datetime) -> None: + print("== Assert J: moderate drift quarantines src_endpoint.ip, value preserved ==") + quarantined = _wait_rule_quarantined(DRIFT_J_TIMEOUT_S) + if quarantined is None: + check("J quarantine + values preserved", False, + f"active syslog rule never listed {QUARANTINE_FIELD} in " + f"quarantined_fields within {DRIFT_J_TIMEOUT_S:.0f}s") + return + audit = audit_after(SYSLOG_FP, "field_quarantined", actor="drift", after=run_start) + if audit is None: + check("J quarantine + values preserved", False, + f"quarantined_fields lists {QUARANTINE_FIELD} but no fresh " + f"field_quarantined audit row with actor=drift") + return + quarantine_ts = _parse_ts(audit.get("ts")) + # The pipeline honors quarantine on the next rule refresh (<= 2 s); the + # margin plus a short steady probe supplies post-quarantine parses. + time.sleep(RULE_REFRESH_MARGIN_S) + run_simulator(label="J probe run (steady)", **PROBE_RUN) + event = wait_parsed_event(quarantine_ts, _j_event_ok, DRIFT_J_TIMEOUT_S) + if event is None: + check("J quarantine + values preserved", False, + f"no parsed syslog event after the quarantine audit ({audit.get('ts')}) " + f"carries unmapped.SRC with src_endpoint.ip absent") + return + ocsf = event["ocsf"] + check("J quarantine + values preserved", True, + f"quarantined_fields=['{QUARANTINE_FIELD}'] on v{quarantined['version']} " + f"(audit field_quarantined actor=drift at {audit.get('ts')}); parsed event " + f"{event['event_id']} @ {event['parsed_at']} preserves SRC value " + f"{ocsf['unmapped']['SRC']!r} under ocsf.unmapped.SRC with src_endpoint.ip absent") + + +def assert_k_severe(run_start: datetime, active: dict, pre_samples: int) -> None: + print("== Assert K: severe -> rule deactivated -> re-onboard -> human restore ==") + problems: list[str] = [] + parts: list[str] = [] + run_simulator(mode="firmware-drift", label="K-maker run (post-quarantine null flood)", + loop=True, **K_MAKER) + + deadline = time.monotonic() + DRIFT_K_TIMEOUT_S + absent = False + while time.monotonic() < deadline: + # Only a SUCCESSFUL listing may prove absence (active_rule_fingerprints + # degrades to an empty set on fetch errors — a hiccup must not + # false-pass this check). + status, body = http_json(f"{GATEWAY}/api/rules?status=active") + if status == 200 and isinstance(body, dict): + fps = {r.get("fingerprint_id") for r in body.get("rules") or []} + if SYSLOG_FP not in fps: + absent = True + break + time.sleep(POLL_INTERVAL_S) + if absent: + parts.append("syslog absent from /api/rules?status=active") + else: + problems.append("syslog never left the active rules within " + f"{DRIFT_K_TIMEOUT_S:.0f}s") + + audit = audit_after(SYSLOG_FP, "rule_deactivated", actor="drift", after=run_start, + want={"rule_id": active["id"]}) + if audit: + parts.append(f"audit rule_deactivated actor=drift rule_id={active['id']} " + f"({(audit.get('detail') or {}).get('window_start')})") + else: + problems.append("no fresh rule_deactivated audit row actor=drift " + f"rule_id={active['id']}") + + # Seed a few syslog lines NOW that the rule is provably down: the + # K-maker's own tail is a race (the simulator exits seconds before the + # deactivating scan, so its last rows may all parse while the rule is + # still active), but any line arriving after the deactivation is + # unparsed and captured as an onboarding sample — the re-entry signal. + run_simulator(label="K sample seeder (steady, rule down)", + eps="8", duration="4") + + samples = pre_samples + deadline = time.monotonic() + DRIFT_K_TIMEOUT_S + while time.monotonic() < deadline: + samples = sample_count(SYSLOG_FP) + if samples > pre_samples: + break + time.sleep(POLL_INTERVAL_S) + if samples > pre_samples: + parts.append(f"onboarding re-entry: sample_count {pre_samples}->{samples}") + else: + problems.append(f"sample_count never grew from {pre_samples}") + + # Freeze enforcement before the human restore: severe windows are + # permanent rows of the version's view and the loop re-evaluates the whole + # view every scan, so a live drift service would re-deactivate the + # restored rule within one poll (verified live). Drift restarts after the + # M3 block finishes restoring state. + compose("stop", "drift", timeout=120) + status, body = http_post_json(f"{GATEWAY}/api/rules/{active['id']}/reactivate", + {"actor": "smoke", "reason": "M3 smoke: human restore"}) + if status == 200 and isinstance(body, dict) and body.get("status") == "active": + parts.append(f"reactivate rule_id={active['id']} -> active (drift paused)") + else: + problems.append(f"reactivate rule_id={active['id']} -> {status}: {body!r}") + restored = active_rule(SYSLOG_FP) + if restored is not None and restored.get("id") == active["id"]: + parts.append(f"active again: id={restored['id']} v{restored['version']}") + else: + problems.append(f"active syslog rule after restore: {restored!r}") + reactivated = audit_after(SYSLOG_FP, "rule_reactivated", after=run_start, + want={"rule_id": active["id"]}) + if reactivated: + parts.append(f"audit rule_reactivated rule_id={active['id']}") + else: + problems.append("no fresh rule_reactivated audit row " + f"rule_id={active['id']}") + check("K severe -> deactivate -> re-onboard -> human restore", not problems, + "; ".join(parts) + ("" if not problems else + "; PROBLEMS: " + "; ".join(problems))) + + +def assert_l_unquarantine(run_start: datetime) -> None: + print("== Assert L: human un-quarantine re-enables the mapping ==") + status, body = http_post_json(f"{GATEWAY}/api/rules/{SYSLOG_FP}/unquarantine", + {"field": QUARANTINE_FIELD, "actor": "smoke"}) + if status != 200 or not isinstance(body, dict): + check("L human un-quarantine", False, + f"POST unquarantine -> {status}: {body!r}") + return + fields = body.get("quarantined_fields") + audit = audit_after(SYSLOG_FP, "field_unquarantined", after=run_start, + want={"field": QUARANTINE_FIELD}) + if fields != [] or audit is None: + check("L human un-quarantine", False, + f"quarantined_fields={fields!r} (want []), " + f"audit field_unquarantined={'present' if audit else 'MISSING'}") + return + unquarantine_ts = _parse_ts(audit.get("ts")) + # Clean lines flush the quarantined-parses out; once the pipeline has the + # un-quarantined rule (<= 2 s refresh), parses map src_endpoint.ip again. + run_simulator(label="L flush run (steady)", **FLUSH_RUN) + event = wait_parsed_event(unquarantine_ts, _l_event_ok, DRIFT_L_TIMEOUT_S) + if event is None: + check("L human un-quarantine", False, + f"no parsed syslog event after the un-quarantine ({audit.get('ts')}) " + f"maps src_endpoint.ip again") + return + src_ip = event["ocsf"]["src_endpoint"]["ip"] + check("L human un-quarantine", True, + f"POST unquarantine -> 200 quarantined_fields=[] (audit field_unquarantined " + f"actor=smoke at {audit.get('ts')}); next clean parse {event['event_id']} @ " + f"{event['parsed_at']} maps src_endpoint.ip={src_ip!r}") + + +def restore_syslog_rule(v1: dict) -> None: + """Version-bump dance (restoration, not an assert): severe/null windows are + permanent rows of v1's current view, so v1 can never again survive a drift + scan. Mint a successor version WITHOUT the SRC mapping (its candidate gate + is corruption-proof: corruption only ever touches SRC), approve it — the M2 + backlog sweep then re-parses and supersedes EVERY v1 row under the new + version, emptying v1's drift view — deactivate the successor, and + reactivate v1: active again, empty view, no severe findings possible. The + stats current view keeps every row parsed (B's precondition), and the next + smoke run's I-L block replays the whole cycle on v1.""" + print("== M3 restore: version-bump dance (clears v1's poisoned drift view) ==") + failures: list[str] = [] + lines = read_corpus_lines(SYSLOG_CORPUS) + report = validate_local_rule(SYSLOG_FP, SYSLOG_NO_SRC_RULE, lines) + if not report.passed: + failures.append(f"local gate: {report.checks}") + rule_id = version = None + if report.passed: + status, body = post_manual_candidate(SYSLOG_FP, SYSLOG_NO_SRC_RULE) + if status == 201 and isinstance(body, dict) and "rule_id" in body: + rule_id, version = body["rule_id"], body.get("version") + else: + failures.append(f"manual candidate -> {status}: {body!r}") + if rule_id is not None: + status, body = http_post_json( + f"{GATEWAY}/api/rules/{SYSLOG_FP}/candidates/{rule_id}/approve", + {"actor": "smoke", "reason": "M3 smoke restore"}) + if status != 200: + failures.append(f"approve v{version} -> {status}: {body!r}") + if not failures and version is not None: + reparse = wait_reparse_complete(SYSLOG_FP, version, REPARSE_TIMEOUT_S) + if reparse is None: + failures.append(f"no reparse_complete v{version} within {REPARSE_TIMEOUT_S:.0f}s") + else: + print(f" sweep v{version}: inserted={reparse.get('inserted')} " + f"superseded={reparse.get('superseded')} (v1 drift view now empty)") + if not failures: + status, body = http_post_json(f"{GATEWAY}/api/rules/{SYSLOG_FP}/deactivate", + {"actor": "smoke", "reason": "M3 smoke restore"}) + if status != 200: + failures.append(f"deactivate successor -> {status}: {body!r}") + if not failures: + status, body = http_post_json(f"{GATEWAY}/api/rules/{v1['id']}/reactivate", + {"actor": "smoke", "reason": "M3 smoke restore"}) + if status != 200: + failures.append(f"reactivate v1 id={v1['id']} -> {status}: {body!r}") + started = compose("start", "drift", timeout=120) + if started.returncode != 0: + failures.append(f"compose start drift failed: {started.stderr.strip()[:120]}") + if failures: + sys.exit("FATAL: M3 restoration dance failed: " + "; ".join(failures)) + left = syslog_view_count(v1["version"]) + print(f" restore complete: successor v{version} deactivated (holds the superseded " + f"backlog), seed rule id={v1['id']} active with a {left}-row drift view; " + f"drift service restarted") + + def wait_quiet(window_s: float = 4.0, timeout_s: float = 45.0) -> int: """Wait until raw_events stops moving, then return its count. @@ -949,9 +1624,54 @@ def wait_quiet(window_s: float = 4.0, timeout_s: float = 45.0) -> int: def main() -> int: - print(f"ULPF smoke (M1 A-E + M2 F-H) - {time.strftime('%Y-%m-%d %H:%M:%S')}") + # M3 demo knobs (Task 12 Step 1) — exported BEFORE compose up so the drift + # container is created with demo-scale windows (30 rows; compose + # passthrough verified in Task 4). The window count is load-bearing for + # the drift-run tuning below. + os.environ["ULPF_DRIFT_WINDOW_COUNT"] = DRIFT_WINDOW_COUNT + os.environ["ULPF_DRIFT_WINDOW_TIME_S"] = DRIFT_WINDOW_TIME_S + # Sample-capture headroom (observed live in the Task 12 gate): the + # accumulating DB saturates the 200/fingerprint cap, after which a + # drift-deactivated fingerprint cannot accumulate onboarding samples and + # K's re-entry signal (sample_count grows) is unobservable. 2000 leaves + # headroom for many accumulating runs; the code default stays 200. + os.environ["ULPF_SAMPLE_CAPTURE_CAP"] = "2000" + print(f"ULPF smoke (M1 A-E + M2 F-H + M3 I-L) - {time.strftime('%Y-%m-%d %H:%M:%S')}") step1_up() + # M3 block FIRST (Task 12 Steps 2-5): the drift demo needs the syslog + # baseline to arm DURING the firmware-drift run (the establishing 10th + # window must itself be the moderate one — see the module header), so it + # runs before the A-H steady traffic can pre-arm a clean baseline. + run_start = datetime.now(UTC) + active = active_rule(SYSLOG_FP) + if active is None: + sys.exit("FATAL: no active syslog rule at the M3 block start") + corpus_lines = len(read_corpus_lines(SYSLOG_CORPUS)) + offset = syslog_view_count(active["version"]) % 30 + plan = tune_drift_run(offset, corpus_lines) + runs_desc = " then ".join( + f"eps={r['eps']}/after={r['drift_after']}/dur={r['duration']}s" for r in plan["runs"]) + print(f" syslog seed rule id={active['id']} v{active['version']}; drift view " + f"offset={offset} -> tuned drift run(s): {runs_desc} " + f"(predicted J-window violation {plan['j_violation']:.3f} " + f"({plan['j_nulls']} nulls), establishing window " + f"{plan['est_violation']:.3f})") + pre_samples = sample_count(SYSLOG_FP) + + assert_i_tier1(run_start, plan) + assert_j_quarantine(run_start) + assert_k_severe(run_start, active, pre_samples) + assert_l_unquarantine(run_start) + restore_syslog_rule(active) + + if M3_ONLY: + failed = [name for name, ok, _ in results if not ok] + print(f"\nSMOKE RESULT (M3 I-L only): {'PASS' if not failed else 'FAIL'} " + f"({len(results) - len(failed)}/{len(results)} checks)" + + (f" failed: {', '.join(failed)}" if failed else "")) + return 0 if not failed else 1 + print("== Snapshot before (delta base) ==") before_raw = wait_quiet() before_norm = db_count("SELECT count(*) FROM normalized_events") @@ -959,7 +1679,7 @@ def main() -> int: print(f" raw_events={before_raw} normalized_events={before_norm} " f"fingerprints={len(before_fps)}") - sent = run_simulator() + sent = run_simulator(label="Step 2 (steady)") print(" " + ", ".join(f"{name}={n}" for name, n in sorted(sent.items()))) drained, raw_delta, norm_delta = wait_drain(before_raw, before_norm, sent["total"]) From f5c9b72a6a905e53cbd1e76e6fee59ed30a0cb98 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 18:02:50 +0530 Subject: [PATCH 19/23] docs: m3 drift ruling record R-M3-1 (DB current-view polling, rejected alternatives, R17 topic note), the stateless window model, migration 007's grant matrix verbatim, the two-tier detection model, quarantine new-parses-only semantics, audit vocabulary additions, per-source volume deferred to M4, and the measured live findings from the Task 12 exit gate (enforce audit serialization, the accumulating-DB window-horizon and sample-cap findings, the reactivation-needs-a-version-bump recovery note). Co-Authored-By: Claude Code --- docs/m3-drift.md | 243 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/m3-drift.md diff --git a/docs/m3-drift.md b/docs/m3-drift.md new file mode 100644 index 0000000..c76473d --- /dev/null +++ b/docs/m3-drift.md @@ -0,0 +1,243 @@ +# M3 drift detection — ruling record + +How firmware drift on a live rule is detected, graduated, and recovered, and +why the pieces look the way they do. Migrating decisions from the M3 plan +brainstorm (2026-09-21, rulings R-M3-1..11) are recorded here because they +bind later milestones. Numbers below are measured on the live compose stack +(Task 12 exit gate) unless marked as a default. + +## R-M3-1 — the drift service reads the DB current view, not the topic + +One convergent loop (`services/drift/app.py`, cold path, Postgres only — no +Kafka consumer) polls the active rules and statelessly recomputes each +`(fingerprint_id, rule_version)`'s windows from +`normalized_events WHERE superseded_by_event_id IS NULL AND rule_version = +` (the `ne_current_idx` semantics). + +Why the DB and not `normalized.events`: + +- **Correct by construction.** The topic is at-least-once and the M2 reparse + sweep writes DB rows only — it emits no envelopes (M2 ledger R17). A topic + consumer would need dedupe machinery AND would still miss backlog + re-parses, i.e. it would be wrong in exactly the cases drift exists for. +- **Single replica needs no fencing.** Window math keyed by + `(fingerprint_id, rule_version, field, window_start)` is idempotent under + re-scan (`ON CONFLICT DO NOTHING`); a consumer group adds offsets, commits, + and rebalance races that a single cold-path reader has no use for. +- **Fail-closed posture.** A failed scan is logged and retried next poll + (`ULPF_DRIFT_POLL_S`, default 5 s); drift can never block the hot path. + +Rejected alternatives: **hybrid topic + DB** (two feeds to merge, ~1.5 tasks +more, no correctness gain at MVP scale — the DB already holds the topic's +information plus the reparse rows); **topic-only** (contradicts the R17 +ledger ruling — misses reparse rows). + +**R17 topic note:** `normalized.events` remains produced by the pipeline in +M3 exactly as in M2 — all-status envelopes keyed by `fingerprint_id`, +at-least-once, dedupable by `(raw_id, rule_version)` — but nothing consumes +it in the MVP. It is the documented future streaming path: when drift (or any +other consumer) needs multi-replica scale-out, §7.1's per-partition ownership +applies and the DB poll becomes a topic read plus the same window math. + +## R-M3-2 — stateless windows, INSERT-only + +Each scan slices the version's current view (ordered by `parsed_at`) into +windows that close on **count** (`ULPF_DRIFT_WINDOW_COUNT`, default 1000 +rows) **or time** (`ULPF_DRIFT_WINDOW_TIME_S`, default 600 s), whichever +first; the trailing partial is held back (it may still grow). `window_start` +is the `parsed_at` of the window's first row, so the primary key +`(fingerprint_id, rule_version, field, window_start)` is deterministic from +data: re-scans INSERT nothing new (`ON CONFLICT DO NOTHING`), and no cursor +table or UPDATE grant on drift tables exists. The acceptance smoke runs the +demo with `ULPF_DRIFT_WINDOW_COUNT=30` plus a time horizon no demo gap can +cross (count-close only — deterministic 30-row boundaries the smoke's tuning +replica models exactly). The brief's original 15 s time horizon was measured +unsound on the accumulating DB: it slices the days-spread M1/M2 history into +~255 one-row windows at the first scan, arming an all-zero baseline from ten +of them, after which the tier-2 null ladder deactivates the rule at any 5% +null rate — before the moderate quarantine (Assert J) can ever land +(observed live; recorded in `scripts/smoke.py`'s header). + +Windows are recomputed from the whole current view every scan — including +already-recorded ones, whose findings are re-evaluated. That is what makes +enforcement convergent after a baseline arms (the establishing scan is +tier-1-only by construction: the profile row is inserted only after that +scan's windows are recorded, so its findings predate the profile), and it is +also why a severe window is permanent for its version: recovery is a version +bump, not a wait (see the restoration note under R-M3-11). + +## R-M3-3/R-M3-11 — the grant matrix is the design; enforcement is graduated + +`deploy/migrations/007_drift_tables.sql` (natural PKs only — no serials, so +the 004/005/006 sequence-grant bug class is structurally impossible; the only +serial-backed INSERT drift makes is `audit_log`, whose sequence is granted): + +```sql +-- deploy/migrations/007_drift_tables.sql — M3 drift detection (R-M3-2/3). +-- Natural PKs ONLY (no serials): the 004/005/006 sequence-grant bug class is +-- structurally impossible here. Drift writes are INSERT-only. +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='drift_role') THEN + CREATE ROLE drift_role LOGIN PASSWORD 'drift_dev'; END IF; +END $$; + +CREATE TABLE drift_windows ( + fingerprint_id TEXT NOT NULL, + rule_version INT NOT NULL, + field TEXT NOT NULL, -- OCSF path, 'unmapped.', or '__rule__' sentinel + window_start TIMESTAMPTZ NOT NULL, -- parsed_at of the window's first event (deterministic key) + window_end TIMESTAMPTZ NOT NULL, + events_count INT NOT NULL, + null_rate REAL, + match_rate REAL, -- '__rule__' rows: parsed / (parsed + parse_error) + violation_rate REAL, -- tier-1 invariant violations for this field + shape_dist JSONB, -- {"ipv4": 12, "int": 3, ...} over non-null scalar values + severity TEXT NOT NULL CHECK (severity IN ('none','minor','moderate','severe')), + action_taken TEXT CHECK (action_taken IS NULL OR action_taken IN + ('alert','field_quarantined','rule_deactivated')), + scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (fingerprint_id, rule_version, field, window_start) +); +CREATE INDEX drift_windows_recent ON drift_windows (fingerprint_id, window_start DESC); + +CREATE TABLE baseline_profiles ( + fingerprint_id TEXT NOT NULL, + rule_version INT NOT NULL, + field TEXT NOT NULL, + profile JSONB NOT NULL, -- {events_count, null_rate, match_rate, shape_dist} + windows_seen INT NOT NULL, + established_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (fingerprint_id, rule_version, field) +); + +-- drift service (detection + enforcement, R-M3-3) +GRANT SELECT ON normalized_events, rules TO drift_role; +GRANT INSERT ON drift_windows, baseline_profiles TO drift_role; +-- P-2: drift_role self-reads — baseline existence checks + recent-window lookups. +GRANT SELECT ON drift_windows, baseline_profiles TO drift_role; +GRANT UPDATE (quarantined_fields) ON rules TO drift_role; +GRANT UPDATE (status, deactivated_at) ON rules TO drift_role; +GRANT INSERT ON audit_log TO drift_role; +GRANT USAGE, SELECT ON SEQUENCE audit_log_id_seq TO drift_role; + +-- gateway: human override of quarantine (audited, rules_role write path) +GRANT UPDATE (quarantined_fields) ON rules TO rules_role; +-- gateway SELECT on both new tables arrives via 003's ALTER DEFAULT PRIVILEGES. +``` + +drift_role never INSERTs rules; rule content stays immutable for every role. +Every enforcement mutation carries its expected-state predicate in the WHERE +(`status = 'active'`, `NOT (field = ANY(quarantined_fields))`) with a +rowcount check — a no-op returns False and writes NO audit row — and the +audit INSERT is paired inside the same transaction, actor `drift`. + +Graduated enforcement per (field, window), severity = max(tier-1, tier-2 +signals), enforced worst-first with one deactivate per fingerprint per scan: + +- `minor` → alert only (the `drift_windows` row IS the alert). +- `moderate` → `field_quarantined` (idempotent append to + `rules.quarantined_fields`). +- `severe` → `rule_deactivated`; the fingerprint reverts to raw-only and + re-enters onboarding through the M2 `_READY_SQL` path (samples re-accumulate + — zero onboarding changes needed). +- Quarantined fields are still measured and still escalate: quarantine + suppresses only a redundant re-quarantine, never a finding — a quarantined + field going severe still deactivates the rule. +- `unmapped.*` rows are new-key-emergence signals only: alert-only, + `decide_action` bypassed (binding Task-4 review ruling). + +Humans override any state: `POST /api/rules/{fp}/unquarantine` (rules_role, +audited, `field_unquarantined`) and the M2 +reactivate/deactivate endpoints. Restoration note (measured live in the Task +12 smoke): a reactivated version whose current view still contains severe +windows is re-deactivated by the next scan — recovery from a real drift event +is a **version bump** (approve a successor rule; the M2 backlog sweep +supersedes the poisoned version's rows out of its drift view), which is the +same path a human edit takes. + +## R-M3-4/R-M3-5 — two detection tiers; the math is pure + +**Tier 1 (from event zero, always on):** invariant-violation rates over the +stored OCSF docs — `src/dst_endpoint.ip` IPv4 shape, ports int 0..65535, +`severity_id` int 0..6, `time` ISO-parsable — mirroring the candidate gate's +`_value_sanity` semantics at drift time (deliberately not an import: +candidate-time vs drift-time). Ladder: `<0.05 none, <0.20 minor, <0.50 +moderate, else severe`. + +**Tier 2 (armed once a baseline exists per `(fingerprint, version)`, +R-M3-7):** the first `ULPF_DRIFT_BASELINE_WINDOWS` (default 10) closed +windows aggregate into `baseline_profiles` (mean null_rate, mean match_rate, +min/max events_count, shape_dist summed and normalized); the profile row is +INSERTed once when the Nth window closes and never re-arms for that version — +a version bump re-arms naturally. Signals: null-rate ratio ladder (the legacy +demo's proven 3x/10x/40x + 0.05-absolute gate), match-rate absolute-drop +ladder (0.10/0.25/0.50), JS-divergence shape ladder (0.15/0.35/0.60, +add-1-smoothed, count-scaled), per-fingerprint volume bounds (silence severe, +outside minor). All of it lives in `libs/ulpf-core/ulpf_core/drift.py` — +stdlib-only, zero deps, fully unit-tested (Task 3: 37 tests; the drift +service suites add 102). + +## R-M3-6 — quarantine is honored at parse time, new parses only + +`ActiveRule` carries `quarantined_fields`; the pipeline worker builds a +parse-time rule copy with the quarantined mappings removed, so the captured +key lands in `ocsf.unmapped` automatically (spec §7.2 "mapping disabled, +values preserved" — no `parse()` API change). Measured in the Task 12 smoke: +after drift quarantined `src_endpoint.ip`, subsequent parses carried the SRC +value under `ocsf.unmapped.SRC` with `src_endpoint.ip` absent; after the human +un-quarantine, the next clean parses mapped `src_endpoint.ip` again. + +Quarantine affects NEW parses only. The existing backlog is NOT auto +re-parsed — a deliberate scope line: a backlog re-parse requires a version +bump (a human edit/approve rides the M2 sweep, which re-parses stale rows and +supersedes the old version's view). Automatic backlog re-parse would need +either an onboarding-side quarantine awareness (re-parse honoring the +filter — not built, see "Deferred") or churn on every quarantine toggle. + +## R-M3-9 — the demo: deterministic escalating firmware drift + +`simulator/simulate.py --mode firmware-drift --drift-after S --duration D` +keeps the steady corpus set but corrupts syslog lines on the wire after the +deadline: a line is corrupted iff `(index * 2654435761 % 1000)/1000 < +phase`, `phase = clamp(elapsed_since_drift/span, 0, 1)`, +`span = max(duration - drift_after, 1.0)`; among corrupted lines `index % 3 +== 0` renames `SRC` -> `SRCADDR` (a null at parse time) and the rest write +`SRC=uplink-trust-0x4f` (a tier-1 IP violation) — exactly 1:2 nulls to +violations. The corruption is deterministic (the seed IS the corpus line +index; no RNG state), which is what lets the acceptance smoke replicate the +math host-side and pre-tune each run (see `tune_drift_run` in +`scripts/smoke.py`). Only line content mutates — per-corpus counts, the other +corpora, and the ` sent=` stdout contract are unchanged. + +## Audit vocabulary additions + +`action` gains `field_quarantined` and `field_unquarantined`; `rule_deactivated` +is reused with actor `drift` (enforcement) vs a human actor (gateway). The +closed set is now {`samples_split`, `candidate_created`, `candidate_failed`, +`rule_approved`, `rule_rejected`, `rule_deactivated`, `rule_reactivated`, +`field_quarantined`, `field_unquarantined`, `reparse_complete`}; entity = +fingerprint_id. Drift-side rows carry actor `drift` and detail +`{field, rule_id, version, window_start}` (quarantine) / `{rule_id, version, +window_start}` (deactivate). + +## R-M3-10 — per-SOURCE volume deferred to M4 + +Per-fingerprint volume bounds ship now (`__rule__` sentinel rows carry +events_count min/max from the baseline; silence is severe — a dead feed never +looks healthy). Per-SOURCE volume needs a `raw_events` join +(`normalized_events` carries no `source_id`); deferred to M4 as polish — the +current-view granularity is what the spec's silence/flood signal needs at MVP +scale. + +## Deferred, with numbers + +- **Per-source volume** — see R-M3-10 above. +- **Re-parse honoring quarantine** — the M2 sweep re-parses under the rule's + full mappings (it rebuilds the Rule without the quarantine filter), so a + quarantined field's backlog re-parse maps it again. Measured consequence + (Task 12): after K's deactivation window, the sweep's re-parsed rows + re-introduce the violations at the new version. Bounded by the same + version-bump recovery path above; not worth onboarding-side quarantine + plumbing in M3. +- **Auth** — actor is a request field defaulting to "anonymous" (MVP has no + auth); every mutation is audited regardless. From 4bde72499bac67a2ad61167dd25defc4a406cfbd Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 18:02:50 +0530 Subject: [PATCH 20/23] ci: install drift service requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The python job installs every service's requirements except drift's; its psycopg pin is identical to gateway's today, but the suite collects services/drift tests, so a future drift dep change must reach CI through this line rather than by accident. pyarrow==18.1.0 rides gateway's requirements (py3.12 manylinux wheel) — documented in the workflow header. No test-count pin exists in the file (plain pytest). Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10932e9..d794251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,9 @@ -# M1+M2 CI. Unit tests only — no container services; the live E2E gate +# M1+M2+M3 CI. Unit tests only — no container services; the live E2E gate # is `python scripts/smoke.py`, run locally against the compose stack. +# Service deps install per-service (drift joined in M3; its psycopg pin is +# identical to gateway's — the line exists so a future drift dep change +# reaches CI with the suite that imports it). pyarrow==18.1.0 rides +# services/gateway/requirements.txt into CI (py3.12 manylinux wheel). name: ci on: [push, pull_request] @@ -19,6 +23,7 @@ jobs: -r services/pipeline/requirements.txt \ -r services/gateway/requirements.txt \ -r services/onboarding/requirements.txt \ + -r services/drift/requirements.txt \ -r simulator/requirements.txt pip install pytest~=8.4 ruff==0.16.8 - name: Lint From 58576f88bd463bb838ac8a804d5711b4969d7983 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 19:58:36 +0530 Subject: [PATCH 21/23] test(smoke): drain the collector producer after each simulator run Live Task 12 finding: under this box's load the collector's librdkafka producer queue delayed messages by many minutes and retry-duplicated posted corpora, breaking K's sample poll and G/H's per-run deltas on otherwise-green runs. A graceful collector restart runs the app's shutdown flush (uvicorn SIGTERM -> lifespan -> producer.flush), bounding delivery to seconds. The collector is a stateless forwarder; the restart is benign. Co-Authored-By: Claude Code --- scripts/smoke.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/smoke.py b/scripts/smoke.py index 4b134b6..a246487 100755 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -435,6 +435,14 @@ def run_simulator(*, mode: str = "steady", eps: str | None = None, missing = {"acmegw", "cef", "json", "syslog"} - set(sent) if missing or corpora_total != sent["total"]: sys.exit(f"FATAL: simulator stdout contract broken: {sent} (missing={missing})") + # Deterministic delivery (live Task 12 finding): the collector's producer + # rides librdkafka defaults, and under this box's load its queue delayed + # messages by many minutes (and retry-duplicated corpora) — breaking K's + # sample poll and G/H's deltas on otherwise-green runs. A graceful + # collector restart runs the app's shutdown flush (uvicorn SIGTERM -> + # lifespan -> producer.flush), draining the queue in seconds. The + # collector is a stateless forwarder; the restart is benign. + compose("restart", "collector", timeout=120) return sent From 73f809a43a0a803187fb49bc19329abf885fb413 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 21:10:18 +0530 Subject: [PATCH 22/23] =?UTF-8?q?docs+test:=20final-review=20fixes=20?= =?UTF-8?q?=E2=80=94=20silence/moderate-window=20honesty,=20007=20pin,=20d?= =?UTF-8?q?ocstring=20number=20(M3=20final=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- docs/m3-drift.md | 32 +++++++--- services/drift/detect.py | 2 +- .../drift/tests/test_migration_007_pin.py | 61 +++++++++++++++++++ web/src/DriftHealth.tsx | 1 + 4 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 services/drift/tests/test_migration_007_pin.py diff --git a/docs/m3-drift.md b/docs/m3-drift.md index c76473d..17226a8 100644 --- a/docs/m3-drift.md +++ b/docs/m3-drift.md @@ -153,7 +153,13 @@ reactivate/deactivate endpoints. Restoration note (measured live in the Task windows is re-deactivated by the next scan — recovery from a real drift event is a **version bump** (approve a successor rule; the M2 backlog sweep supersedes the poisoned version's rows out of its drift view), which is the -same path a human edit takes. +same path a human edit takes. The moderate rung re-arms the same way: +un-quarantining a field whose historical window still evaluates moderate is +undone by the next scan — redundant-quarantine suppression holds only while +the field is IN `quarantined_fields`, so removal re-arms it (findings are +re-evaluated every scan, R-M3-2). An un-quarantine sticks durably only once +the offending window is superseded (the same version bump) or the version's +current view evaluates clean. ## R-M3-4/R-M3-5 — two detection tiers; the math is pure @@ -173,7 +179,8 @@ a version bump re-arms naturally. Signals: null-rate ratio ladder (the legacy demo's proven 3x/10x/40x + 0.05-absolute gate), match-rate absolute-drop ladder (0.10/0.25/0.50), JS-divergence shape ladder (0.15/0.35/0.60, add-1-smoothed, count-scaled), per-fingerprint volume bounds (silence severe, -outside minor). All of it lives in `libs/ulpf-core/ulpf_core/drift.py` — +outside minor — the silence rung's reachability is qualified in R-M3-10). All +of it lives in `libs/ulpf-core/ulpf_core/drift.py` — stdlib-only, zero deps, fully unit-tested (Task 3: 37 tests; the drift service suites add 102). @@ -223,11 +230,22 @@ window_start}` (deactivate). ## R-M3-10 — per-SOURCE volume deferred to M4 Per-fingerprint volume bounds ship now (`__rule__` sentinel rows carry -events_count min/max from the baseline; silence is severe — a dead feed never -looks healthy). Per-SOURCE volume needs a `raw_events` join -(`normalized_events` carries no `source_id`); deferred to M4 as polish — the -current-view granularity is what the spec's silence/flood signal needs at MVP -scale. +events_count min/max from the baseline; the ladder maps a zero-count window +to severe — with a reachability caveat, directly below). Per-SOURCE volume +needs a `raw_events` join (`normalized_events` carries no `source_id`); +deferred to M4 as polish — the current-view granularity is what the spec's +silence/flood signal needs at MVP scale. + +Silence honesty note: the severe-on-zero rung cannot fire end-to-end as +shipped. `compute_windows` (`services/drift/windows.py`) closes only +non-empty windows, so a dead feed produces no new rows — no new windows, no +`__rule__` finding, nothing to evaluate; `volume_severity`'s +`count == 0 → severe` branch (`libs/ulpf-core/ulpf_core/drift.py`) is +defensive-only, and the drought rung fires only retroactively, once traffic +resumes and a below-bounds window closes. A wall-clock last-window-age check +is the M4 path — deliberately excluded here by R-M3-2's data-only determinism +(windows derive from the rows' own `parsed_at`, never the clock). Until M4, +the Overview page's Events / min card is the only live silence visibility. ## Deferred, with numbers diff --git a/services/drift/detect.py b/services/drift/detect.py index 4ce4d48..5aaef01 100644 --- a/services/drift/detect.py +++ b/services/drift/detect.py @@ -289,7 +289,7 @@ def _window_scale(baseline_shape: dict, hist: dict) -> dict: window's observed-value count. R-M3-5's js_divergence add-1 smoothing is calibrated for COUNT-scale histograms (T3 pins 'grows toward ln2 as counts grow'); feeding raw proportions would let the +1s dominate the baseline - side and cap every shape comparison at ~0.32, leaving the severe band + side and cap every shape comparison at ~0.057, leaving the severe band unreachable. Rescaling puts both sides at the same count scale, where the smoothing is negligible and the ladder bands mean what T3 calibrated.""" total = sum(hist.values()) diff --git a/services/drift/tests/test_migration_007_pin.py b/services/drift/tests/test_migration_007_pin.py new file mode 100644 index 0000000..569bc84 --- /dev/null +++ b/services/drift/tests/test_migration_007_pin.py @@ -0,0 +1,61 @@ +"""services/drift/tests/test_migration_007_pin.py — pins the FILE +deploy/migrations/007_drift_tables.sql, not service behavior (the M1 +migration-pin style: the SQL file is the contract, so any edit to its grants +or keys must consciously update this test too). + +Pinned, per R-M3-3's "the grant matrix is the design": (a) both column-level +GRANT UPDATE (quarantined_fields) lines — drift enforcement and the gateway's +human override; (b) natural composite PKs on both tables (R-M3-2's +INSERT-only, ON CONFLICT DO NOTHING idempotence); (c) no SERIAL-family token +anywhere in the SQL — the 004/005/006 sequence-grant bug class stays +structurally impossible, the one allowed serial context being the audit_log +sequence grant line (pinned verbatim below). +""" + +import re +from pathlib import Path + +# Repo root regardless of CWD: tests/ -> drift/ -> services/ -> root. +MIGRATION = ( + Path(__file__).resolve().parents[3] / "deploy" / "migrations" / "007_drift_tables.sql" +) + +# The only sequence context drift legitimately touches: audit_log's serial id +# backing sequence, granted to drift_role. +AUDIT_SEQUENCE_GRANT = "GRANT USAGE, SELECT ON SEQUENCE audit_log_id_seq TO drift_role;" + + +def test_migration_007_grants_keys_and_no_serials_pinned(): + """One pin over the whole file (a)/(b)/(c): + + (a) both column-level UPDATE grants on rules.quarantined_fields exist + verbatim — drift_role enforces (R-M3-3), rules_role is the gateway's + un-quarantine write path; losing either surfaces as a live + permissions error, not a test failure; + (b) both CREATE TABLE statements key on natural composite PKs derived + from data (R-M3-2) — a surrogate key would need a sequence grant and + break ON CONFLICT DO NOTHING re-scan idempotence; + (c) a case-insensitive serial grep over the file's SQL stays empty — + comment prose is not SQL (the header's '(no serials)' is stripped + before matching), and any surviving SERIAL/SMALLSERIAL/BIGSERIAL + token fails unless it sits on the pinned audit_log sequence grant + line, the 004/005/006 bug class locked out by construction. + """ + sql = MIGRATION.read_text(encoding="utf-8") + + # (a) the grant matrix + assert "GRANT UPDATE (quarantined_fields) ON rules TO drift_role;" in sql + assert "GRANT UPDATE (quarantined_fields) ON rules TO rules_role;" in sql + + # (b) natural composite PKs, verbatim clauses + assert "PRIMARY KEY (fingerprint_id, rule_version, field, window_start)" in sql + assert "PRIMARY KEY (fingerprint_id, rule_version, field)" in sql + + # (c) no serial tokens in SQL outside the audit sequence grant line + code = "\n".join(line.split("--", 1)[0] for line in sql.splitlines()) + offenders = [ + line.strip() + for line in code.splitlines() + if re.search(r"(?i)serial", line) and line.strip() != AUDIT_SEQUENCE_GRANT + ] + assert offenders == [] diff --git a/web/src/DriftHealth.tsx b/web/src/DriftHealth.tsx index bf076e8..afb7e15 100644 --- a/web/src/DriftHealth.tsx +++ b/web/src/DriftHealth.tsx @@ -137,6 +137,7 @@ function FieldHealthRow({ className="unquarantine" disabled={busy} onClick={unquarantine} + title="Drift may re-quarantine this field while the offending window remains in view" > Un-quarantine From 7169b494417cdb118cdf110c10085a15ae88eda0 Mon Sep 17 00:00:00 2001 From: Karthik Sethu Raman Date: Tue, 29 Sep 2026 21:33:06 +0530 Subject: [PATCH 23/23] =?UTF-8?q?docs(drift):=20=5Fwindow=5Fscale=20cap=20?= =?UTF-8?q?figure=20re-verified=20by=20execution=20=E2=80=94=20~0.32=20res?= =?UTF-8?q?tored=20with=20measured=20convergence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final-review fix wave replaced ~0.32 with ~0.057 on the strength of two reviewer derivations. The re-review challenged it; executed against the real js_divergence: the described pre-rescale call (count-scale hist vs stored proportions) caps 0.2404@N=20 -> 0.3178@N=10k (~0.32); 0.0566 is the cap of a props-vs-props shape detect.py never makes. Rescale rationale unchanged (the actual rescaled call reaches 0.6853 ~ ln2, full ladder range). Co-Authored-By: Claude Code --- services/drift/detect.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/drift/detect.py b/services/drift/detect.py index 5aaef01..05bbc24 100644 --- a/services/drift/detect.py +++ b/services/drift/detect.py @@ -289,7 +289,9 @@ def _window_scale(baseline_shape: dict, hist: dict) -> dict: window's observed-value count. R-M3-5's js_divergence add-1 smoothing is calibrated for COUNT-scale histograms (T3 pins 'grows toward ln2 as counts grow'); feeding raw proportions would let the +1s dominate the baseline - side and cap every shape comparison at ~0.057, leaving the severe band + side and cap every shape comparison at ~0.32 (verified: full flip + converges 0.2404@N=20 -> 0.3178@N=10k; the ~0.057 figure is the cap of a + props-vs-props shape that never occurs), leaving the severe band unreachable. Rescaling puts both sides at the same count scale, where the smoothing is negligible and the ladder bands mean what T3 calibrated.""" total = sum(hist.values())