Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions TRACE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# FR-A1..A12 → Code / Test Trace (M8 Admin Control-Plane UI)

> Generated companion to `tests/test_fr_trace.py` (the executable source of
> truth). design.md rev2 §1 D8 / §8 M6. Each FR maps to concrete implementation
> sites and to automated test nodes that exist and gate it. `test_fr_trace.py`
> parses each cited node (pytest fn via `ast`, node:test title via regex) so a
> renamed/deleted test breaks this trace instead of rotting.

Test-node id forms:
- `tests/<file>.py::test_<name>` — pytest node id (in-process WSGI / static parser).
- `src/.../ui/test/<file>.mjs > "<title>"` — `node --test` case title (pure JS logic).

## FR-A1

Live consume: the UI consumes the read API as a live read; every poll reflects the store's current value.

Code:
- `src/security_scanner/runtime/ui/static/app.js`
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `src/security_scanner/runtime/ui/test/test_render_helpers.mjs > "cadence: FETCH_TIMEOUT_MS < POLL_INTERVAL_MS (no pile-up)"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "hasFindings: key present (null/[]/array) → true"`

## FR-A2

Serving: the M7 WSGI app is served by a wsgiref loopback server with same-origin static routes (no CORS, no new deps).

Code:
- `src/security_scanner/runtime/dashboard.py`
- `src/security_scanner/cli/commands/dashboard.py`
- `src/security_scanner/cli/app.py`

Tests:
- `tests/test_dashboard_server.py::test_all_routes_200`
- `tests/test_dashboard_server.py::test_nonloopback_bind_rejected`
- `tests/test_cli.py::test_subcommand_registration_order_is_stable`

## FR-A3

GET /findings route (disposition query), delegates to read_findings_panel, returns redacted DTOs.

Code:
- `src/security_scanner/runtime/read_api.py`

Tests:
- `tests/test_read_api_findings_route.py::test_findings_route_filters_by_disposition`
- `tests/test_read_api_findings_route.py::test_findings_route_invalid_disposition_returns_400_before_store`
- `tests/test_read_api_findings_route.py::test_findings_route_no_filter_returns_all`

## FR-A4

Health-at-a-glance panel: freshness rollup; available=false -> '미평가' (never 0); breach/staleness are first-class signals.

Code:
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "freshnessPanelModel: available=false → muted 미평가, never zeros"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "selectDisplayValue: available=false renders 미평가, never 0"`

## FR-A5

Coverage panel: org N/M, gap, excluded/not-included.

Code:
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "coveragePanelModel: gap>0 → warn"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "coveragePanelModel: gap=0 → ok"`

## FR-A6

Queue backlog panel: backlog + per-status counts (pending/leased/completed/dead_letter).

Code:
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "backlogPanelModel: non-empty → warn with per-status fields"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "backlogPanelModel: empty queue → empty state"`

## FR-A7

Findings panel: disposition multi-filter, server-authoritative sort unreviewed->verified->false_positive, redacted (secretHash only), no-pagination awareness.

Code:
- `src/security_scanner/runtime/read_api.py`
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `tests/test_read_api_findings_route.py::test_findings_route_orders_by_dashboard_disposition_order`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "findingsPanelModel: present rows → ok, 10 wire keys verbatim, order preserved"`
- `src/security_scanner/runtime/ui/test/test_render_helpers.mjs > "selectedDispositions: zero checked → [] (caller sends no param → all)"`

## FR-A8

Staleness visibility: elapsed-since-evaluatedAt + breach always shown ('last updated Ns ago', color). Silence is impossible.

Code:
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "computeStalenessMs: null evaluatedAt → Infinity (never 0)"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "stalenessClass: crit above crit threshold and Infinity"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "relativeTime: null/invalid → em-dash, never '0초 전'"`

## FR-A9

State handling: loading/empty/error/stale/미평가 each explicitly rendered; findings key-absent != null distinguished.

Code:
- `src/security_scanner/runtime/ui/static/logic.mjs`
- `src/security_scanner/runtime/ui/static/index.html`

Tests:
- `tests/test_dashboard_a11y_static.py::test_every_state_has_a_status_class`
- `tests/test_dashboard_a11y_static.py::test_every_state_carries_a_non_color_cue`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "findingsPanelModel: key absent → loading"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "findingsPanelModel: present empty [] → empty"`

## FR-A10

Polling: auto N-second poll + manual refresh; on failure retry/backoff, keep last value + a 'stale' marker.

Code:
- `src/security_scanner/runtime/ui/static/app.js`
- `src/security_scanner/runtime/ui/static/logic.mjs`

Tests:
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "backoffDelay: grows exponentially"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "backoffDelay: capped at BACKOFF_MAX_MS"`
- `src/security_scanner/runtime/ui/test/test_render_helpers.mjs > "cadence: FETCH_TIMEOUT_MS < POLL_INTERVAL_MS (no pile-up)"`

## FR-A11

Accessibility: WCAG AA contrast, keyboard nav, aria-live for live updates, responsive.

Code:
- `src/security_scanner/runtime/ui/static/style.css`
- `src/security_scanner/runtime/ui/static/index.html`

Tests:
- `tests/test_dashboard_a11y_static.py::test_style_tokens_match_design_values`
- `tests/test_dashboard_a11y_static.py::test_required_landmarks_present`
- `tests/test_dashboard_a11y_static.py::test_announcers_present_and_distinct`
- `tests/test_dashboard_a11y_static.py::test_heading_levels_do_not_skip`
- `tests/test_dashboard_a11y_static.py::test_style_density_and_a11y_primitives`

## FR-A12

Alert surface: the UI surfaces breach/staleness visually only (dispatch is the out-of-scope M9 sink).

Code:
- `src/security_scanner/runtime/ui/static/logic.mjs`
- `src/security_scanner/runtime/ui/static/index.html`

Tests:
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "alertModel: breach>0 (fresh) → crit alert"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "alertModel: crit staleness (never evaluated) → crit alert even with 0 breaches"`
- `src/security_scanner/runtime/ui/test/test_ui_logic.mjs > "alertModel: fresh + 0 breaches → no alert (quiet)"`

7 changes: 6 additions & 1 deletion src/security_scanner/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import sys

from security_scanner.cli.commands import (
dashboard,
disposition,
doctor,
migrate,
Expand Down Expand Up @@ -41,13 +42,17 @@
disposition,
reconcile,
read_api,
dashboard,
)


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="security-scanner",
description="Local-first SCM secret scanner: scan repos, report findings, gate PRs.",
description=(
"Local-first SCM secret scanner: scan repos, "
"report findings, gate PRs."
),
)
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
for module in _COMMAND_MODULES:
Expand Down
70 changes: 70 additions & 0 deletions src/security_scanner/cli/commands/dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""dashboard subcommand: serve the live admin control-plane UI (M8, M2).

Binds a loopback HTTP server that serves the M7 read-API panels plus the static
thin client (design §5.3). DynamoDB backend only (the dashboard reads the live
queue/freshness/coverage panels, which jsonl does not back); jsonl exits 2. The
loopback bind IS the trust boundary — a non-loopback bind without authentication
is refused (ValueError) and surfaced here as exit 2, NOT a traceback.
"""

from __future__ import annotations

import argparse
import sys

from security_scanner.cli._args import add_incremental_storage_args
from security_scanner.cli._store import store_from_args
from security_scanner.runtime.dashboard import run_dashboard
from security_scanner.runtime.read_api import ReadApiServerConfig


def register(subparsers) -> None:
parser = subparsers.add_parser(
"dashboard",
help=(
"Serve the live admin control-plane UI on a loopback HTTP server "
"(read-only panels + static thin client; dynamodb backend only)."
),
)
# Backend flags only (storage-backend + dynamodb-*). host/port are NOT part
# of the shared helper, so the dashboard adds them directly here.
add_incremental_storage_args(parser)
parser.add_argument(
"--host",
default="127.0.0.1",
help=(
"Bind host (default: 127.0.0.1). The loopback bind is the trust "
"boundary; a non-loopback bind without authentication is refused."
),
)
parser.add_argument(
"--port",
type=int,
default=8787,
help="Bind port (default: 8787).",
)
parser.set_defaults(func=cmd_dashboard)


def cmd_dashboard(args: argparse.Namespace) -> int:
"""Resolve the store, build the bind config, and serve the dashboard."""
if args.storage_backend != "dynamodb":
print(
"error: dashboard supports --storage-backend dynamodb only",
file=sys.stderr,
)
return 2

config = ReadApiServerConfig(host=args.host, port=args.port)
try:
# The dashboard server is threaded and boto3 resources are not
# thread-safe, so hand run_dashboard a per-thread store factory (PR #56)
# rather than a single shared store.
run_dashboard(config, store_factory=lambda: store_from_args(args))
except ValueError as exc:
# F9 trust invariant: non-loopback bind without authentication.
print(f"error: {exc}", file=sys.stderr)
return 2
except KeyboardInterrupt:
return 0
return 0
Loading
Loading