api: GET /banner/summary — attempts/actor/succeeded/queue counts + pressure strip (Part 1 of #55) - #99
Merged
Conversation
…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.
This was referenced Jul 17, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-0070W_STATE(24h) window, computed fromfirewatch_core.attempts(the D1 attempt predicate).succeeded_count— the correctness crux: the success set is Tier-1 verdicts UNION actors carrying acritical-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 sharedattempts.pymodule — this PR does not touchescalation/decider.py,escalation/qualify.py, or scoring/pipeline logic.routes/banner.pymirrorspipeline.analyze_ip's exact window slicing (W_STATE/W_CAMPAIGN) so this endpoint's counts are always consistent with the per-actorThreatScore.escalationtier 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_logincompromise on a pure SSH box is a Tier-2 verdict (its loudness is the rule'sseverity="critical"registration, not the tier number). Binding "succeeded" totier == 1would read "0 succeeded" while the compromise rule is firing — the worst possible false calm. The success set is:act=permittedALLOW + SSH brute force, same IP) → also Tier 1 via the per-actor gate → first arm.brute_force_then_login→ Tier 2, Tier 1 structurally unreachable → second arm only (the must-not regression pin).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 inapp.py.firewatch_api.schemas.BannerAttemptSummary/PressureEntry— new additive response shapes.frontend/vite.config.ts—/banneradded 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.jsonstays byte-identical (sha256fe4787643955c920e934e3789c79f741cd8c8cde6b2adbc6540b66ff3743f31f).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_issue_650_escalation_policy_route.pyat 600 lines).Test plan
packages/firewatch-api/tests/test_issue_55_banner_attempts_summary.py:queue_size: Tier-1/2 count, observed (tier=None) excluded, Tier-3 (blocked_persistent) excluded.attempt_count/actor_count: matches a directis_attempt()tally; zero-attempt actors excluded fromactor_countbut not fromsucceeded_count/queue_size; caller-supplied windowing respected.top_pressure: bounded to 5, ranked by peak intensity descending, engine-integers-only fields, span_minutes derivation.bash scripts/gates-backend.sh— green (see report below).packages/firewatch-api/tests(885 passed) and targeted new suite (21 passed) run in isolation beforehand.TriageBanner.tsxconsumption of this endpoint — follow-on commit on this branch (ui-dev).🤖 Generated with Claude Code