Skip to content

api: GET /banner/summary — attempts/actor/succeeded/queue counts + pressure strip (Part 1 of #55) - #99

Merged
gterdem merged 2 commits into
mainfrom
issue-55-attempts-banner
Jul 17, 2026
Merged

gterdem merged 2 commits into
mainfrom
issue-55-attempts-banner

Conversation

@gterdem

@gterdem gterdem commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

Part 1 of #55 — backend banner feed; frontend follows on this branch.

Adds a new additive read endpoint (GET /banner/summary, ADR-0029) feeding the dashboard triage banner:

  • attempt_count / actor_count — over the ADR-0070 W_STATE (24h) window, computed from firewatch_core.attempts (the D1 attempt predicate).
  • succeeded_count — the correctness crux: the success set is Tier-1 verdicts UNION actors carrying a critical-severity qualifying detection — never Tier-1 alone (ADR-0070 D3 tier-attribution correction, 2026-07-16).
  • queue_size (K) — actors carrying a Tier-1 or Tier-2 escalation verdict.
  • top_pressure — bounded (≤ 5) (source_ip, attempt_count, span_minutes) rows, ranked by peak decayed intensity (λ̂ is used only as an internal ranking key — never itself serialized, per ADR-0035 "engine integers only").

All counts are read-only derivations over the existing detect()/decide() verdicts and the shared attempts.py module — this PR does not touch escalation/decider.py, escalation/qualify.py, or scoring/pipeline logic. routes/banner.py mirrors pipeline.analyze_ip's exact window slicing (W_STATE/W_CAMPAIGN) so this endpoint's counts are always consistent with the per-actor ThreatScore.escalation tier already shown in the triage-banner chips — the banner can never count differently than the engine.

Why the succeeded-set union (not Tier-1 alone)

Per the issue's 2026-07-16 correction: Tier 1 requires a literal ALLOW event, and no host-auth source (syslog/linux_auth) ever emits ALLOW. So a brute_force_then_login compromise on a pure SSH box is a Tier-2 verdict (its loudness is the rule's severity="critical" registration, not the tier number). Binding "succeeded" to tier == 1 would read "0 succeeded" while the compromise rule is firing — the worst possible false calm. The success set is:

succeeded := tier == 1  OR  any(detection.severity == "critical" for detection in detections)
  • Traffic-source actor (ALLOW + detection) → Tier 1 → first arm.
  • Mixed-telemetry actor (e.g. CEF firewall act=permitted ALLOW + SSH brute force, same IP) → also Tier 1 via the per-actor gate → first arm.
  • Pure host-auth actor firing brute_force_then_login → Tier 2, Tier 1 structurally unreachable → second arm only (the must-not regression pin).
  • Ordinary failed-login-only actor (no success, no critical detection) → neither arm → correctly NOT succeeded.

New surface

  • firewatch_api.banner_assembler — pure aggregation module (ActorAttemptStats, compute_actor_attempt_stats, assemble_banner_attempt_summary). No I/O; unit-testable in isolation.
  • firewatch_api.routes.banner — GET /banner/summary, registered in app.py.
  • firewatch_api.schemas.BannerAttemptSummary / PressureEntry — new additive response shapes.
  • frontend/vite.config.ts — /banner added to the dev-proxy allowlist (new route prefix).

No existing route or response shape changes shape (ADR-0029 additive rule). tests/golden/fixtures/expected_scores.json stays byte-identical (sha256 fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f).

Structure self-check

  • banner_assembler.py: 242 lines, one concern (pure aggregation).
  • routes/banner.py: 143 lines, one concern (I/O boundary + window slicing).
  • Test file: 501 lines (single-issue test suite, consistent with existing test_issue_650_escalation_policy_route.py at 600 lines).

Test plan

  • New tests (21) in packages/firewatch-api/tests/test_issue_55_banner_attempts_summary.py:
    • Success-set union: host-auth must-not pin, traffic-source Tier-1, mixed-telemetry Tier-1-via-unrelated-ALLOW, ordinary non-qualifying actor, qualified-but-non-critical Tier-2 (negative case).
    • queue_size: Tier-1/2 count, observed (tier=None) excluded, Tier-3 (blocked_persistent) excluded.
    • attempt_count/actor_count: matches a direct is_attempt() tally; zero-attempt actors excluded from actor_count but not from succeeded_count/queue_size; caller-supplied windowing respected.
    • top_pressure: bounded to 5, ranked by peak intensity descending, engine-integers-only fields, span_minutes derivation.
    • Route-level: empty store → all-zero summary (calm state unchanged), no store → 503, end-to-end host-auth regression pin, response shape.
  • bash scripts/gates-backend.sh — green (see report below).
  • Full packages/firewatch-api/tests (885 passed) and targeted new suite (21 passed) run in isolation beforehand.
  • Frontend TriageBanner.tsx consumption of this endpoint — follow-on commit on this branch (ui-dev).

🤖 Generated with Claude Code

gterdem added 2 commits July 17, 2026 00:50
…essure strip (issue #55, Part 1/backend)

Adds a new additive read endpoint (ADR-0029) for the dashboard triage banner:
attempt_count and actor_count over the state window, succeeded_count, queue_size
(K), and a bounded top-N (<=5) pressure strip — every count computed
server-side from firewatch_core.attempts (the ADR-0070 D1 attempt predicate)
plus the existing detect()/decide() verdicts, so the banner can never count
differently than the escalation engine.

succeeded_count implements the ADR-0070 D3 tier-attribution correction
(2026-07-16): the success set is Tier-1 verdicts UNION actors carrying a
critical-severity qualifying detection — never Tier-1 alone. A host-auth
actor (syslog/linux_auth, which never emits ALLOW) firing the critical
brute_force_then_login rule is Tier 2, not Tier 1, so binding "succeeded" to
tier==1 would read "0 succeeded" during an active compromise. Pinned by a
must-not regression test using real host-auth event shapes (ALERT failures +
LOG success), plus tests covering both union arms (traffic-source Tier-1,
mixed-telemetry actor reaching Tier 1 via an unrelated ALLOW) and the negative
cases (ordinary failed-login-only actor, qualified-but-non-critical Tier-2).

New module firewatch_api.banner_assembler is pure aggregation over
already-computed verdicts/detections/attempts — it never re-derives what
qualifies as an attempt, tier, or detection. routes/banner.py mirrors
pipeline.analyze_ip's exact window slicing (W_STATE/W_CAMPAIGN) so this
endpoint's counts are always consistent with the per-actor ThreatScore
verdicts already shown in the triage-banner chips.

No existing route/shape changed (additive-only, ADR-0029); tests/golden
stays byte-identical (sha256 fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f).
Adds /banner to the vite dev-proxy allowlist.

Frontend TriageBanner consumption (rendering the headline sentence + pressure
strip UI) follows in a subsequent commit on this same branch.

Part 1 of #55 — backend banner feed; frontend follows on this branch.
@gterdem
gterdem merged commit 3e46690 into main Jul 17, 2026
3 of 4 checks passed
gterdem added a commit that referenced this pull request Jul 17, 2026
…d) (#101)

Consumes GET /banner/summary (merged Part 1, #99) to render the dashboard's
attempts headline — "N hostile attempts from M actors — S succeeded · K need
review" — plus a bounded top-5 pressure strip, in the TriageBanner slot #43's
"N detections on the record" line occupies.

New components/dashboard/AttemptsHeadline.tsx renders the headline + strip
entirely from server-provided integers; it contains zero counting/derivation
logic (ADR-0070 D3 hard constraint — the banner must never count differently
than the engine). "0 succeeded" (and any nonzero succeeded_count — the
breach-visible case) comes straight from succeeded_count. lib/escalationCopy.ts
gains attemptsHeadlineText/pressureRowText, the sole place the sentence/row
copy is assembled. TriageBanner supersedes the #43 line with this one, in the
same slot, only when attempt_count > 0; when null/zero, the #43 line renders
unchanged (verified via new wiring tests).

Pressure strip rows: actor IP (links to entity detail via ClickableIp),
attempt count, and span in minutes — no decision verbs, bounded to <=5 rows
(defensively re-capped client-side too), remainder links to Network Logs, no
inner scrollbar.

Strategist "peak pressure X of Y" condition: GET /banner/summary exposes only
attempt_count/span_minutes per row today (no peak-vs-threshold pair) — this
renders what IS available as plain text integers and does not estimate the
missing pair client-side. Flagged as a follow-up additive backend field in
the PR description.

docs/guide/dashboard.md and FAQ.md document the headline's exact semantics
(including the ADR-0070 D3 succeeded-set correction); escalation-and-triage-model.md
gets the matching addition since both docs point there for depth.

Tests: AttemptsHeadline.test.tsx (headline/succeeded/strip/links/no-scrollbar/
security), TriageBanner.test.tsx (supersede-in-slot wiring), DashboardRoute.test.tsx
(fetch -> prop wiring, graceful degradation), escalationCopy.test.ts, client.test.ts.
readFixtures.ts gains BANNER_SUMMARY_EMPTY/ACTIVE/SUCCEEDED. No backend/golden impact.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant