You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Generalize the two correlation rules that are currently welded to one plugin's labels: brute_force_then_login (the CRITICAL "probable compromise" rule) and ids_then_brute_force.
Their selectors move from private category strings and hard-coded source_type names to
contract fields with published vocabularies: outcome (#77) and ocsf_class (#76). Also
retires PR #73's marked-interim selector union.
Why
brute_force_then_login is the loudest verdict in the product — and it is load-bearing in the
intensity model: an attacker who succeeds stops attempting, so the intensity states fade at
the exact moment the situation got worse (the fade-on-success inversion, ADR-0070). Today the
rule only sees events labeled with the syslog plugin's exact strings, and ids_then_brute_force
discards non-syslog/non-suricata events before it even reads the category. Any new
auth-capable source is silently excluded from the path that catches compromise. After this
issue, every source that sets outcome + the Authentication class joins automatically — no
plugin names anywhere in core.
Context
ADR-0071 (draft, in review — link activates when the ADR batch is committed to docs/adr/):
D3 (the selectors, the routing-vocabulary discipline), retire list (grep-derived).
ADR-0070 (Revision 1)
— D3's CRITICAL-row reachability caveat and the distribution table's fade-on-success row
(2026-07-16 corrections) name this defect.
Skill gate: ai-engine-invariants before touching detector.py.
Acceptance criteria
_brute_force_then_login SHALL select failures as ocsf_class == 3002 and outcome == "failure" and successes as ocsf_class == 3002 and outcome == "success"; thresholds and the 30-min window are verbatim unchanged (≥3 failures, ≥1 success after the last failure).
_ids_then_brute_force SHALL replace its named-source corroboration with: ≥1 ALERT
event from a different source_type than the auth-failure events, coinciding within
the existing 10-min window with ≥3 failed auth attempts (selector as above); thresholds
and score deltas verbatim unchanged.
WHEN a linux_auth actor produces ≥3 outcome=failure auth events followed by an outcome=success within 30 min, brute_force_then_login SHALL fire (the PR source: Linux auth & intrusion signals (linux_auth) — local mode #73
reachability scenario, now without the selector union).
Must-NOT: rule names, registered severities, auto_escalate flags, score deltas, and ESCALATION_POLICY entries SHALL NOT change.
Must-NOT:tests/golden/fixtures/expected_scores.json SHALL NOT move
(detection_rule_names: [] is pinned throughout; any drift is a regression by
definition).
Must-NOT: events with outcome=None or a non-3002 class SHALL NOT be selected as
auth failures/successes (no fabricated reachability — a source that doesn't declare
outcomes stays out, by design).
Tests SHALL cover: the syslog-only path (unchanged behavior), the linux_auth path
(new reachability), a mixed-source corroboration case, and the must-NOTs above.
Summary
Generalize the two correlation rules that are currently welded to one plugin's labels:
brute_force_then_login(the CRITICAL "probable compromise" rule) andids_then_brute_force.Their selectors move from private category strings and hard-coded
source_typenames tocontract fields with published vocabularies:
outcome(#77) andocsf_class(#76). Alsoretires PR #73's marked-interim selector union.
Why
brute_force_then_loginis the loudest verdict in the product — and it is load-bearing in theintensity model: an attacker who succeeds stops attempting, so the intensity states fade at
the exact moment the situation got worse (the fade-on-success inversion, ADR-0070). Today the
rule only sees events labeled with the syslog plugin's exact strings, and
ids_then_brute_forcediscards non-
syslog/non-suricataevents before it even reads the category. Any newauth-capable source is silently excluded from the path that catches compromise. After this
issue, every source that sets
outcome+ the Authentication class joins automatically — noplugin names anywhere in core.
Context
docs/adr/):D3 (the selectors, the routing-vocabulary discipline), retire list (grep-derived).
— D3's CRITICAL-row reachability caveat and the distribution table's fade-on-success row
(2026-07-16 corrections) name this defect.
main):detector.py:110,113(source_type == "suricata"/== "syslog"+category == "SSH Brute Force"),detector.py:141,145(category-stringselectors).
and Contract/SDK: SecurityEvent.outcome — the auth-outcome vocabulary (ADR-0071 D1/D2/D4) #77 (the
outcomefield).work as their retirer).
ai-engine-invariantsbefore touchingdetector.py.Acceptance criteria
_brute_force_then_loginSHALL select failures asocsf_class == 3002 and outcome == "failure"and successes asocsf_class == 3002 and outcome == "success"; thresholds and the 30-min window areverbatim unchanged (≥3 failures, ≥1 success after the last failure).
_ids_then_brute_forceSHALL replace its named-source corroboration with: ≥1ALERTevent from a different
source_typethan the auth-failure events, coinciding withinthe existing 10-min window with ≥3 failed auth attempts (selector as above); thresholds
and score deltas verbatim unchanged.
outcome=failureauth events followed by anoutcome=successwithin 30 min,brute_force_then_loginSHALL fire (the PR source: Linux auth & intrusion signals (linux_auth) — local mode #73reachability scenario, now without the selector union).
SecurityEvent.categoryto a literal — verified mechanically:grep -n '"SSH Brute Force"\|"SSH Login"' packages/firewatch-core/src/returns onlycomments/docstrings (PR source: Linux auth & intrusion signals (linux_auth) — local mode #73's burst/intense pair is exempt only if Core: attempt intensity — attempts.py predicate + decayed-rate estimator + attempt_pressure (retires sustained_attack) #53/Core: attack_in_progress + campaign — intensity queue entry (HIGH ALERT) via the assertion gate #54 have not yet
retired it; those rules carry their own retirement).
auto_escalateflags, score deltas, andESCALATION_POLICYentries SHALL NOT change.tests/golden/fixtures/expected_scores.jsonSHALL NOT move(
detection_rule_names: []is pinned throughout; any drift is a regression bydefinition).
outcome=Noneor a non-3002 class SHALL NOT be selected asauth failures/successes (no fabricated reachability — a source that doesn't declare
outcomes stays out, by design).
(new reachability), a mixed-source corroboration case, and the must-NOTs above.
Out of scope
outcomefield and contract text (Contract/SDK: SecurityEvent.outcome — the auth-outcome vocabulary (ADR-0071 D1/D2/D4) #77); the OCSF class values (Sources: OCSF class correction — auth events are Authentication (3002/3), not Network Activity (4001/4) #76)._ssh_login_failure_burst/_ssh_login_failure_intenseinterim rules — retired byCore: attempt intensity — attempts.py predicate + decayed-rate estimator + attempt_pressure (retires sustained_attack) #53/Core: attack_in_progress + campaign — intensity queue entry (HIGH ALERT) via the assertion gate #54 (ADR-0070's retire list), not here.
only afterward) but deliberately not bundled.
corroboration) — new design, new discussion.