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:
/healthnow reports 503 on a bus outage (was hardcoded 200 from an
empty handler map); ingest-edge metrics flattened so/metrics/promemits real
gauges; spool escapesU+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_WARNdegrades instead of killing
the daemon;unmappedlist sanitization now recurses; parser comment + doc
order corrected. - WS-3/contracts: OpenSearch read-side 5xx propagation; tenant default-filters
forlist_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_fullmapped; nis2 envelope/schema aligned;
triage-api report params + CSRF note; inventory-api auth documented. - WS-4/5/8: torn-read-safe reload;
siem:nullpoison-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_enqueuedcounts 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_httpsafe_urlopenSSRF + pinned opener; scrypt
nceiling; log reserved-key + always(). - tools/eval/CI:
check_test_wiringgate; coverage_gate TARGETS derived from
the runner; fire_check untested rules reported; many live benches wired;
container-smokeCI 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_POSTcalledLogger.exception(), a methodshared.log.Logger
does not have (stdlib-logging-only) — an unhandled handler error raised
AttributeErrorbefore the 500 response was ever sent, dropping the
connection instead of returning it.opensearch.py's_log_rw_warncalled
.warning()with positional%sargs against a**fields-only signature —
hit on every non-index-missingHTTPErrorfromcount/_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.jslost every security header. nginx'sadd_headerdoes
not inherit from the parentserverblock once alocationdefines 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.pyandcontainer_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 returnedFalse, landing in
run()'sduplicatescounter 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_attreatedexpires_at == 0.0
(unambiguously expired) as falsy and skipped the check entirely — a row
written or tampered withexpires_at=0.0would resolve as valid forever.
Fixed;test_redis_resolve_enforces_stored_expiryproves the explicit
check does the rejecting, not the Redis key's own TTL.Rule.contributing_event_idsembedded a truncation marker STRING inside
theevent_idslist 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 siblingevent_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-freedict[k] += 1reachable from multiple
ai.requestsworker 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.eventsandscored.eventson 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 setssiem.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=createon OpenSearch,MemoryStore's check-and-write under one
lock hold) used only by thenormalized.eventsworker, 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, droppingnormalized.eventsfrom 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: oneredis.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 boundaryBUS_XREADGROUP_COUNTalready 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_workerrun 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
existingalertstopic 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
incidentstopic →incidents-{tenant}-{date}indices, indexed by WS-3
alongside alerts. Seedocs/adr/007-cross-alert-correlation-separate-service.md
anddocs/superpowers/specs/2026-08-18-ws8-correlation-build-plan.mdfor
the full design and build record, including three real bugs found live on
firstdocker compose up(missing PyYAML dependency, missingCOPY contractsin the Dockerfile, missingdecode_responses=Trueon the real
Redis client) and how each was fixed and regression-tested. - New
GET /incidents+/api/v1/incidentson 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 anip:correlation track.services/ws4-detection/window.pyand itsAllowlist/load_allowlist
CIDR-allowlist loader moved toservices/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 indocs/audit-2026-08-13.md. - Correctness/crash fixes:
timeutil.py/base.pyno longer raise on a
JSONInfinity/NaNtimestamp (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.pyno 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 writesindex()/index_cas()already guard
against;ws2-normalizationparser routing no longer misroutes a crafted
%ASA-containing SSH username away from the real parser. - Security hardening:
shared/bus.py's_MemoryBusno 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_pendingnow streams reclaim rounds instead of buffering
the whole backlog in memory; the dashboard's nginx/api/alertsroute now
actually enforcesX-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-collectorsempty-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.ymlscoped to its real producer,
closing cross-source pooling withk8s_audit;bank_db_priv_esc.yml
retitled to match its actual (untimed) detection logic;
tools/validate_rules.pygained a load-time check blocking anot_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; addedws6-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.shnow checks UDP
5514;dependabot.ymlnow coversdevkit-feeder. - Documentation: corrected
SECURITY.md's stale "v0.6" labeling (6
sites),SSOT.md's false "zero cross-workstream imports" claim, the
ws4/ws5INTERFACE.mdfair-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 overdocker execwhile 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
READONLYforever 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 classmake chaosstructurally cannot see, since
no SIGKILL of a consumer replays a primary acking a write it never
replicated. Contract: everyproduce()that returned success must be
readable after the promotion; a produce that raised is not covered,
because refusing the write is FIX 23'smin-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_segmenthas 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 intomake 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
disarmingindex_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 forgt/gte/lt/lte/ne,
a non-member forin/contains/glob, an INSIDE-business-hours
timestamp foroutside_hours), andnot_inis 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 asorwhereandwas declared, anot_in
allowlist that is never consulted, anoutside_hourswindow 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
(underor, 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 ineval/attack/test_fire_check.py: dropping a declared field from a
rule's compiled selections — declaration intact, engine no longer
checking it — must makemain()exit 1, asserted separately for a plain
equality predicate and for anoutside_hoursone.
Fixed
- MFA/TOTP was inert in every deployed container (
services/shared/mfa.py,
moved fromservices/ws6-inventory/mfa.py).shared/users.pylocated the
TOTP primitive by walkingparent.parent / "ws6-inventory"— correct in a
source checkout, wrong in every image, since ws3-indexer's Dockerfile copies
services/sharedand neverws6-inventory. The import failed, a bare
exceptset_TOTP_AVAILABLE = False, and in the shipped container
provision_totp()raised whileverify_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 ownINTERFACE.mdsaid "hosted here, NOT wired into this
service's own auth"), soshared/was always the right home. The degrade is
no longer silent either: it now emits aRuntimeWarningthat names the
consequence, and the new live e2e treats an unavailable TOTP primitive as a
FAILURE rather than a skip. infra/docker-compose.ymlnever exposedINVENTORY_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 setFENGARDE_SESSION_SECRET, which
RedisSessionStorehas 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 throwawaySESSION_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 intorun_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 realmake ha-up3-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 tostatus: greenafter the
node restarts. The test's own kill mechanism was previously broken (it
invokeddocker compose killagainst 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_URLpoints 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_idfalling back toevent_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) andapi.request.datawere 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
unmappedsubtree; non-string leaves pass through untouched. -
_MemoryBus.consume()'s check-and-pop race: the oldwhile 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
IndexErroron 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.pybroke every tool that imports the detection
engine directly: a new module-levelfrom shared.log import get_logger
assumedservices/was already onsys.path, which only holds when the
module loads through its normal service entrypoint —tools/validate_rules.py
and several WS-4 rule-firing tests importengine/tenantsdirectly and
don't set that path themselves. 14 test failures, one root cause; fixed
with the samesys.pathbootstrap already used inws6-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 inservices/ws4-detection/engine.py/tenants.py, keystore
warnings inservices/ws6-inventory/keystore.pyandstore.py, triage-API
warnings inservices/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 deterministicalert_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_segmentrule fires on a genuinely new device appearing
on an OT segment.services/ws6-inventory'sInventoryStorenow 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.mdhad named WS-6 asassets.updates'
consumer since Phase 0, andrequirements.txthad carried the dependency
comment since an earlier audit, but nothing had ever implemented it: WS-6
now consumesassets.updatesand republishes an alertable first sighting
ontoraw.events, giving the rule a real producer for the first time.
redisis opt-in in WS-6's image (only pulled in whenBUS_BACKENDis
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; seeSSOT.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 namedand/or/notimported
cleanly into a syntactically dead condition with no error reported. -
Sigma-style
globoperator in the rule grammar
(services/ws4-detection/engine.py::_glob_match,*/?/[seq]/[!seq]
viafnmatch) — 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/promroute, 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.pyand
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
themitre: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 failuremain()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 brokenRule.evaluate()already
turned the gate red — all 26 tagged rules, including the 14 stateless ones,
must fire ormain()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): everyuses:in every workflow must be SHA-pinned, and
any trailing# vX.Y.Zcomment must actually resolve upstream to the pinned
commit. Closes a structural hole rather than an instance of one:
scorecard.ymlhas nopull_requesttrigger, so nothing in PR CI ever
read that file and a bad edit reachedmainunexamined — which is exactly
howossf/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.pyrequires each to exit non-zero.
Scope stated honestly: it proves a pin resolves, not that an action still
behaves — thecodeql-actioninit/analyze split that broke #26 resolves
perfectly. -
workflow_dispatchon 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 apull_requesttrigger would be the
wrong fix — that workflow runs withpublish_results: trueand
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 atthreshold - 1in-window, and at
a fullthresholdspread 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 throughmain(), with a control
asserting exit 0 unmutated. A negative assertion that cannot fail is not a
test. Wired intorun_all_tests.sh,make attack-scorecard, and CI's
blockingattack-scorecardjob.
Fixed
fire_check.pyreported 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
statusfield 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 statefuloutside_hoursrule
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 exercisesRule.evaluate(),
notDetector.process()'sclass_uidprefilter 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_codeonce active, additiveusersschema columns (existing
accounts untouched). Both/auth/mfa/enableand/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
requiresadmin. - 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'sws3-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_AUTHboot-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_BACKENDenv-gate only matched the exact string"redis",
silently ignoringredis-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
usesSentinel.master_for()(re-resolves on every reconnect) instead of
a one-shotdiscover_master()that kept writing to a demoted master
after a real failover. - Poison-pill rule guard:
window_seconds/thresholdtype-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 theclass_uid=Nonecatch-all rule bucket
twice for a classless event.db_audit.py's substring-match operation map misclassifiedGRANT SELECTas a read instead of a privilege-escalation event; reordered
privilege-first.shared/ocsf.py::valid_ipnow 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 —RedisSessionStorerefuses to start without
FENGARDE_SESSION_SECRETset, andresolve()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.pywas renamed toshared/outbound_http.py
(the old name shadowed the stdlibhttpmodule and silently broke
import urllib.request) and every outbound call (webhooks, reports, LLM
triage) now uses a no-redirecturllibopener. - UDP syslog dedup: an intermediate version of this pass hardcoded
deterministic_id=Truefor 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-hashedingest_id, which WS-4's window counters dedup by member,
silently zeroing threshold-rule counts. Reverted to honoring the
constructor'sdeterministic_idflag (defaultFalse). - 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 intorun_all_tests.sh(they
existed but were never CI-gated). - The
not_inallowlist 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 runfails 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 — seeSSOT.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.mdgained sections on the Grafana default credential, the
empty-by-defaultFENGARDE_API_KEY_PEPPER, webhook-secret sourcing, and
the now-mandatoryFENGARDE_SESSION_SECRETfor the Redis session
backend.