Skip to content

Releases: supermhel/fengarde

v0.6.0

Choose a tag to compare

@supermhel supermhel released this 27 Aug 21:59

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-de...
Read more

v0.1.0

Choose a tag to compare

@supermhel supermhel released this 27 Aug 21:59

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 OCSF Authentication event.

  • 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 Bus interface 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 /health HTTP 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

Choose a tag to compare

@supermhel supermhel released this 28 Jul 22:21
c3ec328

[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 || true on
    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/prom exposition-
    format route (hand-rolled, stdlib-only — no new runtime dependency),
    opt-in observability compose 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_id path-injection in rules_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
    via FENGARDE_ADMIN_PASSWORD (the service never generates/logs/stores plaintext;
    unset + empty store now fails closed with a loud warning). Open CodeQL alert count
    on main: 0.
  • Three new CI gates: pip-audit (CVE audit of every pinned requirements.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-privilege permissions: 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 of pip-audit's
    CVE-driven gate — a deliberate choice, not an oversight; see the pinning note above.
  • Fixed since PR#2 landed red: ruff F401 in runner.py; ossf/scorecard-action@v2
    pinned to v2.4.3 (no such floating tag existed); quality job 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-
    syntax ilm-policies.json that never worked on this stack), rewired
    infra/provision.sh to real idempotent PUTs. Also found and fixed a
    second, older bug the same live run surfaced: assets/events-bank/
    events-dc/alerts templates each had a top-level _comment field
    OpenSearch's real _index_template PUT rejects outright — silently
    masked forever by curl -sf swallowing the error. Only events-common
    had ever actually installed on a live cluster before this fix.
  • Redis-backed RBAC sessions: services/shared/sessions.py gained
    RedisSessionStore + a make_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 second plan(), and mapping_version round-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.py passed 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-
    validated mitre: {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.py computed 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's getAlerts() never mapped
    rule_id through.
  • 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 existing db_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}, and common_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 when FENGARDE_RBAC_DB is 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/me 404s (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 the hidden attribute
    (toggling .hidden in JS did nothing), and clearing an inline style.display with
    "" fell back to a stylesheet rule that was still none instead 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 existing SameSite=Strict: login now also mints a csrf_token (returned
    in the login/. /auth/me response body, never a cookie), and every state-changing
    (POST) request made with an active session must echo it back as X-CSRF-Token or
    get a 403 — enforced c...
Read more

v0.4.0

Choose a tag to compare

@supermhel supermhel released this 27 Aug 21:59

Added (v0.4 Track S — opt-in auth)

  • FENGARDE_API_KEY shared-secret auth on the WS-3 triage API and WS-6 inventory API (X-Api-Key header, 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 bundle shared, so it gets its own copy).
  • Dashboard basic-auth, opt-in via infra/docker-compose.auth.yml override (nginx auth_basic + htpasswd) — not baked into the main compose file, so docker compose up stays zero-prerequisite.
  • Redis AUTH, opt-in via REDIS_PASSWORD, embedded in REDIS_URL for every service.
  • Dashboard nginx converted to an envsubst template (templates/default.conf.template) so it can inject X-Api-Key server-side on the triage proxy — the browser never holds the key.
  • OpenSearch/Redis/OpenSearch-Dashboards ports bound to 127.0.0.1 by default (were 0.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 with fengarde-sec: POST/GET /alerts/{id}/report on the WS-3 triage port, REPORT_BACKEND=template|http seam, frozen response schema. Hard rules enforced structurally: status must be "draft", disclaimer mandatory non-empty, citations optional (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, deterministic report_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 (reuses outside_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.py updated to run each fixture through the real enrich() 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_by field 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 missing distinct_field value diverged across backends: memory counted None as 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 in contracts/sigma-convention.md.
  • Report route: body not drained on malformed Content-Length — an unparseable Content-Length header on POST /alerts/{id}/report was silently zeroed, leaving stray body bytes buffered (keep-alive connection corruption risk). Now a 400, mirroring the triage route.
  • Cross-source rule-scoping bugsagent_tool_call_burst, ot_write_outside_maintenance, and ot_new_engineering_connection keyed only on class_uid/activity_id (or grouped on a field only one source sets). Landing a third class_uid: 6003 producer (n8n_audit) alongside the existing vmware_vsphere/mcp_agent/opcua_audit surfaced 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 explicit siem.source_type selection. contracts/detection-coverage.md documents 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 demo banner fixed to reflect reality — devkit-feeder already injects a real SSH brute-force burst on every docker 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

Choose a tag to compare

@supermhel supermhel released this 27 Aug 21:59

Added

  • DB-audit parser (db_audit) — vendor-agnostic database audit logs → OCSF Datastore Activity (6005), activity_id 5 for GRANT/REVOKE/ALTER. Un-dormants the bank_db_priv_esc rule, which matched a class no parser emitted.
  • Windows account-change coveragewindows_eventlog parser extended to EventIDs 4720/4722/4726/4728/4732 (Account Change, class 3003), with acting admin in actor.user and target account in unmapped.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-daygt/gte/lt/lte/ne operators, a not_in: <allowlist> suppression clause (contracts/allowlists/*.yml, CIDR + exact match), and an outside_hours time-of-day/day-of-week predicate (with tz_offset_minutes and 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 in contracts/sigma-convention.md.
  • Rule prefilter — the detector buckets rules by their class_uid equality 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, in run_all_tests.sh) — proves each rule's equality selections/group_by/distinct_field are 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_PORT default 8013), find_alert() cross-index lookup in both storage backends, dashboard status dropdown + note field wired via a same-origin /api/triage nginx path. Triage field is OCSF-additive with tolerant-reader defaults.
  • RedisBus test parityservices/shared/test_runner.py parametrized 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 b with classless b stays 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}/triage to the same alert could silently drop one update: the read-modify-write over ThreadingHTTPServer'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_term from 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 note to "", silently wiping an existing analyst note. note is 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-INSERT in InventoryStore.upsert was not atomic; two concurrent observations of the same new MAC both saw no row and both inserted, the second hitting the primary key with an IntegrityError surfaced as a 500. Serialized the read-modify-write with an in-process lock (concurrency regression test added).
  • Dashboard renderGlobal() called async getAlerts() without await, so live-alert rendering operated on a Promise and threw in the browser — silently broken since live alerts shipped.
  • storage/opensearch.py used urllib.parse.quote() without importing urllib.parse — the first real OpenSearch index() call would have raised AttributeError.
  • 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 returns None (treated as not-found).
  • Runner worker called bus.consume() with the default 5 s Redis block_ms, leaving it deaf to a shutdown set mid-block so serve()'s worker join could time out (CI redis-integration hang). Now bounded by a consume_block_ms (default 1 s) so shutdown latency stays under the join timeout.

CI

  • Added .gitleaks.toml allowlisting canonical-UUID values so rule/entity identifiers (contracts/rules/*.yml ids and their test constants) don't trip the generic-api-key heuristic. 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) and src_endpoint.location (country) from a local CIDR→country map (contracts/enrichment/geoip.yml, a lightweight stand-in for a full GeoIP DB, with INTERNAL tagging 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 into normalize_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 for contracts/rules/*.yml, wired into run_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_weight bounds, stateful window/threshold pairing), that the condition parses 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), that not_in allowlists and outside_hours windows are well-formed and reference existing files, and that rule ids are unique. Complements the anti-dormancy check_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 sheddingSyslogUDPServer now 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 monitoringBus.depth(topic) on both backends; services/ws1-collectors/main.py runs a background watchdog logging a warning when raw.events depth crosses RAW_EVENTS_DEPTH_WARN (default 100000). Monitoring-only — the hard cap is the ingest-edge shedding above, not this watchdog.
  • No mid-pipeline MAXLEN trimming 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's BoundedSpool: a FIFO, byte-capped, disk-backed JSONL queue. A shed or produce-failed datagram is spooled instead of lost when SYSLOG_SPOOL_PATH is 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

Choose a tag to compare

@supermhel supermhel released this 01 Jul 18:11

[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 via siem.distinct_field in 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, default 0.0.0.0:5514, now published in docker-compose.yml) and feed them into raw.events for 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) from dst_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 covers src/dst, for/to, and from/to syntaxes.

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.