Skip to content

v0.6.0

Latest

Choose a tag to compare

@supermhel supermhel released this 27 Aug 21:59
· 58 commits to main since this release

First tagged release since v0.5.0 (2026-07-23) — 310 commits' worth of shipped,
tested work that had never been cut into a version. Closes the gap SSOT.md's own
review flagged: everything below was already proven/live-verified on main, just
never given a release boundary. See SSOT.md §1 for the full narrative account of
each dated round; this section is the changelog-format summary.

Fixed (2026-08-27, gap-hunt remediation: WS-1/WS-3/WS-4/WS-5/WS-6/WS-8/shared/tools + container smoke)

Second-pass closure of the 2026-08-26 consolidated gap-hunt report (256 findings)
plus a fresh 2026-08-27 hunt's 34 follow-on findings, all verified by execution and
a green run_all_tests.sh:

  • WS-1: /health now reports 503 on a bus outage (was hardcoded 200 from an
    empty handler map); ingest-edge metrics flattened so /metrics/prom emits real
    gauges; spool escapes U+2028/U+2029/U+0085 (an event could silently split at
    drain); corrupt-line counter now actually increments; os_error/produce-fail
    logging throttled + backoff; monotonic silence clock; empty datagram = its own
    counter; spool rewrite fsync'd for durability.
  • WS-2: malformed NORMALIZED_EVENTS_DEPTH_WARN degrades instead of killing
    the daemon; unmapped list sanitization now recurses; parser comment + doc
    order corrected.
  • WS-3/contracts: OpenSearch read-side 5xx propagation; tenant default-filters
    for list_alerts/events/incidents; reporting day-rollover; audit warn/lock/
    recent(0); report body reject; CAS on all write branches; webhooks non-yaml
    fail-closed; entity_value_full mapped; nis2 envelope/schema aligned;
    triage-api report params + CSRF note; inventory-api auth documented.
  • WS-4/5/8: torn-read-safe reload; siem:null poison-pill; event_ids
    truncation cap (see the code-review remediation entry below for its final
    shape); LLM SSRF/response/truncation hardening tests; plugin-pack
    hot-reload; ai_enqueued counts LLM-only; correlator flat Prometheus
    skip-reasons, attacker-time validation, deterministic anon member id,
    oldest-by-time eviction; lost new-device alerts; inventory auth latch; bounded
    auth-fail map.
  • shared: MemoryBus PEL in-flight eviction (at-least-once); session expiry;
    sanitize C1/U+202E; outbound_http safe_urlopen SSRF + pinned opener; scrypt
    n ceiling; log reserved-key + always().
  • tools/eval/CI: check_test_wiring gate; coverage_gate TARGETS derived from
    the runner; fire_check untested rules reported; many live benches wired;
    container-smoke CI job across all 8 services.

Fixed (2026-08-27, code-review remediation: PR#74)

An independent code review of the branch above (engineering:code-review, model
Opus 5) found 12 issues in the gap-hunt remediation itself, 2 of them reproduced
crashes in newly-added error-handling code. All 12 fixed, re-verified by execution
against a live 15-container docker compose stack driven in-browser (all 8
dashboard tabs, real pipeline data), plus a green run_all_tests.sh + clean
mypy/ruff on every touched workstream:

  • Critical — reproduced crashes, both on error paths: triage_api.py's
    do_GET/do_POST called Logger.exception(), a method shared.log.Logger
    does not have (stdlib-logging-only) — an unhandled handler error raised
    AttributeError before the 500 response was ever sent, dropping the
    connection instead of returning it. opensearch.py's _log_rw_warn called
    .warning() with positional %s args against a **fields-only signature —
    hit on every non-index-missing HTTPError from count/_search_alert/
    find_report/_list, i.e. exactly the 5xx/circuit-breaker case the read-side
    error propagation fix (above) was added to surface. Both fixed with the real
    Logger.error(...) API.
  • /api/config.js lost every security header. nginx's add_header does
    not inherit from the parent server block once a location defines its own
    — the one response carrying the shared API key (see the read-plane #6 note
    above) was the one response stripped of CSP/X-Frame-Options/nosniff/
    Referrer-Policy. Repeated explicitly in that location.
  • New live-e2e CI jobs passed green on an unexpected [SKIP].
    ot_new_device_e2e.py and container_smoke.py's skip branches all
    return 0; their CI jobs guarantee every precondition those scripts check
    (docker up, BUS_BACKEND=redis, INVENTORY_BASELINE_SECONDS=0), so a skip
    there can only mean the job itself is broken. FENGARDE_E2E_STRICT=1 (set
    by both CI jobs) turns a skip into a hard failure.
  • _index_alert_preserving_triage's retry-exhaustion was silent. A CAS
    write that lost every retry under contention returned False, landing in
    run()'s duplicates counter identically to a benign update — a genuinely
    lost write was indistinguishable from ordinary redelivery. Now logged.
  • RedisSessionStore.resolve()'s new expiry check had a second bug,
    found while writing its (previously missing) regression test:
    if expires_at and time.time() > expires_at treated expires_at == 0.0
    (unambiguously expired) as falsy and skipped the check entirely — a row
    written or tampered with expires_at=0.0 would resolve as valid forever.
    Fixed; test_redis_resolve_enforces_stored_expiry proves the explicit
    check does the rejecting, not the Redis key's own TTL.
  • Rule.contributing_event_ids embedded a truncation marker STRING inside
    the event_ids list itself
    ("<truncated: N omitted>") — any consumer
    treating that field as "a list of ids" would treat the sentinel as a real
    one. Split into a clean id-only list plus a sibling event_ids_omitted
    integer count on the alert doc, present only when the cap actually bit
    (alerts mapping bumped to v8).
  • ws5-ai's out-of-enum coercion counter (_LLM_OUT_OF_ENUM) was
    incremented with a lock-free dict[k] += 1 reachable from multiple
    ai.requests worker threads — undercounts under concurrency. Serialized
    under one lock.

Fixed (2026-08-20, P1-4 remainder: WS-3 double-index silently dropped siem.score)

  • Correctness bug, not the perf item it was filed as. WS-3 consumes both
    normalized.events and scored.events on independent per-topic worker
    threads (no ordering guarantee between them); both carry the same event
    and route to the same (index, doc_id); only WS-4 sets siem.score; and
    OpenSearchStore.index() is a full-document replace. Whenever the
    normalized write landed after the scored write, it silently overwrote the
    scored document and stripped its detection score — the event stayed
    indexed, nothing errored, the score was just gone. Proven by execution
    (both write orderings run against the real router + a real store) before
    any fix was written.
  • Fixed with a new StorageAdapter.index_if_absent (create-only write;
    op_type=create on OpenSearch, MemoryStore's check-and-write under one
    lock hold) used only by the normalized.events worker, so it can never
    clobber a scored document — race-free at the storage layer, no
    check-then-act window. contracts/bus-topics.md's frozen consumer list is
    preserved (the cheap alternative, dropping normalized.events from WS-3
    entirely, would have amended that contract and meant nothing gets indexed
    while WS-4 is down).
  • New services/ws3-indexer/test_double_index_order.py (6 scenarios incl. a
    50-round concurrent-two-thread convergence check), confirmed to fail when
    the fix is reverted.

Fixed (2026-08-20, P1-8 remainder: XACK issued one round-trip per message)

  • New additive Bus.ack_batch(msgs, group) on all three backends
    (_RedisBus: one redis.pipeline(transaction=False) flush; _MemoryBus:
    a loop; _RedisSentinelBus: delegates through the existing failover
    wrapper) — ack()'s own signature and every pre-existing caller are
    unchanged.
  • services/shared/runner.py::_topic_worker's two consume loops now defer
    acks into a batch and flush once per pass instead of one XACK round-trip
    per message, at the same boundary BUS_XREADGROUP_COUNT already batches
    reads at. A handler failure mid-batch is excluded from the flush (stays
    unacked, redelivered normally); a failed pipeline flush leaves everything
    in that batch unacked too, never a silent phantom ack.
  • New services/shared/test_ack_batching.py (3 scenarios, drives a real
    _topic_worker run with a spy bus counting actual ack calls), confirmed
    to fail when the batching is reverted.

Added (2026-08-18, WS-8 cross-alert correlation)

  • New services/ws8-correlation — closes the top remaining
    detection-architecture gap named by the 2026-07-29 adversarial review
    (Design-C): a low-and-slow attacker pacing each technique under its own
    rule's threshold used to produce N isolated alerts, never one aggregated
    incident. A second, independent consumer group (cg-correlate) on the
    existing alerts topic tracks per-entity (actor:{name} / ip:{addr},
    never merged) activity over a 24h default horizon and promotes a track to
    an incident once it shows ≥2 distinct MITRE tactics; produces a new
    incidents topic → incidents-{tenant}-{date} indices, indexed by WS-3
    alongside alerts. See docs/adr/007-cross-alert-correlation-separate-service.md
    and docs/superpowers/specs/2026-08-18-ws8-correlation-build-plan.md for
    the full design and build record, including three real bugs found live on
    first docker compose up (missing PyYAML dependency, missing COPY contracts in the Dockerfile, missing decode_responses=True on the real
    Redis client) and how each was fixed and regression-tested.
  • New GET /incidents + /api/v1/incidents on WS-3's triage API, and a
    plain "Incidents" table view on the dashboard.
  • contracts/allowlists/shared_infrastructure.yml (shipped empty) — CIDR/
    exact-match addresses (NAT gateways, proxies, VPN concentrators) that
    never open an ip: correlation track.
  • services/ws4-detection/window.py and its Allowlist/load_allowlist
    CIDR-allowlist loader moved to services/shared/ so WS-8 can reuse them
    without a cross-workstream import (ADR 004/007); WS-4's own behavior is
    unchanged (git mv, re-verified against its full test suite).
  • ADR 008: declined a shared pydantic schema package (closes a 2+ month
    dangling doc-debt item from the v0.5 plan) — contracts stay JSON Schema +
    hand-rolled tolerant-reader validation.

Fixed (2026-08-13, exhaustive repo audit fix pass)

  • 46 confirmed findings from a full repo audit — 17-partition static review,
    a documentation-discrepancy sweep, and live Docker/Redis/OpenSearch
    verification, followed by a same-day fix pass. Full writeup, methodology,
    and the (small) list of deliberate exceptions in docs/audit-2026-08-13.md.
  • Correctness/crash fixes: timeutil.py/base.py no longer raise on a
    JSON Infinity/NaN timestamp (previously dead-lettered real security
    events); ws4-detection/engine.py's class_uid-bucketing satisfiability
    probe no longer silently narrows which events a rule is ever evaluated
    against; ws4-detection/main.py's hot-reload no longer resets in-flight
    sliding-window state on the default in-memory backend; ws3-indexer/ router.py no longer lets a malformed timestamp abort an entire batch
    drain; ws3-indexer/storage/memory.py's read paths are now lock-protected
    against the same concurrent writes index()/index_cas() already guard
    against; ws2-normalization parser routing no longer misroutes a crafted
    %ASA-containing SSH username away from the real parser.
  • Security hardening: shared/bus.py's _MemoryBus no longer loses
    un-yielded messages on shutdown mid-batch, and a handler exception is now
    reclaimable via a minimal in-memory PEL instead of permanently lost;
    _RedisBus.claim_pending now streams reclaim rounds instead of buffering
    the whole backlog in memory; the dashboard's nginx /api/alerts route now
    actually enforces X-Api-Key (previously open even with auth "enabled",
    since OpenSearch itself ignores that header); ws3-indexer/webhooks.py
    gained SSRF host validation for operator-configured webhook URLs;
    ws1-collectors empty-datagram floods no longer bypass rate limiting
    invisibly, and gained per-source-IP token bucketing alongside the existing
    tenant-level bucket; shared/users.py's scrypt cost was raised with a
    self-describing, backward-compatible hash format.
  • Rule content: dc_mass_vm_delete.yml scoped to its real producer,
    closing cross-source pooling with k8s_audit; bank_db_priv_esc.yml
    retitled to match its actual (untimed) detection logic;
    tools/validate_rules.py gained a load-time check blocking a not_in
    fail-open/fail-closed inversion footgun for future rule contributions.
  • Test/CI/ops: wired 4 previously CI-orphaned test files into
    run_all_tests.sh; added ws6-inventory/test_manage_keys.py (coverage
    69%→74%); parser fuzz coverage expanded from 5/17 to 17/17; dead-letter
    queue depth now surfaced on /metrics//metrics/prom; added the missing
    reports-* OpenSearch mapping template; preflight.sh now checks UDP
    5514; dependabot.yml now covers devkit-feeder.
  • Documentation: corrected SECURITY.md's stale "v0.6" labeling (6
    sites), SSOT.md's false "zero cross-workstream imports" claim, the
    ws4/ws5 INTERFACE.md fair-consume default contradiction, and several
    smaller doc/code drift findings.

Added

  • Failover-scoped live verification lane (make ha-verify): two proofs
    that needed a real primary kill and therefore never ran. Both drive an
    in-network probe over docker exec while performing the kill from the
    host — the HA Redis nodes are not host-published, and both defect classes
    require ONE long-lived client spanning the promotion, so a fresh
    per-step client would resolve the new master trivially and prove nothing.

    • tools/sentinel_failover_live.py +
      services/ws4-detection/test_window_sentinel_failover_live.py: the
      distributed window counter across a Sentinel promotion. Builds its
      client exactly as ws4's HA branch does and holds it across the kill.
      The defect guarded is a client pinned to a demoted master, which answers
      READONLY forever while every health check stays green and every
      stateful rule silently stops firing. Asserts a VALUE, not just write
      success — a promoted replica that had not replicated the window would
      accept writes happily while having lost the count.
    • tools/chaos_failover_test.py + tools/chaos_failover_probe.py: the
      acked-tail durability class make chaos structurally cannot see, since
      no SIGKILL of a consumer replays a primary acking a write it never
      replicated. Contract: every produce() that returned success must be
      readable after the promotion; a produce that raised is not covered,
      because refusing the write is FIX 23's min-replicas-to-write
      guarantee working rather than a violation.
  • Three more live verification lanes, each closing a SSOT.md §2 row that
    had been reviewed but never executed against real infrastructure:

    • services/ws3-indexer/test_mfa_live_e2e.py — the full MFA/TOTP flow over
      real HTTP against the deployed handler. Steps 4 and 5 (password-only
      login must be REFUSED once TOTP is active, and a wrong code must be
      refused indistinguishably from a wrong password) are the load-bearing
      ones; a build with MFA not enforced at all still passes step 6 alone.
      This test found the inert-MFA defect listed under Fixed.
    • tools/ot_new_device_e2e.py — WS-6 bus consumer → raw.events → WS-2
      parser → WS-4 rule → indexed alert, the first time
      ot_new_device_on_segment has fired from a real producer rather than its
      own anti-dormancy fixture. Uses a MAC and a tenant unique per run so a
      stale alert cannot be misread as a fresh pass.
    • tools/backpressure_load_test.py — a real 120k-datagram flood at ~2.2x
      the configured cap, from inside the docker network (a host→container
      flood loses ~75% to Docker's NAT, so nothing engages and the test passes
      while proving nothing). Its own guard refuses to report evidence when the
      achieved send rate did not clear 2x the cap.
  • Live OCC/CAS concurrency test
    (services/ws3-indexer/storage/test_opensearch_cas_concurrency_live.py,
    wired into make test-live): 8 concurrent read-modify-write triage
    updates against one alert on a real cluster, asserting all 8 notes
    survive. A lost update is invisible to a serial test — every write
    succeeds and the final document is well-formed — so only a marker that
    should be present and isn't reveals it. Sensitivity-verified by
    disarming index_cas's version guard, which loses 7 of 8 notes.

  • Stateless-rule near-miss probes (eval/attack/fire_check.py
    _near_miss_probe): closes the last standing gap in the rule-boundary
    gate. The 15 stateless rules previously had no negative probe at all —
    they carry no threshold to step under and no window to overrun, and the
    gate reported them "untested, not passing" on the stated grounds that no
    near-miss was generatable and each needed a hand-authored fixture. That
    reasoning was wrong: every shipped stateless rule's condition is a pure
    conjunction of field predicates, so violating exactly ONE declared
    predicate on the fixture the rule fired on must silence it — generatable
    per predicate. Each predicate kind has its own constructor (perturbed
    value for equality, one step across the boundary for gt/gte/lt/lte/ne,
    a non-member for in/contains/glob, an INSIDE-business-hours
    timestamp for outside_hours), and not_in is inverted on purpose: the
    event is left untouched and the rule is rebuilt against a throwaway
    allowlist directory in which its value IS listed, so the suppression path
    itself runs. Result: 15 of 15 stateless rules hold, 46 predicate
    near-misses, none skipped
    — every MITRE-tagged rule is now negatively
    verified by one half of the gate or the other, none by neither.
    The claim is deliberately narrow — single-predicate NECESSITY, not
    well-scopedness, the same limit the stateful probes carry on declared
    thresholds. It catches what the positive fire check passes silently: a
    condition evaluated as or where and was declared, a not_in
    allowlist that is never consulted, an outside_hours window ignored (a
    rule that ignores time-of-day fires on its own off-hours fixture exactly
    like a healthy one), and a compile step that drops a selection field. It
    proves nothing about whether the declared predicate set is the right one.
    Rules with non-conjunctive conditions are declined rather than probed
    (under or, violating one predicate legitimately leaves the rule firing,
    so asserting silence would assert a defect), a rule whose positive
    control does not reproduce is declined rather than reported all-held, and
    any predicate with no constructible violation is reported skipped and
    keeps its rule out of the "fully held" count. Sensitivity-tested end to
    end in eval/attack/test_fire_check.py: dropping a declared field from a
    rule's compiled selections — declaration intact, engine no longer
    checking it — must make main() exit 1, asserted separately for a plain
    equality predicate and for an outside_hours one.

Fixed

  • MFA/TOTP was inert in every deployed container (services/shared/mfa.py,
    moved from services/ws6-inventory/mfa.py). shared/users.py located the
    TOTP primitive by walking parent.parent / "ws6-inventory" — correct in a
    source checkout, wrong in every image, since ws3-indexer's Dockerfile copies
    services/shared and never ws6-inventory. The import failed, a bare
    except set _TOTP_AVAILABLE = False, and in the shipped container
    provision_totp() raised while verify_totp() rejected every code — so an
    operator could believe MFA was enforced while it was not, with all zero-infra
    tests green because the checkout path resolves fine. Found by running the MFA
    flow against a real container for the first time. ws6 never imported the
    module (its own INTERFACE.md said "hosted here, NOT wired into this
    service's own auth"), so shared/ was always the right home. The degrade is
    no longer silent either: it now emits a RuntimeWarning that names the
    consequence, and the new live e2e treats an unavailable TOTP primitive as a
    FAILURE rather than a skip.
  • infra/docker-compose.yml never exposed INVENTORY_BASELINE_SECONDS, a
    documented WS-6 tunable, so the only way to change the per-tenant baseline
    window was editing the compose file. Now a passthrough with its documented
    3600s default.
  • make test-live's session step never set FENGARDE_SESSION_SECRET, which
    RedisSessionStore has required since the mandatory-signing change (FIX 5).
    CI supplied one in its own job env, so the lane was green there and broken
    for anyone running it locally — found by running it by hand. The target now
    passes a throwaway SESSION_TEST_SECRET (overridable).

Added (continued)

  • Per-tenant fair consume ordering (services/shared/fairness.py):
    WS-4 detection and WS-5 AI triage now round-robin each consume batch by
    tenant instead of raw FIFO, so one tenant flooding a shared deployment can
    no longer occupy every consecutive processing turn ahead of another
    tenant's events. Reorders only, never drops or delays a message — the
    single serial consumer thread per topic ruled out a token-bucket-style
    delay (it would just add latency to everyone, not redistribute it).
    Default on; a single-tenant deployment sees byte-for-byte unchanged
    behavior (round-robin over one bucket is plain FIFO). Honest scope: bounds
    delay within one raw consume batch (MemoryBus's full drain, or Redis's
    BUS_XREADGROUP_COUNT-sized read) — a flood large enough to fill an
    entire raw batch by itself still delays a quiet tenant until the
    underlying bus's own read reaches it; see the module's docstring and
    services/shared/test_fairness.py's batch-boundary test.
  • Detection-quality precision/recall/F1 canary (docs/detection-quality.md,
    tools/detection_quality_eval.py): the real engine scored against a small
    hand-labeled corpus, including two deliberately adversarial labels (an
    off-hours admin logon with no timestamp, a service-account logon against
    the intentionally-empty allowlist) that keep the numbers honest instead of
    a trivial 1.0. This is engine-versus-labels agreement, not a real-world
    detection-fidelity claim — a regression trip-wire (macro-F1 floor 0.5,
    deliberately low), not a quality bar. Wired into run_all_tests.sh.
  • OpenSearch 3-node HA writer failover, live-kill-tested
    (services/ws3-indexer/storage/test_opensearch_ha_failover_live.py):
    brought up the real make ha-up 3-node cluster, docker kill'd a node
    directly, confirmed a write still succeeds via round-robin failover to a
    surviving node, confirmed the cluster returns to status: green after the
    node restarts. The test's own kill mechanism was previously broken (it
    invoked docker compose kill against an override file with no image/build
    context, so every prior run silently skipped the actual kill and reported
    a false PASS) — fixed to kill the container directly, and a failed
    kill/restart now hard-fails the test instead of silently downgrading to a
    skip.

Fixed

  • WS-5 LLM triage now dedups on redelivery: at-least-once bus delivery
    meant a redelivered event could trigger a second, duplicate (real-cost, if
    OLLAMA_URL points at a paid endpoint) LLM call for content already
    triaged. A bounded per-event-id cache (services/ws5-ai/main.py, keyed on
    siem.ingest_id falling back to event_id) now returns the prior verdict
    on a redelivery instead of re-calling the LLM; oldest entries evict first
    once the cache is full, so memory stays flat under sustained load. Stores
    and returns copies, not shared references — a downstream mutation of a
    produced message can no longer corrupt the cached entry for a future
    redelivery.

  • Log-injection sanitizer gap: unmapped.* (any depth, any parser
    extension) and api.request.data were not in the fixed free-text sanitize
    path (services/ws2-normalization/main.py), so attacker-controlled
    content riding in those extension fields reached downstream log sinks
    unsanitized. Fixed with a recursive wildcard walk over the whole
    unmapped subtree; non-string leaves pass through untouched.

  • _MemoryBus.consume()'s check-and-pop race: the old while q: q.popleft() loop was not atomic — two concurrent consumers on one shared
    _MemoryBus (e.g. a worker-thread pool) could both pass the emptiness
    check before either popped, causing a double-delivery or an unhandled
    IndexError on an already-drained queue. Fixed by snapshotting and
    clearing the queue under a lock, released before any message is yielded
    (a service's own handler can produce back onto the same bus from inside
    the consume loop — holding a non-reentrant lock across the yield would
    have self-deadlocked that path).

  • engine.py/tenants.py broke every tool that imports the detection
    engine directly
    : a new module-level from shared.log import get_logger
    assumed services/ was already on sys.path, which only holds when the
    module loads through its normal service entrypoint — tools/validate_rules.py
    and several WS-4 rule-firing tests import engine/tenants directly and
    don't set that path themselves. 14 test failures, one root cause; fixed
    with the same sys.path bootstrap already used in ws6-inventory.

  • _SECTORS's dormant "dc" alias removed (tools/validate_rules.py)
    "datacenter" was the only value any shipped rule actually used.

  • Assorted print()-based logging (auth warnings in
    services/shared/authz.py, services/ws6-inventory/authz.py, rule/tenant
    load warnings in services/ws4-detection/engine.py/tenants.py, keystore
    warnings in services/ws6-inventory/keystore.py and store.py, triage-API
    warnings in services/ws3-indexer/triage_api.py) converted to the shared
    structured logger — no functional change, consistent machine-parseable
    logs everywhere a service is meant to run long-lived.

  • demo_e2e.py's T7 acceptance test now asserts what it actually claims:
    it previously only proved a new, window-overlapping event dedups to the
    same deterministic alert_id (real, but not literal replay); a second
    assertion now also replays the exact original event byte-for-byte and
    confirms it maps to the same id with no new document.

  • M7 Track Y — OT inventory-diff detection, end to end: a new
    ot_new_device_on_segment rule fires on a genuinely new device appearing
    on an OT segment. services/ws6-inventory's InventoryStore now tracks a
    first-ever sighting of a MAC per tenant, gated behind a per-tenant baseline
    window (INVENTORY_BASELINE_SECONDS, default 1h) so standing the service
    up against an existing segment populates inventory instead of alerting on
    every device already there, and durable in SQLite so a restart is never
    mistaken for the whole segment reappearing. A new bus consumer
    (services/ws6-inventory/bus_consumer.py) closes a gap that predates this
    feature — contracts/bus-topics.md had named WS-6 as assets.updates'
    consumer since Phase 0, and requirements.txt had carried the dependency
    comment since an earlier audit, but nothing had ever implemented it: WS-6
    now consumes assets.updates and republishes an alertable first sighting
    onto raw.events, giving the rule a real producer for the first time.
    redis is opt-in in WS-6's image (only pulled in when BUS_BACKEND is
    set), so the zero-infra HTTP-only path is unaffected. Two independent
    adversarial reviews returned DO NOT SHIP on the first cut of this work (no
    baseline, no durable state, unproven tenant isolation, no producer at all)
    — all fixed; see SSOT.md's M7 Track Y rows for the full history.

  • Partial SigmaHQ rule importer (tools/import_sigma_rules.py): converts
    a subset of SigmaHQ's public detection rules into this repo's own rule
    format — selection sanitization, dict/list/OR selection shapes, condition
    rewriting (and/or/not), contains/startswith/endswith/re
    modifiers (regex translated to a bounded glob under ADR-005's no-ReDoS
    constraint, or rejected). Honest about scope: roughly the basic
    detection/condition layer, an estimated 10-20% of real-world SigmaHQ
    constructs — full regex fields, additional modifiers, timeframes, and
    complex condition syntax are still open. Two independent review rounds
    found and this fixed 5 real bugs, the sharpest being that a Sigma
    list-selection (OR-of-items) referenced by its original name silently kept
    only the first branch, and a selection named and/or/not imported
    cleanly into a syntactically dead condition with no error reported.

  • Sigma-style glob operator in the rule grammar
    (services/ws4-detection/engine.py::_glob_match, */?/[seq]/[!seq]
    via fnmatch) — the cheap partial step toward Sigma-rule portability;
    explicitly not a regex layer, so ADR-005's no-ReDoS guarantee is unchanged.

  • Rule-health / dead-rule watchdog: Detector.record_fire() stamps a
    real timestamp per rule id on every match; rule_health_metrics() renders
    one gauge per rule that has actually fired (never fabricated as 0 for one
    that hasn't) on the existing /metrics/prom route, closing the gap where
    a rule proven fireable (fire_check.py) could still go silently dead in
    production with no distinguishable signal from "no attacks happened."

  • Non-zero rule-count floor in eval/attack/fire_check.py and
    tools/check_rule_producers.py: both gates passed while examining zero
    rules
    . Every check in either is vacuously true over an empty set, so a
    rule set that failed to load printed [OK] all 0 rules ... and exited 0 —
    with the event-side counts still large and convincing (32 events, 83 paths, 297 (path,value) pairs checked). Reachable without anyone noticing:
    load_rules() on a missing directory returns [] without raising,
    _contracts_dir() falls through to a path that need not exist, and renaming
    the mitre: key would make every rule skip the tagged-rule filter. This is
    the genuine "the suite silently stopped testing anything" failure, and a
    one-line count floor is the only thing that catches it.

  • Harness attribution canary in eval/attack/fire_check.py: a synthetic,
    unconditionally-satisfiable rule replayed against every fixture event before
    any rule result is reported; on failure main() exits 1 blaming the
    harness
    and overwrites the JSON artifact so a stale green report cannot be
    read as current. Scope, corrected by adversarial review after the first
    version of this entry overstated it: this does not close a detection
    blind spot. A dead fixture pipeline or broken Rule.evaluate() already
    turned the gate red — all 26 tagged rules, including the 14 stateless ones,
    must fire or main() returns 1. What it buys is attribution: without it a
    dead harness reports "26 rule(s) ... never fire -- a real defect
    (dead-on-arrival detection)", sending someone to hunt 26 rule bugs that do
    not exist. It also does not catch partial harness death (a degraded
    enrich(), one parser dropping out of _REGISTRY), where it stays green
    while real rules are falsely accused — documented in its docstring rather
    than left to be discovered.

  • Action-pin gate (tools/verify_action_pins.py, blocking in CI and in
    run_all_tests.sh): every uses: in every workflow must be SHA-pinned, and
    any trailing # vX.Y.Z comment must actually resolve upstream to the pinned
    commit. Closes a structural hole rather than an instance of one:
    scorecard.yml has no pull_request trigger, so nothing in PR CI ever
    read that file
    and a bad edit reached main unexamined — which is exactly
    how ossf/scorecard-action@v2's unresolvable tag shipped broken and sat
    until its first live run. A workflow with no PR trigger is still a file on
    disk, and this validates it without executing it. Mutation-tested against
    three defect classes (stale version comment, floating tag, nonexistent
    commit); tools/test_verify_action_pins.py requires each to exit non-zero.
    Scope stated honestly: it proves a pin resolves, not that an action still
    behaves — the codeql-action init/analyze split that broke #26 resolves
    perfectly.

  • workflow_dispatch on the Scorecard workflow, so a change to it can be
    exercised on demand right after merging instead of waiting for the weekly
    cron to discover a problem. Adding a pull_request trigger would be the
    wrong fix — that workflow runs with publish_results: true and
    id-token: write.

  • Boundary (negative) probes in the MITRE firing check
    (eval/attack/fire_check.py): the existing check proved every tagged rule
    fires AT its threshold. A rule that is too loose — off-by-one count, a
    window wider than declared — fires at threshold too, passes the
    anti-dormancy gate too, and is invisible to both; it surfaces months later
    as false-positive volume nobody traces back to the rule. Every stateful
    rule that fires is now also replayed at threshold - 1 in-window, and at
    a full threshold spread so its total span lands just past
    window_seconds, and must stay silent for both. 12/12 stateful rules
    hold their boundary; the 14 stateless rules are reported NOT
    boundary-tested rather than counted as passing
    — the near-miss of a
    single-event field match is the entire remaining value space, so no
    near-miss fixture is generatable and each needs a hand-authored one.

  • eval/attack/test_fire_check.py: mutation tests that make the probes
    falsifiable — real rules are mutated until they ARE too loose (engine
    fires one event early, window 10% wider than declared) and the gate is
    required to exit 1 end to end through main(), with a control
    asserting exit 0 unmutated. A negative assertion that cannot fail is not a
    test. Wired into run_all_tests.sh, make attack-scorecard, and CI's
    blocking attack-scorecard job.

Fixed

  • fire_check.py reported a rule as having held its boundary when some or
    all of its probes had been skipped rather than run, and selected its
    FAILING verdict by exact-matching the prose string "FIRED" while the
    passing verdict used a prefix match — so editing the failure message
    disarmed the gate with every test still green. Verdicts are now a machine
    status field and coverage is counted only from probes that actually ran.
  • Off-hours anchoring is span-aware: a replay is not an instant, and an
    anchor that is itself off-hours could still drag its oldest event back
    into business hours, which made a healthy stateful outside_hours rule
    report as dead-on-arrival. An unconstructable replay is now reported as a
    harness failure, distinct from a rule defect. The anchor search steps
    by minutes, not hours — stepping by hours held minute-of-hour fixed across
    the whole search and made the result depend on what minute CI started.

Changed

  • SSOT.md and docs/superpowers/specs/2026-07-22-mitre-fire-check.md
    corrected on two overclaims: the firing check exercises Rule.evaluate(),
    not Detector.process()'s class_uid prefilter or per-tenant disable
    (a mis-bucketed rule still reports FIRED); and the boundary probes prove
    the engine agrees with each rule's declared threshold, not that any
    threshold is well chosen — lowering a declared threshold keeps the gate
    green.

Added (2026-08-06, security-hardening + enhancements, PR #54)

An independent security/architecture/CI audit (28 findings, plus a
cross-check against a prior swarm review and a dedicated HA audit — full
detail in SSOT.md's two new §1 rows and docs/reviews/implementation_report_2026-08-06.md)
drove a 39-finding fix pass, followed by a second independent review that
caught 1 CRITICAL + 4 HIGH regressions the fix pass itself introduced and
fixed those too (commit 983efc7).

  • Opt-in MFA/TOTP (services/ws6-inventory/mfa.py, RFC 6238,
    stdlib-only): provision → confirm two-step activation, login gates on
    totp_code once active, additive users schema columns (existing
    accounts untouched). Both /auth/mfa/enable and /auth/mfa/verify
    require the acting user's own current password — a session cookie alone
    cannot touch an account's MFA config — rate-limited and audited.
  • Append-only admin-scoped audit log (services/ws3-indexer/audit.py):
    JSONL, capacity-capped (ring-buffer tail-trim), fail-open (an audit
    outage never breaks login/triage/report), wired into login
    success/failure, triage updates, and report generation; GET /audit
    requires admin.
  • OpenSearch multi-node write failover (FIX H6): OpenSearchStore
    accepts a comma-separated node list and rotates to a surviving node on a
    connection-level failure; infra/docker-compose.ha.yml's ws3-indexer
    now actually points at all 3 nodes (it shipped pointed at one, making the
    failover code dead in the one profile meant to exercise it).
  • Per-source syslog metrics (E6): a bounded, LRU-evicted, thread-safe
    per-peer-IP breakdown on WS-1's /metrics.
  • Dashboard: saved alert-search filters (client-side), dark/light theme
    toggle, alert-lifecycle guidance and per-rule playbook rendering
    (E11/E12/E13).
  • FENGARDE_REQUIRE_AUTH boot-time gate (services/shared/authz.py):
    refuses to start when auth is required but the configured surface is
    incomplete, instead of silently booting default-open.

Fixed (2026-08-06, same pass)

  • HA BUS_BACKEND env-gate only matched the exact string "redis",
    silently ignoring redis-sentinel — 12 stateful rules fell back to
    per-process window counters under the HA profile and would never fire at
    scale. Now accepts both, and the Redis Sentinel window-counter client
    uses Sentinel.master_for() (re-resolves on every reconnect) instead of
    a one-shot discover_master() that kept writing to a demoted master
    after a real failover.
  • Poison-pill rule guard: window_seconds/threshold type-validated
    at rule load, plus a runtime fail-closed wrapper around stateful
    evaluation, so a malformed rule can no longer crash the detection
    consumer.
  • Detector.process() evaluated the class_uid=None catch-all rule bucket
    twice for a classless event.
  • db_audit.py's substring-match operation map misclassified GRANT SELECT as a read instead of a privilege-escalation event; reordered
    privilege-first.
  • shared/ocsf.py::valid_ip now normalizes IPv4-mapped IPv6
    (::ffff:a.b.c.d) so dual-stack auth events stop dead-lettering.
  • Session rows written to the Redis session backend are now HMAC-signed
    and required to be — RedisSessionStore refuses to start without
    FENGARDE_SESSION_SECRET set, and resolve() rejects any row without a
    valid signature (an earlier version of this fix left an unsigned
    backward-compat path open, which a process able to write to Redis
    directly could use to forge a session — closed in the review pass).
  • SSRF hardening: shared/http.py was renamed to shared/outbound_http.py
    (the old name shadowed the stdlib http module and silently broke
    import urllib.request) and every outbound call (webhooks, reports, LLM
    triage) now uses a no-redirect urllib opener.
  • UDP syslog dedup: an intermediate version of this pass hardcoded
    deterministic_id=True for every UDP datagram on the theory that "UDP
    retransmission is normal" — false, and it collapsed N genuinely repeated
    identical log lines (e.g. N real brute-force attempts) into one
    content-hashed ingest_id, which WS-4's window counters dedup by member,
    silently zeroing threshold-rule counts. Reverted to honoring the
    constructor's deterministic_id flag (default False).
  • Sigma regex-to-glob translation (tools/import_sigma_rules.py, M18): a
    bare . outside a .* wildcard is rejected on both translation
    branches now — the first pass only closed the fully-literal branch,
    leaving ^cmd.exe .*payload$ silently narrowed to a literal-dot glob.
    Both Sigma test files are now wired into run_all_tests.sh (they
    existed but were never CI-gated).
  • The not_in allowlist module-level cache (_ALLOWLIST_CACHE) was never
    invalidated, so an operator repairing a broken allowlist file would see
    the fix ignored until process restart; cleared at the start of every
    load_rules() pass.
  • CI's mutmut step was flipped to "blocking" against a config with no
    actual threshold field to gate on (mutmut run fails on any survivor,
    which would fail every PR at the measured ~72% baseline); reverted to
    informational.

Security

  • Redis AUTH/primary now runs with --min-replicas-to-write 1 --min-replicas-max-lag 10 (FIX 23) so it refuses writes while no replica
    is connected, rather than silently accepting an acked tail a subsequent
    failover could lose — see SSOT.md's chaos-gate row for the honest scope
    this adds (consumer-failure durability was already proven; primary
    failover durability is what this closes).
  • SECURITY.md gained sections on the Grafana default credential, the
    empty-by-default FENGARDE_API_KEY_PEPPER, webhook-secret sourcing, and
    the now-mandatory FENGARDE_SESSION_SECRET for the Redis session
    backend.