Releases: supermhel/fengarde
Release list
v0.6.0
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-de...
v0.1.0
Added
-
Full detection pipeline — end-to-end flow: collect → normalize (OCSF) → detect → index → dashboard. Every stage is independently testable and wired together in a single
docker compose up. -
4 log source parsers — Linux SSH (
/var/log/auth.log), Cisco ASA syslog, Windows Active Directory EventID 4625 (failed logon), and VMware vSphere. Each parser emits a typed OCSFAuthenticationevent. -
Brute-force detection rule — fires when a single IP accumulates 10 failed authentications within a 60-second window. Threshold and window are YAML-configurable; no code change required to tune sensitivity.
-
Contract-first architecture — 7 machine-readable contracts (OCSF event schemas, OpenAPI specs for internal HTTP surfaces, Sigma rule schema) committed alongside code. Contracts are the source of truth; implementations are verified against them in CI.
-
Shared message bus abstraction — a single
Businterface with two concrete backends: an in-memory implementation for unit and acceptance tests (zero infrastructure), and a Redis Streams implementation for production. Services never import a backend directly. -
Shared runner — common event-loop component used by every service. Provides ack-after-handler semantics, configurable redelivery on failure, a dead-letter queue for poison messages, and a
/healthHTTP endpoint that CI and Docker health checks hit. -
Deterministic alert IDs (T7) — alert IDs are derived from a stable hash of the triggering evidence. Re-processing the same log stream produces identical IDs, making the pipeline idempotent under at-least-once delivery.
-
Global window counter (T6) — sliding-window counts are stored in Redis sorted sets (
ZCOUNT). All replicas share a single counter, so horizontal scaling does not split detection windows or cause missed alerts. -
Zero-infrastructure acceptance test (
make e2e) — the full pipeline (parse → detect → index) runs in-process with the in-memory bus. No Docker, no Redis, no OpenSearch required locally. The same test is the CI gate. -
Live dashboard — a browser-based UI served by nginx, which also acts as a reverse proxy to OpenSearch. No CORS configuration needed; the browser talks only to nginx.
-
Auto-feeder (devkit-feeder) — a companion container that injects a synthetic brute-force log sequence on
docker compose up. A real alert appears in the dashboard within seconds of the stack starting, with no manual curl commands. -
Secret scanning in CI — gitleaks runs on every push and pull request. Any credential committed by mistake blocks the build before it reaches reviewers.
-
Apache-2.0 license — permissive license; use in commercial products, fork freely.
v0.5.0
[0.5.0] - 2026-07-23
Added (M2 proof artifacts + M7 continuous tracks, 2026-07-22)
- mypy blocking gate: re-measured the stale "20 findings" baseline before
trusting it — a live re-run found 47 across all 8 workstreams, not 20.
Fixed all 47 with narrow, behavior-preserving changes; CI's|| trueon
the mypy step removed. Regressions now fail CI the same way ruff/coverage
already do. - Mutation-testing gate (mutmut): first-ever run in this repo, scoped to
services/shared/sessions.py(a directory-wide run would take hours per
mutant re-run here). Measured baseline: 142 mutants, 50 covered, 36
killed / 14 survived (72% kill rate) — informational in CI, same
measure-first sequencing mypy went through before it was flipped blocking. - MITRE empirical firing check (
eval/attack/fire_check.py): closes the
gap between the existing declared-coverage scorecard and the dataset-gated
real-world replay lane — every MITRE-tagged rule is replayed against its
own real producer fixture through the actual detection engine. 26/26
tagged rules fire. Found and fixed a real harness bug along the way
(synthetic timestamps stepping into the future tripped the anti-poisoning
clock-skew guard, falsely showing 2 real rules as silent). - Observability: Prometheus + Grafana: new
/metrics/promexposition-
format route (hand-rolled, stdlib-only — no new runtime dependency),
opt-inobservabilitycompose profile with auto-provisioned Grafana
dashboard. Live-verified: all 5 scrape targets up, real pipeline counters
queryable. OTel tracing stays explicitly out of scope (bigger lift, still
an ADR-only aspiration). - Modbus/TCP protocol-anomaly detector (
modbus_anomaly.py), the second
OT source after OPC UA — deliberately scoped as an anomaly detector over
the protocol's own public function-code table, not a vendor-log parser
(Modbus has no audit-log format to parse). New rule
ot_modbus_unauthorized_write.yml(ATT&CK-ICS T0855) ships with a real
producer and passes both the anti-dormancy gate and the new firing check.
Added (post-merge CI hardening, PR#2 → main)
- CodeQL's first-ever live scan (2026-07-18/19) found 5 HIGH alerts, all fixed at
the design level, zero dismissed:tenant_idpath-injection inrules_view.py
closed by never letting request data into a path expression (trusted-dir glob +
stem match); the RBAC first-boot admin password redesigned to operator-supplied
viaFENGARDE_ADMIN_PASSWORD(the service never generates/logs/stores plaintext;
unset + empty store now fails closed with a loud warning). Open CodeQL alert count
onmain: 0. - Three new CI gates:
pip-audit(CVE audit of every pinnedrequirements.txt,
the deliberate replacement for disabled Dependabot),docker-build(all 8 images
must build — nothing previously proved this in CI),actionlint(lints the
workflows themselves; immediately caught a real dead-var in the mypy step). - Supply-chain pinning: every workflow
uses:SHA-pinned, all 8 Docker base
images digest-pinned, least-privilegepermissions:on every workflow — Scorecard
alerts 54→19. The remaining 19 are accepted, not open work (11 want full
transitive pip hash-pinning, declined as maintenance-disproportionate given
version-pins + the pip-audit gate; 8 are practice/settings-level signals that
accrue with repo history or are an explicit choice — Dependabot's own removal
below is one of them). - Dependabot removed (
.github/dependabot.yml) in favor ofpip-audit's
CVE-driven gate — a deliberate choice, not an oversight; see the pinning note above. - Fixed since PR#2 landed red: ruff
F401inrunner.py;ossf/scorecard-action@v2
pinned tov2.4.3(no such floating tag existed);qualityjob missing
hypothesis; the coverage gate's hand-synced test list re-synced post-merge.
Added (v0.5: closed the five disclosed post-M6 gaps + full Track X backlog)
Live-verified on Docker Desktop where the gap required it; SSOT.md has the
full evidence trail per item.
- ILM → ISM retention policies, live-verified: rewrote the four
retention policies in real OpenSearch 2.13 ISM schema
(contracts/opensearch-mappings/ism-*.json, replacing the Elasticsearch-
syntaxilm-policies.jsonthat never worked on this stack), rewired
infra/provision.shto real idempotent PUTs. Also found and fixed a
second, older bug the same live run surfaced:assets/events-bank/
events-dc/alertstemplates each had a top-level_commentfield
OpenSearch's real_index_templatePUT rejects outright — silently
masked forever bycurl -sfswallowing the error. Onlyevents-common
had ever actually installed on a live cluster before this fix. - Redis-backed RBAC sessions:
services/shared/sessions.pygained
RedisSessionStore+ amake_session_store()factory
(FENGARDE_SESSION_BACKEND=memory|redis, fails loud rather than
silently falling back — a session store is a security boundary).
Live-verified against Docker Desktop's real Redis. - Live migrate/CAS verification:
tools/migrate_opensearch.py's
plan/apply cycle now has a live test proving real template PUTs, zero
drift on a secondplan(), andmapping_versionround-tripping through
a real cluster — previously wire-format tested only. - Open-core section in README stating the free/paid split explicitly
(was previously only implicit in SSOT.md). - B3 dual-backend test verified live (was already built by prior work,
never live-confirmed):BUS_BACKEND=redis test_runner.pypassed all six
parametrized bodies against Docker Desktop's real Redis. - B4 rule hot-reload (opt-in,
RULES_RELOAD_INTERVAL_S, default off):
Detector.reload()atomically swaps in a freshly parsed rule set,
fail-closed on a malformed edit. - C2 dashboard auto-refresh: polls every 10s when data is live and the
tab is visible, skips the DOM rebuild when nothing changed (protects an
in-progress triage-note edit). - C3 MITRE ATT&CK/ATT&CK-ICS/ATLAS coverage heatmap: optional, shape-
validatedmitre: {tactic, technique}block on rule YAML (24 of 25
rules tagged), propagated onto every alert, rendered as a new dashboard
"Coverage" tab. Surfaced and fixed two real, previously-undetected bugs
while live-verifying this:rules_view.py/webhooks.pycomputed their
contracts/path with container-incompatible math (GET /rules and
webhook config loading had returned nothing on every live deployment
since they shipped), and the dashboard'sgetAlerts()never mapped
rule_idthrough. - Four new parser packs (DNS query log, Kubernetes audit, CEF, AWS
CloudTrail) closing the long-standing class-4002 (DNS/HTTP Activity) gap
and adding the first Kubernetes and cloud-control-plane producers. Five
new rules ship with real producers:common_dns_exfil,
dc_privileged_container,cloud_root_console_login,
bank_mass_card_read(one additive field on the existingdb_audit.py),
common_rapid_account_lifecycle. - Periodicity/beaconing primitive:
hit_periodic()on both window
backends (coefficient of variation of inter-arrival deltas, reusing
existing window state — no new storage), wired into the rule grammar as
siem.periodicity: {max_cv}, andcommon_beaconing.yml— the design
item flagged "design-first" since the v0.3 plan. - S7/PROFINET decision gate re-investigated: found the original
"proprietary-shaped" deferral reasoning was too broad (S7-1500 ships a
real, public RFC 5424 syslog security-event feed) but the concrete event
vocabulary needed to parse it honestly is access-gated, not
undocumented — still deferred, now for an evidenced reason with a
concrete unblock path recorded. - B5 HA design doc: Redis Sentinel + OpenSearch multi-node
recommendation, decision only, no code — closes the last open Track X
item.
Added (M3 remainder: dashboard session login + CSRF)
Closes two items the M3 milestone had left genuinely open (verified by grep before
starting, not assumed from the plan doc): the RBAC session API (/auth/login,
/auth/logout, /auth/me, M4.2) was real and tested at the HTTP level, but nothing
in services/ws7-dashboard/ actually called it, and CSRF protection didn't exist.
- Dashboard login UI (
services/ws7-dashboard/index.html) — a login form gates
the app behind a real session whenFENGARDE_RBAC_DBis set; a user badge
(username + role + Sign out) replaces it once authenticated. Wired to a new nginx
proxy path (/api/auth/→ ws3-indexer's/auth/*,services/ws7-dashboard/ templates/default.conf.template). RBAC off (the default, every existing
deployment) is byte-for-byte unaffected:GET /auth/me404s (no such route), the
login gate is skipped entirely, and the app renders exactly as before — same
"opt-in, zero behavior change" convention as every other auth layer in this
project. Found and fixed two real bugs while browser-testing this (Playwright/
Chromium, not just the static contract test): a CSS-specificity trap where
#loginScreen's own ID-selector rule silently outranked thehiddenattribute
(toggling.hiddenin JS did nothing), and clearing an inlinestyle.displaywith
""fell back to a stylesheet rule that was stillnoneinstead of becoming
visible — both fixed by always setting an explicit display value, documented
inline where a future edit could easily reintroduce either trap. - CSRF protection (
services/ws3-indexer/triage_api.py::_check_csrf,
services/shared/sessions.py) — a second, independent layer on top of the session
cookie's existingSameSite=Strict: login now also mints acsrf_token(returned
in the login/. /auth/meresponse body, never a cookie), and every state-changing
(POST) request made with an active session must echo it back asX-CSRF-Tokenor
get a 403 — enforced c...
v0.4.0
Added (v0.4 Track S — opt-in auth)
FENGARDE_API_KEYshared-secret auth on the WS-3 triage API and WS-6 inventory API (X-Api-Keyheader, constant-time compare). Unset (default) = every request allowed + a startup warning; set = 401 on missing/wrong key.services/shared/authz.py(ws3) +services/ws6-inventory/authz.py(ws6 doesn't bundleshared, so it gets its own copy).- Dashboard basic-auth, opt-in via
infra/docker-compose.auth.ymloverride (nginxauth_basic+ htpasswd) — not baked into the main compose file, sodocker compose upstays zero-prerequisite. - Redis
AUTH, opt-in viaREDIS_PASSWORD, embedded inREDIS_URLfor every service. - Dashboard nginx converted to an envsubst template (
templates/default.conf.template) so it can injectX-Api-Keyserver-side on the triage proxy — the browser never holds the key. - OpenSearch/Redis/OpenSearch-Dashboards ports bound to
127.0.0.1by default (were0.0.0.0). OpenSearch's security plugin stays disabled — a documented scope cut, not an oversight (SECURITY.md§2).
Added (v0.4 Track R — incident-report hook)
contracts/reporting.md— frozen cross-repo contract withfengarde-sec:POST/GET /alerts/{id}/reporton the WS-3 triage port,REPORT_BACKEND=template|httpseam, frozen response schema. Hard rules enforced structurally:statusmust be"draft",disclaimermandatory non-empty,citationsoptional (additive-field discipline) — a non-conforming backend response is rejected and WS-3 falls back to the builtin template (fail-open).services/ws3-indexer/reporting.py— generic markdown template renderer (rule/severity/timeline/source/actor/triage state, explicit[ANALYST MUST PROVIDE]blocks, zero regulatory claims), HTTP-backend caller + response validator, deterministicreport_id(idempotent re-generation).- Dashboard "Rapport" button per alert row; renders the draft as text (never
innerHTML, same XSS discipline as the rest of the UI).
Added (v0.4 Track P — niche parser packs)
- MCP/AI-agent parser (
mcp_agent) — tool-call audit logs → OCSF API Activity (6003). Pattern classification (credential-path access, prompt-injection indicators) happens at parse time as documented heuristic booleans (unmapped.mcp.*), since the rule engine has no substring-match operator. Three rules:agent_credential_file_access,agent_tool_call_burst,agent_prompt_injection_indicator. - OPC UA/OT parser (
opcua_audit) — industrial control-system audit events (IEC 62541 Part 5) → OCSF Authentication (session/cert events) + API Activity (write/method-call events). First OT source chosen over S7/PROFINET because Part 5 is publicly documented and fixturable honestly; S7 deferred, named not dropped. Three rules:ot_write_outside_maintenance(reusesoutside_hours),ot_new_engineering_connection(distinct source IP per PLC),ot_config_change. - n8n automation-platform parser (
n8n_audit) — workflow/webhook/credential/login events → OCSF. Two rules:n8n_new_webhook_exposed,n8n_workflow_modified_after_hours. - Impossible-travel rule (
common_impossible_travel) — the first rule consuming v0.3's A5 geo enrichment (src_endpoint.location.country, distinct-count, no engine change needed).tools/check_rule_producers.pyupdated to run each fixture through the realenrich()step too, mirroring the actual parse→enrich pipeline — otherwise this rule would have looked dormant by the tool's own standard.
Fixed
- Stateful rules pooled unattributable events under a shared "None" group — an event whose
group_byfield was missing was counted under the literal string"None", pooling unrelated actors toward one threshold (e.g. two sessionless agent streams summing into one burst). Worse, a missingdistinct_fieldvalue diverged across backends: memory countedNoneas one distinct value, Redis turned every None-valued event into a fresh distinct member — N unenriched events alone could satisfy any distinct threshold (impossible-travel firing on 2 logins with no geo enrichment).Rule.evaluate()now fails closed: no group → no count; no distinct value → no count. Regression tests cover both paths; convention documented incontracts/sigma-convention.md. - Report route: body not drained on malformed Content-Length — an unparseable
Content-Lengthheader onPOST /alerts/{id}/reportwas silently zeroed, leaving stray body bytes buffered (keep-alive connection corruption risk). Now a 400, mirroring the triage route. - Cross-source rule-scoping bugs —
agent_tool_call_burst,ot_write_outside_maintenance, andot_new_engineering_connectionkeyed only onclass_uid/activity_id(or grouped on a field only one source sets). Landing a thirdclass_uid: 6003producer (n8n_audit) alongside the existingvmware_vsphere/mcp_agent/opcua_auditsurfaced the bug: another source's event could silently mis-fire the wrong rule or pool into a shared "None" counter bucket. All three now add an explicitsiem.source_typeselection.contracts/detection-coverage.mddocuments this as a standing lesson for the next shared-class producer —check_rule_producers.py's satisfiability check does not catch this class of bug (it proves a rule can fire, not that it fires on the right source).
Added (v0.4 Track D — distribution)
make demobanner fixed to reflect reality —devkit-feederalready injects a real SSH brute-force burst on everydocker compose up, but the Makefile still claimed the feeder wasn't built.- README repositioned to the validated wedge ("the open-source SIEM for the European industrial Mittelstand"), new "Quickstart (10 minutes)" section, capability table refreshed (10 parsers / 17 rules).
- Three architecture write-ups (
docs/posts/ocsf-native.md,opensearch-not-elastic.md,local-ai-triage.md) + a launch checklist (docs/posts/launch-checklist.md).
v0.3.0
Added
- DB-audit parser (
db_audit) — vendor-agnostic database audit logs → OCSF Datastore Activity (6005), activity_id 5 for GRANT/REVOKE/ALTER. Un-dormants thebank_db_priv_escrule, which matched a class no parser emitted. - Windows account-change coverage —
windows_eventlogparser extended to EventIDs 4720/4722/4726/4728/4732 (Account Change, class 3003), with acting admin inactor.userand target account inunmapped.target_user. - Password-spray rule (
common_password_spray.yml) — one account failing auth from ≥8 distinct source IPs (inverse of brute-force). - Privileged-group grant rule (
common_priv_grant.yml) — single-shot on Account Change activity 5. - After-hours privileged-logon rule (
common_after_hours_admin.yml) — Windows 4672 special-privilege assignment (class 1002 activity 2) outside a configurable business-hours window. - Rule grammar: comparison operators + allowlists + time-of-day —
gt/gte/lt/lte/neoperators, anot_in: <allowlist>suppression clause (contracts/allowlists/*.yml, CIDR + exact match), and anoutside_hourstime-of-day/day-of-week predicate (withtz_offset_minutesand midnight-wrapping windows) in the boolean evaluator. Operators fail closed on malformed input; a missing/malformed allowlist file fails open on the rule (keeps firing) but closed on suppression (never suppresses). Grammar documented incontracts/sigma-convention.md. - Rule prefilter — the detector buckets rules by their
class_uidequality selection and only evaluates candidate rules per event, replacing the O(rules×events) linear scan. Alert-firing behavior verified byte-identical before/after. - Anti-dormancy guardrail (
tools/check_rule_producers.py, inrun_all_tests.sh) — proves each rule's equality selections/group_by/distinct_fieldare satisfiable by an actual (path, value) pair some registered parser emits against a real fixture. - Detection coverage map (
contracts/detection-coverage.md) — ground truth of OCSF classes emitted by shipped parsers vs. rule coverage. - Triage workflow (v0.3 C1) — status + analyst note per alert: new WS-3 triage HTTP API (
GET/POST /alerts/{id}/triage,TRIAGE_PORTdefault 8013),find_alert()cross-index lookup in both storage backends, dashboard status dropdown + note field wired via a same-origin/api/triagenginx path. Triage field is OCSF-additive with tolerant-reader defaults. - RedisBus test parity —
services/shared/test_runner.pyparametrized so the full MemoryBus behavioral suite also runs against RedisBus in CI's redis-integration job.
Fixed
- Prefilter mis-bucketing of multi-class rules — the detector bucketed a rule under the first selection's
class_uid, so a rule spanning classes (e.g.(class 3002) OR (class 4001)) was never evaluated for the second class's events: a silent missed detection. Bucketing now probes the condition with the real T4 parser and only buckets under X when class X is provably necessary for any match (a and bwith classlessbstays bucketed;a or b, multi-class OR, and negations fall back to the always-evaluated catch-all). All 8 shipped rules keep their exact buckets — no shipped rule was affected; the bug bit only contributor-style multi-class rules. - Triage API lost-update race (single-replica) — concurrent
POST /alerts/{id}/triageto the same alert could silently drop one update: the read-modify-write overThreadingHTTPServer's one-thread-per-request model had no lock. Serialized the critical section with a process-wide write lock (triage writes are rare/cheap; GETs and writes to other alerts are unaffected). - Triage API lost-update race (multi-replica) — an in-process lock can't serialize two separate ws3 replicas racing on a shared OpenSearch cluster. Added a second OCC layer:
find_alert_versioned()retrieves_seq_no/_primary_termfrom OpenSearch;index_cas()writes with?if_seq_no=N&if_primary_term=M— a stale write gets HTTP 409 → the retry loop re-reads the fresh doc and re-applies (bounded at_CAS_MAX_RETRIES=5; exhaustion surfaces as an honest 409 to the client, never a silent drop). CAS wire format unit-tested via fake transport (test_storage_cas.py); MemoryStore gets a matching real version counter so tests and single-replica use the same interface. - Triage API note-clearing bug — a status-only update unconditionally overwrote
noteto"", silently wiping an existing analyst note.noteis now a true partial update: absent from the body → preserved; present as""→ deliberately cleared (a distinct, intentional action). - WS-6 inventory upsert race — the
SELECT-then-INSERTinInventoryStore.upsertwas not atomic; two concurrent observations of the same new MAC both saw no row and both inserted, the second hitting the primary key with anIntegrityErrorsurfaced as a 500. Serialized the read-modify-write with an in-process lock (concurrency regression test added). - Dashboard
renderGlobal()called asyncgetAlerts()withoutawait, so live-alert rendering operated on a Promise and threw in the browser — silently broken since live alerts shipped. storage/opensearch.pyusedurllib.parse.quote()without importingurllib.parse— the first real OpenSearchindex()call would have raisedAttributeError.storage/opensearch.py::find_alert()returned an empty dict on a hit with missing/empty_source; a triage update on such a hit re-indexed only the triage field and wiped the alert's original fields. Now returnsNone(treated as not-found).- Runner worker called
bus.consume()with the default 5 s Redisblock_ms, leaving it deaf to a shutdown set mid-block soserve()'s worker join could time out (CIredis-integrationhang). Now bounded by aconsume_block_ms(default 1 s) so shutdown latency stays under the join timeout.
CI
- Added
.gitleaks.tomlallowlisting canonical-UUID values so rule/entity identifiers (contracts/rules/*.ymlids and their test constants) don't trip thegeneric-api-keyheuristic. Default ruleset otherwise unchanged; real (non-UUID-shaped) secrets are still detected.
Added (v0.3 A5 — event enrichment)
- Offline event enrichment (
services/ws2-normalization/enrichment/) — a WS-2 post-normalize stage that adds OCSF-additive context to events from local data files only (no external calls; air-gap-safe):src_endpoint.reputation(score + categories) from a local IOC list (contracts/enrichment/ioc.yml, exact-IP and CIDR, longest-prefix match) andsrc_endpoint.location(country) from a local CIDR→country map (contracts/enrichment/geoip.yml, a lightweight stand-in for a full GeoIP DB, withINTERNALtagging for RFC1918). Additive and fail-open: it never overwrites a parser-set field, and a missing/malformed data file, bad IP, or any error leaves the event untouched and flowing — nothing hard-depends on these fields (tolerant readers). Enriched events still validate against Contract A. Wired intonormalize_one(parse → enrich → validate). Enriched fields added to the OpenSearch event mappings (common/bank/dc) so they're queryable. Unblocks reputation- and geo-keyed detection rules (a follow-up; no rule consumes these fields yet, so alert behavior is unchanged). 12 unit tests.
Added (v0.3 B4 — rule validation gate)
tools/validate_rules.py— a contributor-facing static validator forcontracts/rules/*.yml, wired intorun_all_tests.sh/CI. Reuses the real WS-4 engine's tokenizer/parser and operator set (so "valid" means exactly "the runtime will evaluate this") to check: schema (title, canonical-UUID id, level enum,siem.score_weightbounds, stateful window/threshold pairing), that theconditionparses under the T4 evaluator and references only defined selections, that every selection operator is one the engine implements (unknown operators rejected, not silently fail-closed at runtime), thatnot_inallowlists andoutside_hourswindows are well-formed and reference existing files, and that rule ids are unique. Complements the anti-dormancycheck_rule_producers.py. 20 unit tests (tools/test_validate_rules.py) — every check has an adversarial reject case.
Added (v0.3 B2 — backpressure)
- Ingest-edge shedding —
SyslogUDPServernow sheds excess datagrams via a token bucket (SYSLOG_MAX_EVENTS_PER_SEC, default 2000/s) before they ever reach the bus, rather than letting an unbounded flood grow the Redis stream toward OOM. UDP is connectionless, so shedding (not blocking) is the only lever at this edge; the shed-warning log is itself throttled to 1/sec so a flood can't become a logging DoS. - Stream-depth monitoring —
Bus.depth(topic)on both backends;services/ws1-collectors/main.pyruns a background watchdog logging a warning whenraw.eventsdepth crossesRAW_EVENTS_DEPTH_WARN(default 100000). Monitoring-only — the hard cap is the ingest-edge shedding above, not this watchdog. - No mid-pipeline
MAXLENtrimming was added or is planned — trimming would silently drop unconsumed events, an audit-completeness violation for a bank. - Zero-loss-under-flood fallback (opt-in) —
services/ws1-collectors/collectors/spool.py'sBoundedSpool: a FIFO, byte-capped, disk-backed JSONL queue. A shed or produce-failed datagram is spooled instead of lost whenSYSLOG_SPOOL_PATHis set (SYSLOG_SPOOL_MAX_BYTES, default 64 MiB); a background thread replays it into the bus in order once capacity/connectivity returns. Still bounded — once the spool itself is full, the event is truly lost, but distinctly counted (events_lost) rather than silently merged into the plain shed counter. Disabled by default.
v0.2.0
[0.2.0] - 2026-07-01
Added
- Generic syslog parser (
generic_syslog) — RFC 3164 syslog lines (with or without<PRI>) → OCSF, with PRI-severity mapping. Covers sources that don't match a product-specific parser. - Windows Event Log parser (
windows_eventlog) — broad coverage of security-relevant EventIDs (4624 logon, 4634/4647 logoff, 4688 process creation, 4672 special privileges) → OCSF. Complements the existing Active Directory 4625 parser without overlap. - Port-scan detection rule — fires when one source IP hits ≥15 distinct DENIED destination ports within 60s (OCSF Network Activity, activity_id 6). Restricted to denies for precision; open-port scans are intentionally out of scope.
- Lateral-movement detection rule — fires when one account successfully authenticates to ≥5 distinct destination hosts within 300s.
- Distinct-count windowing — new
hit_distinct()on both the deque (single-replica) and Redis (multi-replica, sorted-set) window counters, so rules can threshold on the number of distinct field values in a window, not just the event count. Rules opt in viasiem.distinct_fieldin YAML. - Real local-LLM triage (Ollama) — WS-5 now calls a local Ollama model (
OLLAMA_URL/OLLAMA_MODEL) for alert triage, returning a structured verdict, and degrades gracefully to the passthrough stub when Ollama is unset, unreachable, or returns malformed output. The acceptance test still runs stub-only with zero infra. - Real syslog UDP listener (WS-1) — collectors now accept live syslog datagrams (
SYSLOG_UDP_HOST/SYSLOG_UDP_PORT, default0.0.0.0:5514, now published indocker-compose.yml) and feed them intoraw.eventsfor the generic syslog parser, alongside the existing mock collection path.
Fixed
- Windows parser mapped the logon source and destination host to the same field, leaving the lateral-movement rule unable to ever fire on real data; auth events now correctly split
src_endpoint(logon origin) fromdst_endpoint.hostname(target host). - Cisco ASA parser dropped both endpoints on
denied from IP/port to IP/port-style deny messages (106001/106006/106015), making the port-scan rule blind to that message family; endpoint extraction now coverssrc/dst,for/to, andfrom/tosyntaxes.
Security
- Documented the two new v0.2 attack surfaces in
SECURITY.md: the syslog UDP listener is unauthenticated/spoofable by protocol design (keep it on a trusted network segment), and LLM triage output is advisory and enum-constrained but not immune to prompt injection. - Capped the Ollama HTTP response read at 1 MiB to bound memory use against a runaway/hostile local response.
Verified live (2026-07-01)
Full Docker stack (Docker Desktop 4.80.0 / engine 29.6.1): a real UDP syslog packet sent from the host to the newly-published 5514/udp port was received by the container's live listener and indexed; 15 Cisco ASA denies to distinct ports fired the port-scan rule; 5 Windows 4624 logons to distinct hosts fired the lateral-movement rule; the existing brute-force rule fired unaffected. All three produced a rule alert AND a WS-5 AI triage verdict (StubLLM, since no OLLAMA_URL was configured for this run — confirming the documented fallback path), and all three rendered live in the dashboard via GET /api/alerts.