From 434e16737de033f37c52b40abd3ecbd3a85cd84a Mon Sep 17 00:00:00 2001 From: pureliture Date: Sun, 21 Jun 2026 12:25:45 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(dashboard):=20M8=20admin=20control-pla?= =?UTF-8?q?ne=20UI=20=E2=80=94=20read-only=20live=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scale #2 M8/GATE3. read API(M7, PR #49) 위 thin client 운영자 대시보드. 정적 HTML+vanilla JS(빌드·node·새 dep 0), loopback HTTP 서빙. staleness가 항상 1급 신호로 렌더 → silent-staleness 사고의 구조적 수정("침묵 불가능"). - runtime/dashboard.py: ThreadingWSGIServer + 정적 서빙(null-byte/traversal 가드, resolve+is_relative_to, ext allowlist, nosniff, index no-store), SIGTERM/SIGINT 별도 스레드 graceful shutdown. - cli `dashboard`: loopback bind validate(비-loopback은 require_auth 없이 거부, exit 2), store_from_args 재사용, --host/--port. - read_api.py: GET /findings 라우트(app() 본문 분기, QUERY_STRING disposition 멀티필터, 무효 400, 서버 정렬 권위 order_findings_for_dashboard, secretHash만/raw secret 0). - ui/static: index.html/app.js/logic.mjs/style.css — 4패널(헬스 rollup/커버리지 org N·M/ 큐 백로그/findings disposition 필터), 6상태(미평가≠0), dark WCAG AA 토큰+never-color-alone, aria-live 티어, 폴링 5s+timeout+backoff+keep-last-good, prefers-reduced-motion. - 테스트: dashboard server/static/a11y-static, /findings route, FR-A1..A12 trace(TRACE.md), node --test 순수 로직(logic.mjs). 게이트: uv run pytest 1113 passed/4 skipped(env-gated smoke), node --test 43/43, ruff clean. 제품/설계 출처: grill-to-spec requirements.md/design.md(rev2, 멀티에이전트 리뷰 반영). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt --- TRACE.md | 163 +++++ src/security_scanner/cli/app.py | 7 +- .../cli/commands/dashboard.py | 68 ++ src/security_scanner/runtime/dashboard.py | 209 ++++++ src/security_scanner/runtime/read_api.py | 50 +- src/security_scanner/runtime/ui/static/app.js | 581 ++++++++++++++++ .../runtime/ui/static/index.html | 303 +++++++++ .../runtime/ui/static/logic.mjs | 407 +++++++++++ .../runtime/ui/static/style.css | 638 ++++++++++++++++++ .../runtime/ui/test/index.mjs | 29 + .../runtime/ui/test/package.json | 7 + .../runtime/ui/test/test_render_helpers.mjs | 106 +++ .../runtime/ui/test/test_ui_logic.mjs | 337 +++++++++ tests/test_cli.py | 1 + tests/test_dashboard_a11y_static.py | 319 +++++++++ tests/test_dashboard_server.py | 168 +++++ tests/test_dashboard_smoke.py | 296 ++++++++ tests/test_dashboard_static.py | 159 +++++ tests/test_fr_trace.py | 396 +++++++++++ tests/test_read_api_findings_route.py | 268 ++++++++ 20 files changed, 4510 insertions(+), 2 deletions(-) create mode 100644 TRACE.md create mode 100644 src/security_scanner/cli/commands/dashboard.py create mode 100644 src/security_scanner/runtime/dashboard.py create mode 100644 src/security_scanner/runtime/ui/static/app.js create mode 100644 src/security_scanner/runtime/ui/static/index.html create mode 100644 src/security_scanner/runtime/ui/static/logic.mjs create mode 100644 src/security_scanner/runtime/ui/static/style.css create mode 100644 src/security_scanner/runtime/ui/test/index.mjs create mode 100644 src/security_scanner/runtime/ui/test/package.json create mode 100644 src/security_scanner/runtime/ui/test/test_render_helpers.mjs create mode 100644 src/security_scanner/runtime/ui/test/test_ui_logic.mjs create mode 100644 tests/test_dashboard_a11y_static.py create mode 100644 tests/test_dashboard_server.py create mode 100644 tests/test_dashboard_smoke.py create mode 100644 tests/test_dashboard_static.py create mode 100644 tests/test_fr_trace.py create mode 100644 tests/test_read_api_findings_route.py diff --git a/TRACE.md b/TRACE.md new file mode 100644 index 0000000..8f8e9ca --- /dev/null +++ b/TRACE.md @@ -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/.py::test_` — pytest node id (in-process WSGI / static parser). +- `src/.../ui/test/.mjs > ""` — `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)"` + diff --git a/src/security_scanner/cli/app.py b/src/security_scanner/cli/app.py index 3235390..9ca9879 100644 --- a/src/security_scanner/cli/app.py +++ b/src/security_scanner/cli/app.py @@ -12,6 +12,7 @@ import sys from security_scanner.cli.commands import ( + dashboard, disposition, doctor, migrate, @@ -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: diff --git a/src/security_scanner/cli/commands/dashboard.py b/src/security_scanner/cli/commands/dashboard.py new file mode 100644 index 0000000..649873f --- /dev/null +++ b/src/security_scanner/cli/commands/dashboard.py @@ -0,0 +1,68 @@ +"""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: + store = store_from_args(args) + run_dashboard(config, store=store) + 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 diff --git a/src/security_scanner/runtime/dashboard.py b/src/security_scanner/runtime/dashboard.py new file mode 100644 index 0000000..5d91dde --- /dev/null +++ b/src/security_scanner/runtime/dashboard.py @@ -0,0 +1,209 @@ +"""Live admin dashboard server: loopback WSGI + static thin client (M8, M2). + +Serves the M7 read-API panels (delegated to the existing +:func:`build_read_api_wsgi_app`) plus the static thin client (``index.html`` + +``app.js`` + ``logic.mjs`` + ``style.css``) over a single loopback HTTP server. +The dashboard owns a FIXED set of read-API route strings (it knows the route +strings, not the query logic) and serves everything else as static; read_api.py +owns the query/sort/redaction/DTO concerns (it is serving-unaware). + +Trust model (F9, design §2/§6): the loopback bind IS the trust boundary. A +non-loopback bind without authentication is refused by +:func:`security_scanner.runtime.read_api.validate_read_api_server_config` +(ValueError), surfaced by the CLI as exit 2. There is no per-request Host check — +the bind host is the boundary. +""" + +from __future__ import annotations + +import mimetypes +import pathlib +import signal +import socketserver +import threading +import wsgiref.simple_server +from typing import Any + +from security_scanner.runtime.read_api import ( + ReadApiServerConfig, + build_read_api_wsgi_app, + validate_read_api_server_config, +) + +# Read-API route strings the dashboard delegates to the M7 WSGI app. The +# dashboard knows ONLY the route strings (a fixed set), never the query logic; +# anything outside this set is served as a static asset. ``/findings`` is in the +# set so its QUERY_STRING parsing (which lives in read_api's app() body) is +# reached through delegation, not re-implemented here. +READ_ROUTES = frozenset( + {"/freshness", "/coverage", "/backlog", "/snapshot", "/findings"} +) + +# Static file extensions the dashboard will serve. An allowlist (not a denylist) +# so an unexpected file type is refused (403) rather than served with a guessed +# Content-Type. +_ALLOWED_SUFFIXES = frozenset( + {".html", ".js", ".mjs", ".css", ".json", ".ico", ".svg", ".woff2"} +) + +# Resolve the static root ONCE at module load (design §5.2): if an ancestor of +# the package dir is a symlink (e.g. macOS ``/var`` -> ``/private/var``), a +# per-request resolve of only the candidate would not match an unresolved root +# and ``is_relative_to`` would spuriously 403. Resolving both sides once keeps +# the containment check correct. +STATIC_ROOT = (pathlib.Path(__file__).parent / "ui" / "static").resolve() + +# ``.mjs`` is not in the stdlib mimetypes table on every platform; register it so +# ES modules are served as JavaScript (browsers refuse ``type=module`` otherwise). +mimetypes.add_type("application/javascript", ".mjs") +mimetypes.add_type("application/javascript", ".js") + + +class ThreadingWSGIServer( + socketserver.ThreadingMixIn, wsgiref.simple_server.WSGIServer +): + """A threaded WSGI server so one slow poll never blocks another (design §3). + + MRO matters: ``ThreadingMixIn`` MUST come first so its threaded + ``process_request`` overrides ``WSGIServer``'s synchronous one. ``daemon_threads`` + lets the process exit without joining live request threads; + ``allow_reuse_address`` avoids TIME_WAIT bind failures on quick restarts; + ``block_on_close`` drains in-flight request threads on shutdown so a SIGTERM + does not truncate a response mid-flight. + """ + + daemon_threads = True + allow_reuse_address = True + block_on_close = True + + +def make_dashboard_app(store: Any, static_dir: pathlib.Path): + """Build the dashboard WSGI app: read-API delegation + static serving. + + Dispatch is purely by ``PATH_INFO``: a path in :data:`READ_ROUTES` is + delegated to the M7 read-API WSGI app (which owns method handling — a non-GET + on a read route comes back 405 from read_api, not from here); everything else + is served as a static asset under ``static_dir`` by :func:`serve_static`. + """ + read_app = build_read_api_wsgi_app(store) + static_root = static_dir.resolve() + + def serve_static(path_info: str, start_response): + """Serve a static asset with the design §5.2 guard order (EXACT). + + Guard order is load-bearing: + 1. null-byte -> 400 BEFORE resolve (an unquoted NUL makes + ``Path.resolve()`` raise ValueError -> a 500; reject it first); + 2. resolve the candidate, then ``is_relative_to`` containment -> 403 + (both sides resolved so a symlinked ancestor cannot defeat it); + 3. extension allowlist -> 403; + 4. ``is_file`` -> 404; + 5. otherwise 200 with Content-Type / Content-Length / nosniff / + Cache-Control, returning a single-element ``[body]`` list so the + HTTP/1.0 server emits a correct Content-Length. + """ + if "\x00" in path_info: + return _respond(start_response, "400 Bad Request", b"bad request") + + rel = path_info.lstrip("/") or "index.html" + cand = (static_root / rel).resolve() + + if not cand.is_relative_to(static_root): + return _respond(start_response, "403 Forbidden", b"forbidden") + + if cand.suffix not in _ALLOWED_SUFFIXES: + return _respond(start_response, "403 Forbidden", b"forbidden") + + if not cand.is_file(): + return _respond(start_response, "404 Not Found", b"not found") + + body = cand.read_bytes() + content_type = mimetypes.guess_type(cand.name)[0] or "application/octet-stream" + # index.html must never be cached (it is the bootstrap shell that the + # poll loop re-validates against); other assets are immutable enough to + # cache for an hour. + cache_control = ( + "no-store" if cand.name == "index.html" else "max-age=3600" + ) + start_response( + "200 OK", + [ + ("Content-Type", content_type), + ("Content-Length", str(len(body))), + ("X-Content-Type-Options", "nosniff"), + ("Cache-Control", cache_control), + ], + ) + return [body] + + def app(environ: dict[str, Any], start_response): + path = environ.get("PATH_INFO", "/") + if path in READ_ROUTES: + return read_app(environ, start_response) + return serve_static(path, start_response) + + return app + + +def _respond(start_response, status: str, body: bytes): + """Emit a tiny text/plain response (Content-Length + nosniff) as ``[body]``.""" + start_response( + status, + [ + ("Content-Type", "text/plain; charset=utf-8"), + ("Content-Length", str(len(body))), + ("X-Content-Type-Options", "nosniff"), + ], + ) + return [body] + + +def run_dashboard( + config: ReadApiServerConfig, + *, + store: Any, + static_dir: pathlib.Path = STATIC_ROOT, +) -> None: + """Validate the F9 trust invariant, bind, serve, and shut down cleanly. + + Enforces the loopback/authn invariant via + :func:`validate_read_api_server_config` BEFORE any socket is bound (a + non-loopback bind without ``require_auth`` raises ValueError). SIGTERM AND + SIGINT are both handled, and the handler calls ``server.shutdown()`` from a + SEPARATE daemon thread: ``shutdown()`` blocks until ``serve_forever`` returns, + so calling it from the signal handler (which runs on the serving thread) + would deadlock. ``server_close()`` runs in ``finally`` so the socket is always + released. + """ + validate_read_api_server_config(config) + + app = make_dashboard_app(store, static_dir) + server = wsgiref.simple_server.make_server( + config.host, + config.port, + app, + server_class=ThreadingWSGIServer, + ) + + def _request_shutdown(_signum, _frame): + # shutdown() blocks until serve_forever() returns; run it off the serving + # thread to avoid a self-deadlock. + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGTERM, _request_shutdown) + signal.signal(signal.SIGINT, _request_shutdown) + + try: + server.serve_forever() + finally: + server.server_close() + + +__all__ = [ + "READ_ROUTES", + "STATIC_ROOT", + "ThreadingWSGIServer", + "make_dashboard_app", + "run_dashboard", +] + diff --git a/src/security_scanner/runtime/read_api.py b/src/security_scanner/runtime/read_api.py index 5e6bf06..737949a 100644 --- a/src/security_scanner/runtime/read_api.py +++ b/src/security_scanner/runtime/read_api.py @@ -38,8 +38,13 @@ from dataclasses import dataclass from typing import Any +from urllib.parse import parse_qs -from security_scanner.core.finding.model import Finding +from security_scanner.core.finding.model import ( + Disposition, + Finding, + order_findings_for_dashboard, +) from security_scanner.runtime.catalog_reconcile import coverage_gap_from_store from security_scanner.runtime.finding_query import ( FindingQueryRequest, @@ -425,6 +430,8 @@ def build_read_api_wsgi_app(store: Any): "/snapshot": lambda: read_dashboard_snapshot(store).to_dict(), } + valid_dispositions = tuple(d.value for d in Disposition) + def app(environ: dict[str, Any], start_response): if environ.get("REQUEST_METHOD", "GET") != "GET": body = json.dumps({"error": "method not allowed"}).encode("utf-8") @@ -434,6 +441,47 @@ def app(environ: dict[str, Any], start_response): ) return [body] path = environ.get("PATH_INFO", "/") + if path == "/findings": + # FR-A3: disposition-filtered findings panel. The route + QUERY_STRING + # parsing live HERE in app() (not the store-only lambda routes — a + # lambda cannot read the request). Pre-validate disposition values + # BEFORE touching the store so a typo'd filter returns a 400 JSON + # instead of letting read_findings' ValueError bubble into a 500. + params = parse_qs(environ.get("QUERY_STRING", "")) + requested = params.get("disposition", []) + invalid = [v for v in requested if v not in valid_dispositions] + if invalid: + body = json.dumps( + { + "error": "invalid disposition", + "invalid": invalid, + "allowed": list(valid_dispositions), + } + ).encode("utf-8") + start_response( + "400 Bad Request", + [("Content-Type", "application/json")], + ) + return [body] + # 0 values => no filter (None = all). NEVER pass an empty list: an + # empty disposition intersection would silently return 0 findings. + dispositions = requested if requested else None + req = FindingQueryRequest( + storage_backend="dynamodb", + dynamodb_config=None, + dispositions=dispositions, + ) + # FR-A7 sort authority: order raw Findings by dashboard emphasis + # (unreviewed -> verified -> false_positive) BEFORE DTO projection + # (design.md §5.1), matching order_findings_for_dashboard's declared + # Finding type, so the UI consumes a pre-sorted wire and never re-sorts. + findings = read_findings(req, store=store) + ordered = order_findings_for_dashboard(findings) + body = json.dumps( + {"findings": [finding_to_public_dto(f).to_dict() for f in ordered]} + ).encode("utf-8") + start_response("200 OK", [("Content-Type", "application/json")]) + return [body] handler = routes.get(path) if handler is None: body = json.dumps({"error": "not found", "path": path}).encode("utf-8") diff --git a/src/security_scanner/runtime/ui/static/app.js b/src/security_scanner/runtime/ui/static/app.js new file mode 100644 index 0000000..9afb4ff --- /dev/null +++ b/src/security_scanner/runtime/ui/static/app.js @@ -0,0 +1,581 @@ +// M4 — browser wiring/bootstrap for the security-scanner admin control-plane UI. +// +// CONTRACT (design.md rev2 §5.4 app.js / §4 Data Flow / §8 M4): +// This module owns ONLY browser concerns — fetch, timers, DOM mutation, +// announce, visibility, manual refresh. ALL data→data decisions live in the +// pure substrate `logic.mjs` (DOM/timer/window-free, `node --test`'d). The DOM +// is an OUTPUT projection of state, never the source of truth. +// +// Discipline enforced here: +// - fetchWithTimeout: AbortController, 4000ms (< 5000 poll interval). +// - fetchSnapshot: _pollInFlight lock (no overlap); on success store +// _lastGoodSnapshot + reset failures + fresh; on failure keep last-good + +// STALE banner + increment failures + backoffDelay reschedule. +// - startPolling/stopPolling: immediate first poll, then setInterval(5000). +// - applySnapshot → renderXxxPanel: targeted setTextIfChanged + data-severity +// (no innerHTML churn, no reflow of unchanged cells). +// - announce: clear → double-rAF → set (forces SR re-announcement). +// - manual refresh: disabled while a fetch is in flight. +// - visibilitychange: hidden → stopPolling; visible → startPolling (immediate). +// - init(): load-time DOM access guarded by `typeof document !== 'undefined'` +// so importing this module under node never throws. + +import { + POLL_INTERVAL_MS, + FETCH_TIMEOUT_MS, + backoffDelay, + hasFindings, + freshnessPanelModel, + coveragePanelModel, + backlogPanelModel, + findingsPanelModel, + alertModel, + computeStalenessMs, + stalenessClass, + relativeTime, + findingRowLabel, + selectedDispositions, +} from "./logic.mjs"; + +// --- module state (single object; DOM mirrors it, not vice-versa) ----------- + +const state = { + lastGoodSnapshot: null, // last successful /snapshot payload + lastFindings: null, // last successful /findings payload ('findings' present) + failures: 0, // consecutive poll failures (drives backoff) + pollInFlight: false, // re-entrancy lock for snapshot fetch + findingsInFlight: false, // re-entrancy lock for findings fetch + intervalId: null, // setInterval handle + backoffTimerId: null, // pending backoff setTimeout handle + stale: false, // current snapshot is stale (last fetch failed) +}; + +// --- DOM helpers (targeted, reflow-averse) ---------------------------------- + +/** + * Set textContent only when it actually changes — avoids layout thrash and + * spurious mutation observers on every 5s poll. + */ +function setTextIfChanged(el, text) { + if (!el) { + return; + } + const next = String(text); + if (el.textContent !== next) { + el.textContent = next; + } +} + +/** Set a data-* attribute only when it changes. */ +function setAttrIfChanged(el, name, value) { + if (!el) { + return; + } + const next = String(value); + if (el.getAttribute(name) !== next) { + el.setAttribute(name, next); + } +} + +/** Write a panel's data-field cells from a panelModel's fields list. */ +function writeFields(root, fields) { + if (!root) { + return; + } + for (const field of fields) { + const cell = root.querySelector(`[data-field="${field.key}"]`); + setTextIfChanged(cell, field.value); + } +} + +/** Apply a panelModel's severity + state to the panel root (never-color-alone). */ +function writePanelStatus(root, model) { + if (!root) { + return; + } + setAttrIfChanged(root, "data-severity", model.severity); + setAttrIfChanged(root, "data-state", model.state); +} + +// --- announce (clear → double-rAF → set) ------------------------------------ +// +// Screen readers only announce a live region when its text CHANGES. Re-emitting +// the same string is a no-op. Clearing then re-setting across two rAFs forces a +// fresh mutation the AT will pick up. We schedule on rAF (not setTimeout) so the +// clear is painted before the set. + +function announce(targetId, message) { + if (typeof document === "undefined") { + return; + } + const node = document.getElementById(targetId); + if (!node) { + return; + } + node.textContent = ""; + const raf = + typeof requestAnimationFrame === "function" + ? requestAnimationFrame + : (cb) => setTimeout(cb, 0); + raf(() => { + raf(() => { + node.textContent = message; + }); + }); +} + +// --- render: per-panel targeted DOM updates --------------------------------- + +function renderFreshnessPanel(snapshot) { + if (typeof document === "undefined") { + return; + } + const root = document.getElementById("panel-freshness"); + const model = freshnessPanelModel(snapshot ? snapshot.freshness : null); + writeFields(root, model.fields); + writePanelStatus(root, model); + + // staleness chip (relative-time, decoupled from the poll clock). + const ms = computeStalenessMs(snapshot); + const stale = stalenessClass(ms); + const stalenessEl = document.getElementById("freshness-staleness"); + const evaluatedAt = + snapshot && snapshot.freshness ? snapshot.freshness.evaluatedAt : null; + setTextIfChanged(stalenessEl, relativeTime(evaluatedAt)); + if (stalenessEl) { + setAttrIfChanged(stalenessEl, "data-severity", stale); + } +} + +function renderCoveragePanel(snapshot) { + if (typeof document === "undefined") { + return; + } + const root = document.getElementById("panel-coverage"); + const model = coveragePanelModel(snapshot ? snapshot.coverage : null); + writeFields(root, model.fields); + writePanelStatus(root, model); +} + +function renderBacklogPanel(snapshot) { + if (typeof document === "undefined") { + return; + } + const root = document.getElementById("panel-backlog"); + const model = backlogPanelModel(snapshot ? snapshot.backlog : null); + // backlog row is fixed; per-status counts vary — write the known cell plus a + // compact per-status list. + const backlogCell = root + ? root.querySelector('[data-field="backlog"]') + : null; + const backlogField = model.fields.find((f) => f.key === "backlog"); + setTextIfChanged(backlogCell, backlogField ? backlogField.value : "—"); + + const statusList = document.getElementById("backlog-status-list"); + if (statusList) { + const statusFields = model.fields.filter((f) => f.key !== "backlog"); + // Rebuild only when the set of statuses changed (rare); otherwise update text. + const existingKeys = Array.from( + statusList.querySelectorAll("[data-field]"), + ).map((li) => li.getAttribute("data-field")); + const wantKeys = statusFields.map((f) => f.key); + const sameKeys = + existingKeys.length === wantKeys.length && + existingKeys.every((k, i) => k === wantKeys[i]); + if (!sameKeys) { + statusList.textContent = ""; + for (const field of statusFields) { + const li = document.createElement("li"); + li.setAttribute("data-field", field.key); + const label = document.createElement("span"); + label.className = "status-label"; + label.textContent = field.label; + const value = document.createElement("span"); + value.className = "status-value"; + value.textContent = field.value; + li.appendChild(label); + li.appendChild(value); + statusList.appendChild(li); + } + } else { + for (const field of statusFields) { + const li = statusList.querySelector(`[data-field="${field.key}"]`); + const valueEl = li ? li.querySelector(".status-value") : null; + setTextIfChanged(valueEl, field.value); + } + } + } + writePanelStatus(root, model); +} + +function renderFindingsPanel(resp) { + if (typeof document === "undefined") { + return; + } + const root = document.getElementById("panel-findings"); + // The wire is ALREADY server-sorted (DASHBOARD_DISPOSITION_ORDER). The model + // projects verbatim; this render NEVER re-sorts. + const model = findingsPanelModel(resp); + writePanelStatus(root, model); + + const tbody = document.getElementById("findings-tbody"); + if (!tbody) { + return; + } + + // 6-state markup: surface the non-ok states via data-state; the empty/loading + // copy lives in dedicated <tr> rows toggled by CSS on the panel root. + if (model.state !== "ok") { + tbody.textContent = ""; + return; + } + + // Build rows in the server order. We rebuild the tbody (rows are small and + // disposition filtering changes the set), but each cell is plain text — no + // markup injection from wire values. + const WIRE_KEYS = [ + "findingId", + "repo", + "ruleId", + "severity", + "confidence", + "status", + "disposition", + "filePath", + "lineStart", + "secretHash", + ]; + const frag = document.createDocumentFragment(); + for (const row of model.rows) { + const tr = document.createElement("tr"); + setAttrIfChanged(tr, "data-severity", String(row.severity ?? "")); + + // selection checkbox with identifying context (a11y). + const selectCell = document.createElement("td"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "finding-select"; + checkbox.setAttribute("aria-label", findingRowLabel(row)); + selectCell.appendChild(checkbox); + tr.appendChild(selectCell); + + for (const key of WIRE_KEYS) { + const td = document.createElement("td"); + td.setAttribute("data-field", key); + const v = row[key]; + td.textContent = v === null || v === undefined ? "—" : String(v); + tr.appendChild(td); + } + frag.appendChild(tr); + } + tbody.textContent = ""; + tbody.appendChild(frag); +} + +// --- alert surface (FR-A12: banner + aside + announce) ---------------------- + +function renderAlert(snapshot) { + if (typeof document === "undefined") { + return; + } + const banner = document.getElementById("alert-banner"); + const aside = document.getElementById("alert-stream"); + const model = alertModel(snapshot); + + if (!model) { + if (banner) { + banner.hidden = true; + setAttrIfChanged(banner, "data-severity", "ok"); + } + if (aside) { + setAttrIfChanged(aside, "data-state", "empty"); + } + return; + } + + if (banner) { + banner.hidden = false; + setAttrIfChanged(banner, "data-severity", model.level); + const msgEl = banner.querySelector("[data-field='alert-msg']"); + setTextIfChanged(msgEl, model.msg); + } + if (aside) { + setAttrIfChanged(aside, "data-state", "ok"); + setAttrIfChanged(aside, "data-severity", model.level); + const asideMsg = aside.querySelector("[data-field='alert-stream-msg']"); + setTextIfChanged(asideMsg, model.msg); + } + // role=alert is announced automatically by the AT when the node text changes; + // we additionally drive a forced re-announcement for repeated identical msgs. + announce("alert-announcer", model.msg); +} + +// --- stale banner ----------------------------------------------------------- + +function renderStaleBanner(isStale) { + if (typeof document === "undefined") { + return; + } + const banner = document.getElementById("stale-banner"); + if (!banner) { + return; + } + banner.hidden = !isStale; + setAttrIfChanged(banner, "data-severity", isStale ? "stale" : "ok"); + if (isStale) { + announce( + "status-announcer", + "최신 데이터를 가져오지 못했습니다. 마지막으로 성공한 값을 표시합니다.", + ); + } +} + +// --- applySnapshot: one render pass from a snapshot payload ------------------ + +function applySnapshot(snapshot) { + renderFreshnessPanel(snapshot); + renderCoveragePanel(snapshot); + renderBacklogPanel(snapshot); + renderAlert(snapshot); +} + +// --- fetch with timeout (AbortController) ------------------------------------ + +function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(url, { signal: controller.signal, cache: "no-store" }) + .then((resp) => { + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}`); + } + return resp.json(); + }) + .finally(() => clearTimeout(timer)); +} + +// --- fetchSnapshot: locked poll with keep-last-good + stale ------------------ + +async function fetchSnapshot() { + if (state.pollInFlight) { + return; // re-entrancy guard (no overlapping polls) + } + state.pollInFlight = true; + setRefreshDisabled(true); + try { + const snapshot = await fetchWithTimeout("/snapshot", FETCH_TIMEOUT_MS); + state.lastGoodSnapshot = snapshot; + state.failures = 0; + state.stale = false; + applySnapshot(snapshot); + renderStaleBanner(false); + } catch (_err) { + state.failures += 1; + state.stale = true; + // keep-last-good: re-render the last successful snapshot, mark stale. + if (state.lastGoodSnapshot) { + applySnapshot(state.lastGoodSnapshot); + } + renderStaleBanner(true); + scheduleBackoff(); + } finally { + state.pollInFlight = false; + setRefreshDisabled(false); + } +} + +// --- findings: separate on-demand fetch driven by disposition filter -------- + +async function fetchFindings() { + if (typeof document === "undefined") { + return; + } + if (state.findingsInFlight) { + return; + } + state.findingsInFlight = true; + try { + const params = selectedDispositions(readDispositionCheckboxes()); + const qs = params.map((d) => `disposition=${encodeURIComponent(d)}`).join("&"); + const url = qs ? `/findings?${qs}` : "/findings"; + const resp = await fetchWithTimeout(url, FETCH_TIMEOUT_MS); + if (hasFindings(resp)) { + state.lastFindings = resp; + renderFindingsPanel(resp); // consumes server order; no client re-sort + } + } catch (_err) { + // findings are non-critical for the live signal; keep last-good rows. + if (state.lastFindings) { + renderFindingsPanel(state.lastFindings); + } + } finally { + state.findingsInFlight = false; + } +} + +function readDispositionCheckboxes() { + if (typeof document === "undefined") { + return []; + } + const boxes = Array.from( + document.querySelectorAll('input[name="disposition"]'), + ); + return boxes.map((b) => ({ value: b.value, checked: b.checked })); +} + +// --- backoff scheduling ----------------------------------------------------- + +function scheduleBackoff() { + if (typeof setTimeout === "undefined") { + return; + } + if (state.backoffTimerId !== null) { + clearTimeout(state.backoffTimerId); + } + const delay = backoffDelay(state.failures); + state.backoffTimerId = setTimeout(() => { + state.backoffTimerId = null; + fetchSnapshot(); + }, delay); +} + +// --- polling lifecycle ------------------------------------------------------ + +function startPolling() { + if (typeof setInterval === "undefined") { + return; + } + stopPolling(); + fetchSnapshot(); // immediate first poll + state.intervalId = setInterval(fetchSnapshot, POLL_INTERVAL_MS); +} + +function stopPolling() { + if (state.intervalId !== null) { + clearInterval(state.intervalId); + state.intervalId = null; + } + if (state.backoffTimerId !== null) { + clearTimeout(state.backoffTimerId); + state.backoffTimerId = null; + } +} + +// --- manual refresh (disabled while in-flight) ------------------------------ + +function setRefreshDisabled(disabled) { + if (typeof document === "undefined") { + return; + } + const btn = document.getElementById("manual-refresh"); + if (btn) { + btn.disabled = !!disabled; + setAttrIfChanged(btn, "aria-busy", disabled ? "true" : "false"); + } +} + +function onManualRefresh() { + if (state.pollInFlight) { + return; // already fetching; button should be disabled anyway + } + fetchSnapshot(); +} + +// --- visibility (pause hidden / resume + immediate fetch) -------------------- + +function onVisibilityChange() { + if (typeof document === "undefined") { + return; + } + if (document.hidden) { + stopPolling(); + } else { + startPolling(); // resume includes an immediate fetch + } +} + +// --- init (load-time guard) ------------------------------------------------- + +function init() { + if (typeof document === "undefined") { + return; // imported under node (`node --test`) — never touch the DOM. + } + + const refreshBtn = document.getElementById("manual-refresh"); + if (refreshBtn) { + refreshBtn.addEventListener("click", onManualRefresh); + } + + // disposition filter checkboxes drive a fresh /findings fetch. + for (const box of document.querySelectorAll('input[name="disposition"]')) { + box.addEventListener("change", fetchFindings); + } + + // sortable header buttons are display-only affordances over the SERVER order; + // they toggle aria-sort but do NOT re-sort client-side (server is authority). + for (const btn of document.querySelectorAll("th button[data-sort-key]")) { + btn.addEventListener("click", () => toggleAriaSort(btn)); + } + + document.addEventListener("visibilitychange", onVisibilityChange); + + startPolling(); + fetchFindings(); // initial findings load (all dispositions) +} + +function toggleAriaSort(btn) { + if (typeof document === "undefined") { + return; + } + const th = btn.closest("th"); + if (!th) { + return; + } + const current = th.getAttribute("aria-sort"); + const next = current === "ascending" ? "descending" : "ascending"; + // clear sibling th aria-sort (only one active indicator). + const row = th.parentElement; + if (row) { + for (const cell of row.querySelectorAll("th[aria-sort]")) { + if (cell !== th) { + cell.setAttribute("aria-sort", "none"); + } + } + } + th.setAttribute("aria-sort", next); +} + +// Load-time guard: only wire the DOM in a browser. Under `node --test` this +// import is a no-op and the pure helpers remain in logic.mjs. +if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +} + +export { + state, + setTextIfChanged, + setAttrIfChanged, + writeFields, + writePanelStatus, + announce, + applySnapshot, + fetchWithTimeout, + fetchSnapshot, + fetchFindings, + startPolling, + stopPolling, + onManualRefresh, + onVisibilityChange, + renderFreshnessPanel, + renderCoveragePanel, + renderBacklogPanel, + renderFindingsPanel, + renderAlert, + renderStaleBanner, + toggleAriaSort, + init, +}; diff --git a/src/security_scanner/runtime/ui/static/index.html b/src/security_scanner/runtime/ui/static/index.html new file mode 100644 index 0000000..b335f1a --- /dev/null +++ b/src/security_scanner/runtime/ui/static/index.html @@ -0,0 +1,303 @@ +<!doctype html> +<html lang="ko"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="color-scheme" content="dark" /> + <title>security-scanner 관리자 대시보드 + + + + + + + + + + + +
+ + + + +
+

security-scanner 관리자 대시보드 — 스캔 현황

+ + +
+

신선도 / 정책 위반

+ +
+
+
총 위반
+
+
+
+
증분 위반
+
+
+
+
베이스라인 위반
+
+
+
+
평가된 repo
+
+
+
+
마지막 평가
+
+ +
+
+
+ + +
+ + +
+

커버리지

+ +
+
+
조직 전체
+
+
+
+
포함
+
+
+
+
제외
+
+
+
+
커버됨
+
+
+
+
커버리지 갭
+
+
+
+ + +
+ + +
+

큐 백로그

+ +
+
+
+
백로그
+
+
+
+
    +
    + + + +
    + + +
    +

    Findings

    + + +
    +
    + Disposition 필터 + + + +
    +
    + + + + +
    + + + + + + + + + + + + + + + + + + +
    + Disposition 순서(미검토 → 확인됨 → 오탐)로 서버 정렬된 findings 목록 +
    선택 + + + + + + + + + + + + + + + + + + + +
    +
    + + + + +
    +
    + + + + + + + diff --git a/src/security_scanner/runtime/ui/static/logic.mjs b/src/security_scanner/runtime/ui/static/logic.mjs new file mode 100644 index 0000000..ae55966 --- /dev/null +++ b/src/security_scanner/runtime/ui/static/logic.mjs @@ -0,0 +1,407 @@ +// M3 — pure dashboard logic (security-scanner admin control-plane UI). +// +// CONTRACT (design.md rev2 §5.4 / §7 L2): this module is the render-input → +// render-output substrate. It MUST stay pure — no `document`, no `window`, no +// timers (`setInterval`/`setTimeout`), no network. Every function below maps +// data to data so it can be exercised by `node --test` with zero DOM. The +// browser wiring (polling, DOM mutation, announce) lives in `app.js` (M4). +// +// It deliberately does NOT redefine any disposition order constant: the +// `/findings` wire is already pre-sorted by the server via +// `order_findings_for_dashboard` (DASHBOARD_DISPOSITION_ORDER). The UI consumes +// that order verbatim and never re-sorts. + +// --- staleness thresholds (ms) -------------------------------------------- +// design.md §4: warn > 10min, crit > 30min. evaluatedAt is the evaluator's +// last run, decoupled from the poll clock. +export const STALE_WARN_MS = 10 * 60 * 1000; // 10 minutes +export const STALE_CRIT_MS = 30 * 60 * 1000; // 30 minutes + +// --- backoff (ms) ---------------------------------------------------------- +// design.md §4: base 2s, capped at 60s. Exponential on consecutive failures. +export const BACKOFF_BASE_MS = 2000; +export const BACKOFF_MAX_MS = 60000; + +// --- poll cadence / fetch timeout (ms) ------------------------------------- +// design.md §4/§5.4: setInterval 5s; fetch timeout 4s (strictly < interval so a +// hung request can never pile up behind the next tick). app.js consumes these; +// they live here so the cadence invariant (timeout < interval) is node-testable. +export const POLL_INTERVAL_MS = 5000; +export const FETCH_TIMEOUT_MS = 4000; + +/** + * Milliseconds since the snapshot's freshness `evaluatedAt`. + * + * design.md §4/§5.4: when there is no evaluator pass yet (`available=false` → + * `evaluatedAt` null/absent) this returns `Infinity` — NEVER 0 — so callers can + * never render a misleading "updated 0s ago" for a never-evaluated system. + * + * @param {object|null|undefined} snapshot - the /snapshot payload. + * @returns {number} ms of staleness, or Infinity when not evaluable. + */ +export function computeStalenessMs(snapshot) { + const evaluatedAt = snapshot && snapshot.freshness + ? snapshot.freshness.evaluatedAt + : undefined; + if (evaluatedAt === null || evaluatedAt === undefined || evaluatedAt === "") { + return Infinity; + } + const parsed = Date.parse(evaluatedAt); + if (Number.isNaN(parsed)) { + return Infinity; + } + return Date.now() - parsed; +} + +/** + * Classify a staleness duration into one of the never-color-alone states. + * + * @param {number} ms - staleness in ms (Infinity allowed). + * @returns {"fresh"|"warn"|"crit"} severity class. + */ +export function stalenessClass(ms) { + if (ms > STALE_CRIT_MS) { + return "crit"; + } + if (ms > STALE_WARN_MS) { + return "warn"; + } + return "fresh"; +} + +/** + * Choose the display value for the freshness hero. + * + * design.md §4/§6: `available=false` → "미평가" (NOT 0, NOT "0s ago"). When + * available, the breach total is surfaced and the severity reflects whether any + * breach exists. + * + * @param {object|null|undefined} freshness - the freshness panel dto. + * @returns {{text: string, severity: string}} + */ +export function selectDisplayValue(freshness) { + if (!freshness || freshness.available === false) { + return { text: "미평가", severity: "muted" }; + } + const total = Number(freshness.totalBreaches ?? 0); + return { + text: String(total), + severity: total > 0 ? "crit" : "ok", + }; +} + +/** + * Render an ISO timestamp as a coarse Korean relative-time string. + * + * design.md §4: re-rendered each poll. A null/absent/invalid timestamp renders + * the em-dash placeholder, never "0초 전". + * + * @param {string|null|undefined} iso + * @returns {string} + */ +export function relativeTime(iso) { + if (iso === null || iso === undefined || iso === "") { + return "—"; + } + const parsed = Date.parse(iso); + if (Number.isNaN(parsed)) { + return "—"; + } + const deltaMs = Date.now() - parsed; + if (deltaMs < 0) { + return "방금 전"; + } + const seconds = Math.floor(deltaMs / 1000); + if (seconds < 60) { + return `${seconds}초 전`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}분 전`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}시간 전`; + } + const days = Math.floor(hours / 24); + return `${days}일 전`; +} + +/** + * Backoff delay for the Nth consecutive poll failure. + * + * design.md §4: base 2s, exponential, capped at 60s. `failures<=0` → base. + * + * @param {number} failures - count of consecutive failures. + * @returns {number} delay in ms, never exceeding BACKOFF_MAX_MS. + */ +export function backoffDelay(failures) { + const n = Number(failures); + if (!Number.isFinite(n) || n <= 1) { + return BACKOFF_BASE_MS; + } + const delay = BACKOFF_BASE_MS * 2 ** (n - 1); + return Math.min(delay, BACKOFF_MAX_MS); +} + +/** + * Whether a /findings response carries a findings collection. + * + * design.md §4/§6: key-absence is a first-class signal distinct from null and + * from []. Branch strictly on `'findings' in resp`. + * + * @param {object|null|undefined} resp + * @returns {boolean} + */ +export function hasFindings(resp) { + if (resp === null || resp === undefined || typeof resp !== "object") { + return false; + } + return "findings" in resp; +} + +// --- panel models ---------------------------------------------------------- +// Each panelModel maps a wire dto to {fields, severity, state}: +// fields — ordered [{key, label, value}] cells for the panel. +// severity — never-color-alone class for the panel root. +// state — one of the 6 render states {loading, ok, error, stale, muted, empty}. + +/** + * Freshness hero panel (incremental/baseline/total breaches, repos, evaluatedAt). + * `available=false` → muted "미평가" state (NOT zeros). + * + * @param {object|null|undefined} freshness + * @returns {{fields: Array, severity: string, state: string}} + */ +export function freshnessPanelModel(freshness) { + if (!freshness || freshness.available === false) { + return { + fields: [ + { key: "totalBreaches", label: "총 위반", value: "미평가" }, + { key: "incrementalBreaches", label: "증분 위반", value: "미평가" }, + { key: "baselineBreaches", label: "베이스라인 위반", value: "미평가" }, + { key: "reposEvaluated", label: "평가된 repo", value: "미평가" }, + { key: "evaluatedAt", label: "마지막 평가", value: "—" }, + ], + severity: "muted", + state: "muted", + }; + } + const total = Number(freshness.totalBreaches ?? 0); + return { + fields: [ + { key: "totalBreaches", label: "총 위반", value: String(total) }, + { + key: "incrementalBreaches", + label: "증분 위반", + value: String(Number(freshness.incrementalBreaches ?? 0)), + }, + { + key: "baselineBreaches", + label: "베이스라인 위반", + value: String(Number(freshness.baselineBreaches ?? 0)), + }, + { + key: "reposEvaluated", + label: "평가된 repo", + value: String(Number(freshness.reposEvaluated ?? 0)), + }, + { + key: "evaluatedAt", + label: "마지막 평가", + value: relativeTime(freshness.evaluatedAt), + }, + ], + severity: total > 0 ? "crit" : "ok", + state: "ok", + }; +} + +/** + * Coverage panel (orgTotal/included/excluded/covered/coverageGap). + * A coverage gap > 0 is a warn signal. + * + * @param {object|null|undefined} coverage + * @returns {{fields: Array, severity: string, state: string}} + */ +export function coveragePanelModel(coverage) { + if (!coverage) { + return { fields: [], severity: "muted", state: "muted" }; + } + const gap = Number(coverage.coverageGap ?? 0); + return { + fields: [ + { + key: "orgTotal", + label: "조직 전체", + value: String(Number(coverage.orgTotal ?? 0)), + }, + { + key: "included", + label: "포함", + value: String(Number(coverage.included ?? 0)), + }, + { + key: "excluded", + label: "제외", + value: String(Number(coverage.excluded ?? 0)), + }, + { + key: "covered", + label: "커버됨", + value: String(Number(coverage.covered ?? 0)), + }, + { key: "coverageGap", label: "커버리지 갭", value: String(gap) }, + ], + severity: gap > 0 ? "warn" : "ok", + state: "ok", + }; +} + +/** + * Queue backlog panel (backlog + jobCountsByStatus). + * Empty queue → empty state; non-empty backlog → warn. + * + * @param {object|null|undefined} backlogDto + * @returns {{fields: Array, severity: string, state: string}} + */ +export function backlogPanelModel(backlogDto) { + if (!backlogDto) { + return { fields: [], severity: "muted", state: "muted" }; + } + const backlog = Number(backlogDto.backlog ?? 0); + const counts = backlogDto.jobCountsByStatus || {}; + const fields = [{ key: "backlog", label: "백로그", value: String(backlog) }]; + for (const status of Object.keys(counts)) { + fields.push({ + key: status, + label: status, + value: String(Number(counts[status] ?? 0)), + }); + } + if (backlog === 0) { + return { fields, severity: "ok", state: "empty" }; + } + return { fields, severity: "warn", state: "ok" }; +} + +/** + * Findings panel. The wire is ALREADY sorted by the server + * (DASHBOARD_DISPOSITION_ORDER) — this model never re-sorts; it only projects + * the 10 wire keys verbatim into row fields and decides the panel state. + * + * design.md §6: key-absent (loading not yet fetched) → "loading"; present-empty + * [] → "empty"; present-non-empty → "ok". + * + * @param {object|null|undefined} resp - the /findings response (or /snapshot). + * @returns {{fields: Array, severity: string, state: string, rows: Array}} + */ +export function findingsPanelModel(resp) { + if (!hasFindings(resp)) { + return { fields: [], severity: "muted", state: "loading", rows: [] }; + } + const findings = resp.findings; + if (!Array.isArray(findings) || findings.length === 0) { + return { fields: [], severity: "muted", state: "empty", rows: [] }; + } + // 10 wire keys, consumed verbatim and in the server-provided order. + const rows = findings.map((f) => ({ + findingId: f.findingId, + repo: f.repo, + ruleId: f.ruleId, + severity: f.severity, + confidence: f.confidence, + status: f.status, + disposition: f.disposition, + filePath: f.filePath, + lineStart: f.lineStart, + secretHash: f.secretHash, + })); + return { + fields: [{ key: "count", label: "findings", value: String(rows.length) }], + severity: "ok", + state: "ok", + rows, + }; +} + +/** + * Top-level alert model (FR-A12). + * + * design.md §5.4: surface an alert when there is at least one breach + * (`freshness.totalBreaches > 0`) OR staleness has reached the crit threshold + * (`stalenessClass(computeStalenessMs(snapshot)) === 'crit'`). The crit-staleness + * branch makes silence structurally impossible: a never-evaluated / long-stale + * system always alerts (Infinity staleness → crit). + * + * @param {object|null|undefined} snapshot - the /snapshot payload. + * @returns {{level: string, msg: string}|null} null when nothing to surface. + */ +export function alertModel(snapshot) { + const freshness = snapshot && snapshot.freshness ? snapshot.freshness : null; + const ms = computeStalenessMs(snapshot); + const staleClass = stalenessClass(ms); + + if (staleClass === "crit") { + const reason = freshness && freshness.available === false + ? "스캔 결과가 아직 평가되지 않았습니다 (미평가)" + : "신선도가 임계(30분 초과)를 넘었습니다"; + return { level: "crit", msg: reason }; + } + + const breaches = freshness ? Number(freshness.totalBreaches ?? 0) : 0; + if (freshness && freshness.available !== false && breaches > 0) { + return { level: "crit", msg: `정책 위반 ${breaches}건이 감지되었습니다` }; + } + + return null; +} + +// --- render-support helpers (pure; used by app.js render layer) ------------- + +/** + * Accessible label for a findings-row selection checkbox. + * + * design.md §5.5: "행 체크박스 aria-label에 식별 컨텍스트." A bare "select" + * checkbox is meaningless to a screen reader; this composes repo + rule + + * location so each row's control is self-describing. Pure (string → string) so + * the identifying-context invariant is node-testable. + * + * @param {object|null|undefined} row - a projected findings row (10 wire keys). + * @returns {string} + */ +export function findingRowLabel(row) { + if (!row || typeof row !== "object") { + return "finding 선택"; + } + const repo = row.repo ?? "알 수 없는 repo"; + const rule = row.ruleId ?? "규칙 미상"; + const file = row.filePath ?? "위치 미상"; + const line = + row.lineStart === null || row.lineStart === undefined + ? "" + : `:${row.lineStart}`; + return `${repo} / ${rule} (${file}${line}) finding 선택`; +} + +/** + * Extract the checked disposition values for a `/findings` query. + * + * design.md §5.1/§6: zero checked → return [] so the caller sends NO + * `disposition` param (server treats absence as dispositions=None → all). A + * non-empty selection is returned verbatim. Pure (boxes → values) so the + * "never send empty []" boundary is node-testable. The caller (app.js) maps a + * non-empty result to query params and an empty result to a bare `/findings`. + * + * @param {Array<{value: string, checked: boolean}>} boxes + * @returns {string[]} checked values (possibly empty). + */ +export function selectedDispositions(boxes) { + if (!Array.isArray(boxes)) { + return []; + } + return boxes + .filter((b) => b && b.checked) + .map((b) => String(b.value)) + .filter((v) => v.length > 0); +} diff --git a/src/security_scanner/runtime/ui/static/style.css b/src/security_scanner/runtime/ui/static/style.css new file mode 100644 index 0000000..0e516d7 --- /dev/null +++ b/src/security_scanner/runtime/ui/static/style.css @@ -0,0 +1,638 @@ +/* security-scanner 관리자 대시보드 — design system / tokens / a11y (design.md rev2 §5.6 / §8 M5). + * + * Dense dark control-plane styling (WCAG AA target). NO bundler, vanilla CSS only. + * + * Authority on contrast numbers: axe-core (best-effort, D6) + the D6b static parser + * (tests/test_dashboard_a11y_static.py). We deliberately do NOT inline ratio numbers + * here — the tests own the thresholds. Text aims ≥ 4.5:1, icons/borders ≥ 3:1. + * + * never-color-alone: status is carried by a `data-severity` cascade for COLOR, but + * every one of the 6 states (loading / ok / error / stale / muted=미평가 / empty) + * ALSO ships a text label and/or a shape icon (● ▲ ✕ ○ …) so meaning survives with + * color stripped. The status icons are aria-hidden; the adjacent text is the label. + */ + +/* ── design tokens ────────────────────────────────────────────────────────── + * The four status tokens are the contract surface the D6b parser asserts. + * They sit on a ~#111827 surface; the muted token doubles as default body text. + */ +:root { + color-scheme: dark; + + /* status tokens (D6b: these four MUST exist) */ + --ok: #4ade80; + --warn: #fbbf24; + --crit: #f87171; + --muted: #94a3b8; + + /* surfaces (dense dark, no gradient/glass) */ + --surface-base: #111827; /* app background (~#111827 per spec) */ + --surface-panel: #1a2233; /* panel fill, one step up from base */ + --surface-raised: #232c40; /* table header / inputs */ + + /* text */ + --text-strong: #e5e7eb; /* primary readable text on surfaces */ + --text-body: #cbd5e1; /* secondary body */ + --text-muted: var(--muted); /* de-emphasised / 미평가 */ + + /* hairline borders (no shadows) */ + --border-hairline: #2d3852; + --border-strong: #3a4663; + + /* severity → role color (default ok) */ + --severity-color: var(--ok); + + /* spacing — strict 4px grid */ + --space-1: 4px; + --space-2: 8px; /* gutter */ + --space-3: 12px; /* panel padding */ + --space-4: 16px; + --space-6: 24px; + + /* type scale (3 sizes) + humanist sans for labels */ + --font-sans: + "Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, + sans-serif; + --font-mono: + ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, + monospace; + --fs-sm: 12px; + --fs-md: 14px; + --fs-lg: 18px; + + /* touch target floor */ + --touch: 44px; + + --radius: 4px; /* small only — no big rounds */ +} + +/* ── severity cascade ─────────────────────────────────────────────────────── + * A single `data-severity` attribute remaps `--severity-color`; descendants read + * it. This is the COLOR channel only — text/icon cues are independent (below). + */ +[data-severity="ok"] { + --severity-color: var(--ok); +} +[data-severity="warn"] { + --severity-color: var(--warn); +} +[data-severity="crit"] { + --severity-color: var(--crit); +} +[data-severity="stale"] { + --severity-color: var(--warn); +} +[data-severity="muted"] { + --severity-color: var(--muted); +} + +/* ── reset / base ───────────────────────────────────────────────────────────*/ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-size: 100%; +} + +body { + margin: 0; + padding: var(--space-3); + background: var(--surface-base); + color: var(--text-body); + font-family: var(--font-sans); + font-size: var(--fs-md); + line-height: 1.45; + /* tabular figures everywhere so dense metric columns align */ + font-variant-numeric: tabular-nums; + -moz-font-feature-settings: "tnum"; + font-feature-settings: "tnum"; +} + +h1, +h2 { + margin: 0; + font-weight: 600; + color: var(--text-strong); + line-height: 1.25; +} + +/* Panel titles are a FRAMING register, not the loudest thing on screen. Demoting + * them to the medium size (uppercase, tracked, muted) frees the large size to + * mean "a number" so the hero metric can dominate. Hero title stays body color + * so the hero panel still reads as the primary panel. Muted on the panel surface + * passes AA (~5.4:1). */ +h2.panel-title { + font-size: var(--fs-md); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} +.panel-hero > .panel-title { + color: var(--text-body); +} + +button { + font: inherit; + color: var(--text-strong); + background: var(--surface-raised); + border: 1px solid var(--border-hairline); + border-radius: var(--radius); + padding: var(--space-2) var(--space-3); + cursor: pointer; + /* 44px minimum touch target */ + min-height: var(--touch); +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +/* visible, non-color-alone focus ring for keyboard operability. The strong-text + * outline (~11–14:1 on dark) is wrapped in a base-surface halo so the ring still + * reads when a colored border (e.g. a severity accent) sits directly underneath. + * The shadow is scoped to :focus-visible ONLY — never decorative at rest. */ +:focus-visible { + outline: 2px solid var(--text-strong); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--surface-base); +} + +/* ── screen-reader-only utility ─────────────────────────────────────────────*/ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* skip links: visible only when focused, large touch target */ +.skip-link { + position: absolute; + left: var(--space-2); + top: -64px; + z-index: 10; + display: inline-flex; + align-items: center; + min-height: var(--touch); + padding: 0 var(--space-3); + background: var(--surface-raised); + color: var(--text-strong); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + text-decoration: none; + transition: top 120ms ease; +} +.skip-link:focus, +.skip-link:focus-visible { + top: var(--space-2); +} + +/* ── header / banner ────────────────────────────────────────────────────────*/ +.app-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.app-brand { + margin: 0; + font-size: var(--fs-lg); + font-weight: 700; + color: var(--text-strong); +} + +.app-nav { + margin-left: auto; +} + +.btn-icon { + margin-right: var(--space-1); +} + +/* ── banners (stale + alert) — never-color-alone ──────────────────────────── + * Each banner carries a shape icon AND a text label, plus a left accent border + * tinted by severity. Hidden via the `hidden` attribute until activated by JS. + */ +.banner { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + margin: var(--space-2) 0 0; + padding: var(--space-2) var(--space-3); + min-height: var(--touch); + background: var(--surface-panel); + border: 1px solid var(--border-hairline); + border-left: 3px solid var(--severity-color); + border-radius: var(--radius); + color: var(--text-strong); +} +.banner[hidden] { + display: none; +} +.banner-icon { + color: var(--severity-color); + font-size: var(--fs-md); +} +.banner-label { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: var(--fs-sm); + color: var(--severity-color); +} +.banner-text { + color: var(--text-body); +} + +/* Headline dominance without color-alone: the crit and stale banners are the + * most important signals in the header, so they get a thicker accent (6px vs the + * 3px ordinary banner) and a larger label, and sort first on header wrap. The + * shape/color agreement (▲ on amber/red) is handled by the glyph in markup; the + * surface stays a flat tint (no color-mix) to avoid a browser-support regression. */ +.banner[data-severity="crit"], +.banner[data-severity="stale"] { + border-left-width: 6px; + order: -1; +} +.banner[data-severity="crit"] .banner-label, +.banner[data-severity="stale"] .banner-label { + font-size: var(--fs-md); +} + +/* ── 12-column grid (responsive 1024 / 768) ─────────────────────────────────*/ +.app-main { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: var(--space-2); /* 8px gutter */ + align-items: start; +} + +/* hero spans full width; coverage + backlog share a row; findings full width */ +.panel-hero { + grid-column: span 12; +} +#panel-coverage { + grid-column: span 6; +} +#panel-backlog { + grid-column: span 6; +} +.panel-findings { + grid-column: span 12; +} + +/* ≤1024px: collapse to 6-col, panels go full-row */ +@media (max-width: 1024px) { + .app-main { + grid-template-columns: repeat(6, 1fr); + } + .panel-hero, + #panel-coverage, + #panel-backlog, + .panel-findings { + grid-column: span 6; + } +} + +/* ≤768px: single column stack */ +@media (max-width: 768px) { + body { + padding: var(--space-2); + } + .app-main { + grid-template-columns: 1fr; + } + .panel-hero, + #panel-coverage, + #panel-backlog, + .panel-findings { + grid-column: 1 / -1; + } +} + +/* ── panels ───────────────────────────────────────────────────────────────── + * Hairline border, no shadow. 12px padding. The hero gets a LEFT 3px accent + * border (no background fill) tinted by severity. + */ +.panel { + background: var(--surface-panel); + border: 1px solid var(--border-hairline); + border-radius: var(--radius); + padding: var(--space-3); /* 12px */ +} + +.panel-title { + margin-bottom: var(--space-2); +} + +/* hero accent: left border only, no fill change. Accent THICKNESS is a rank + * channel — the hero gets 4px while banners, aside and table rows stay 3px, so + * the hero no longer reads as just another box in the dense grid. */ +.panel-hero { + border-left: 4px solid var(--severity-color); +} + +/* ── metric grid ────────────────────────────────────────────────────────────*/ +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--space-2); + margin: 0; +} +.metric { + display: flex; + flex-direction: column; + gap: var(--space-1); +} +.metric dt { + font-size: var(--fs-sm); + color: var(--text-muted); +} +.metric dd { + margin: 0; + font-size: var(--fs-lg); + font-weight: 600; + color: var(--text-strong); + font-variant-numeric: tabular-nums; +} + +/* Hero dominance: the hero's FIRST metric value carries the most weight + the + * tightest tracking on the existing large size, so a NUMBER (not a title or a + * secondary metric) is the single thing the eye lands on first. Stays within the + * 3-size scale — weight + tracking + line-height do the ranking, not a 4th size. */ +.panel-hero .metric-grid .metric:first-child dd { + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.05; +} + +/* the staleness chip and any [data-severity] value cell tint to severity */ +[data-field] [data-severity], +#freshness-staleness { + color: var(--severity-color); +} + +/* per-status backlog list */ +.status-list { + list-style: none; + margin: var(--space-2) 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: var(--space-1) var(--space-3); +} +.status-list li { + display: flex; + gap: var(--space-1); + font-size: var(--fs-sm); +} +.status-value { + font-weight: 600; + color: var(--text-strong); + font-variant-numeric: tabular-nums; +} + +/* ── 6-state visibility (never-color-alone) ───────────────────────────────── + * The panel root carries `data-state`; we reveal exactly one state block. Each + * block (in index.html) already contains a text label and a shape icon, so the + * distinction never depends on color. By default everything is hidden and the + * matching `data-state` un-hides the right child. + */ +.state-loading, +.state-content, +.state-error, +.state-empty, +.state-muted { + display: none; +} + +[data-state="loading"] .state-loading { + display: block; +} +[data-state="ok"] .state-content { + display: block; +} +[data-state="error"] .state-error { + display: block; +} +[data-state="empty"] .state-empty { + display: block; +} +[data-state="muted"] .state-muted { + display: block; +} + +/* The `stale` state keeps the last-good content visible (per design §6 keep + * last-good) — staleness is surfaced by the banner, not by blanking the panel. */ +[data-state="stale"] .state-content { + display: block; +} + +/* status cue lines: icon + text, icon tinted to a state-appropriate color */ +.state-loading, +.state-error, +.state-empty, +.state-muted { + display: none; /* re-declared above; explicit for clarity */ + align-items: center; + gap: var(--space-2); + margin: var(--space-2) 0 0; + color: var(--text-body); +} +[data-state="loading"] .state-loading, +[data-state="error"] .state-error, +[data-state="empty"] .state-empty, +[data-state="muted"] .state-muted { + display: flex; +} + +.state-icon { + font-size: var(--fs-md); + line-height: 1; +} +.state-loading .state-icon { + color: var(--text-muted); +} +.state-error .state-icon { + color: var(--crit); +} +.state-empty .state-icon { + color: var(--text-muted); +} +.state-muted .state-icon { + color: var(--muted); +} + +/* ── findings filter ────────────────────────────────────────────────────────*/ +.findings-filter { + margin: 0 0 var(--space-3); +} +.findings-filter fieldset { + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + border: 1px solid var(--border-hairline); + border-radius: var(--radius); + padding: var(--space-2) var(--space-3); +} +.findings-filter legend { + font-size: var(--fs-sm); + color: var(--text-muted); + padding: 0 var(--space-1); +} +.findings-filter label { + display: inline-flex; + align-items: center; + gap: var(--space-1); + /* 44px touch target for each filter control row */ + min-height: var(--touch); +} +.findings-filter input[type="checkbox"] { + width: 18px; + height: 18px; +} + +/* ── findings table + horizontal scroll wrapper ───────────────────────────── + * The wrapper is focusable (tabindex=0) so keyboard users can scroll it; it gets + * a visible focus ring. The table uses tabular figures and hairline cell borders. + */ +.table-scroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border: 1px solid var(--border-hairline); + border-radius: var(--radius); +} +.table-scroll:focus-visible { + outline: 2px solid var(--text-strong); + outline-offset: -2px; +} + +.findings-table { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; +} +.findings-table th, +.findings-table td { + padding: var(--space-1) var(--space-2); + text-align: left; + border-bottom: 1px solid var(--border-hairline); + white-space: nowrap; +} +.findings-table thead th { + background: var(--surface-raised); + color: var(--text-strong); + position: sticky; + top: 0; +} +.findings-table thead th button { + background: transparent; + border: 0; + padding: var(--space-1); + /* keep header sort affordance a real 44px target */ + min-height: var(--touch); + color: inherit; + font-weight: 600; +} +/* sort indicator is shape-based, never color-alone */ +.findings-table th[aria-sort="ascending"] button::after { + content: " ▲"; +} +.findings-table th[aria-sort="descending"] button::after { + content: " ▼"; +} +.findings-table tbody tr { + border-left: 3px solid var(--severity-color); +} +.finding-select { + width: 18px; + height: 18px; +} + +/* ── alert stream (aside) — never-color-alone ───────────────────────────────*/ +.alert-stream { + margin-top: var(--space-3); + padding: var(--space-3); + background: var(--surface-panel); + border: 1px solid var(--border-hairline); + border-left: 3px solid var(--severity-color); + border-radius: var(--radius); +} +.alert-stream .state-empty, +.alert-stream .state-content { + display: none; + align-items: center; + gap: var(--space-2); + margin: var(--space-2) 0 0; +} +.alert-stream[data-state="empty"] .state-empty { + display: flex; +} +.alert-stream[data-state="ok"] .state-content { + display: flex; +} +.alert-stream .state-content .state-icon { + color: var(--severity-color); +} + +/* ── busy / loading motion (reduced-motion-safe) ──────────────────────────── + * The one positive motion in the UI: a refresh-glyph spinner. It brings the + * loading state (the only text-only state, and the first every user sees) to + * icon+text parity, and gives the manual-refresh button a real in-flight + * affordance beyond dimmed opacity. aria-busy is already toggled by app.js, so + * no JS change is needed. The reduced-motion block below kills the spin AND adds + * a genuinely distinct non-motion cue (a trailing ellipsis on the busy button). + */ +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* manual-refresh: spin its glyph while a fetch is in flight */ +#manual-refresh[aria-busy="true"] .btn-icon { + display: inline-block; + animation: spin 800ms linear infinite; +} + +/* loading state: spin the decorative refresh glyph in each loading paragraph */ +[data-state="loading"] .state-loading .state-icon { + display: inline-block; + animation: spin 800ms linear infinite; +} + +/* ── reduced motion ─────────────────────────────────────────────────────────*/ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + /* both spinners stop … */ + #manual-refresh[aria-busy="true"] .btn-icon, + [data-state="loading"] .state-loading .state-icon { + animation: none !important; + } + /* … and a non-motion in-flight cue takes over for the busy button. */ + #manual-refresh[aria-busy="true"]::after { + content: "…"; + margin-left: var(--space-1); + } +} diff --git a/src/security_scanner/runtime/ui/test/index.mjs b/src/security_scanner/runtime/ui/test/index.mjs new file mode 100644 index 0000000..9b1f654 --- /dev/null +++ b/src/security_scanner/runtime/ui/test/index.mjs @@ -0,0 +1,29 @@ +// Directory entry-point for `node --test src/security_scanner/runtime/ui/test/`. +// +// Why this file exists (design.md rev2 §8 D3/M3 verify command): +// On Node >= 22 (this box: v25.8.1), passing a bare directory to `node --test` +// resolves the directory as an *explicit module* (CommonJS-style: package.json +// `main`) rather than as a test-discovery root. Without a resolvable entry it +// throws MODULE_NOT_FOUND and the command exits 1 with 0 real tests executed +// (a synthetic failure). The sibling package.json points `main` here so the +// directory is resolvable; this module then performs discovery itself. +// +// Discovery is dynamic on purpose: every sibling `test_*.mjs` is imported at +// runtime. Adding a new test file (M4/M6) requires no edit here — it cannot +// silently fall out of the suite. Each imported module registers its +// `node:test` cases, so the runner counts and gates on the real tests, and a +// failing assertion still propagates to a non-zero exit (verified). + +import { readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +const testFiles = readdirSync(here) + .filter((name) => /^test_.*\.mjs$/.test(name)) + .sort(); + +for (const name of testFiles) { + await import(join(here, name)); +} diff --git a/src/security_scanner/runtime/ui/test/package.json b/src/security_scanner/runtime/ui/test/package.json new file mode 100644 index 0000000..2ac5131 --- /dev/null +++ b/src/security_scanner/runtime/ui/test/package.json @@ -0,0 +1,7 @@ +{ + "name": "m8-ui-tests", + "private": true, + "type": "module", + "main": "./index.mjs", + "description": "Directory entry-point shim so the SoT verify command `node --test src/security_scanner/runtime/ui/test/` works on Node >=22 (incl. v25), where a bare-directory test arg is resolved as an explicit module instead of a discovery root. index.mjs dynamically imports every sibling test_*.mjs so new test files are picked up automatically (no manual import list to drift)." +} diff --git a/src/security_scanner/runtime/ui/test/test_render_helpers.mjs b/src/security_scanner/runtime/ui/test/test_render_helpers.mjs new file mode 100644 index 0000000..aea6821 --- /dev/null +++ b/src/security_scanner/runtime/ui/test/test_render_helpers.mjs @@ -0,0 +1,106 @@ +// M4 — L2 pure-logic tests for the render-support helpers added in §5.4/§5.5. +// +// app.js (browser wiring) owns no data decisions; the new pure helpers it leans +// on live in logic.mjs and are exercised here with zero DOM: +// - POLL_INTERVAL_MS / FETCH_TIMEOUT_MS cadence invariant (timeout < interval) +// - findingRowLabel: identifying context for a row checkbox aria-label +// - selectedDispositions: never returns a "send empty []" footgun +// +// This file is auto-discovered by index.mjs (any sibling test_*.mjs), so adding +// it requires no edit to the runner. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + POLL_INTERVAL_MS, + FETCH_TIMEOUT_MS, + findingRowLabel, + selectedDispositions, +} from "../static/logic.mjs"; + +// --- cadence invariant: fetch timeout strictly < poll interval -------------- + +test("cadence: FETCH_TIMEOUT_MS < POLL_INTERVAL_MS (no pile-up)", () => { + assert.equal(POLL_INTERVAL_MS, 5000); + assert.equal(FETCH_TIMEOUT_MS, 4000); + assert.ok( + FETCH_TIMEOUT_MS < POLL_INTERVAL_MS, + "a hung fetch must abort before the next poll tick", + ); +}); + +// --- findingRowLabel: identifying context (design.md §5.5) ------------------ + +test("findingRowLabel: composes repo + rule + location", () => { + const label = findingRowLabel({ + findingId: "f-1", + repo: "org/a", + ruleId: "gitleaks.aws-key", + filePath: "src/app.py", + lineStart: 42, + }); + assert.match(label, /org\/a/); + assert.match(label, /gitleaks\.aws-key/); + assert.match(label, /src\/app\.py/); + assert.match(label, /42/); + // not a bare "select" — must carry context so a screen reader row is distinct. + assert.notEqual(label.trim(), "선택"); +}); + +test("findingRowLabel: missing fields degrade to placeholders (never empty)", () => { + const label = findingRowLabel({ repo: null, ruleId: undefined }); + assert.ok(label.length > 0); + assert.match(label, /finding 선택/); +}); + +test("findingRowLabel: null/non-object → safe default", () => { + assert.equal(findingRowLabel(null), "finding 선택"); + assert.equal(findingRowLabel(undefined), "finding 선택"); + assert.equal(findingRowLabel("nope"), "finding 선택"); +}); + +test("findingRowLabel: omits line suffix when lineStart absent", () => { + const label = findingRowLabel({ + repo: "org/a", + ruleId: "r1", + filePath: "a.py", + }); + assert.match(label, /a\.py\)/); // closing paren immediately after file, no ":" + assert.doesNotMatch(label, /:undefined/); + assert.doesNotMatch(label, /:null/); +}); + +// --- selectedDispositions: never the empty-[] footgun ---------------------- + +test("selectedDispositions: zero checked → [] (caller sends no param → all)", () => { + const out = selectedDispositions([ + { value: "unreviewed", checked: false }, + { value: "verified", checked: false }, + { value: "false_positive", checked: false }, + ]); + assert.deepEqual(out, []); +}); + +test("selectedDispositions: returns only checked values, verbatim", () => { + const out = selectedDispositions([ + { value: "unreviewed", checked: true }, + { value: "verified", checked: false }, + { value: "false_positive", checked: true }, + ]); + assert.deepEqual(out, ["unreviewed", "false_positive"]); +}); + +test("selectedDispositions: non-array → []", () => { + assert.deepEqual(selectedDispositions(null), []); + assert.deepEqual(selectedDispositions(undefined), []); + assert.deepEqual(selectedDispositions("unreviewed"), []); +}); + +test("selectedDispositions: drops empty-string values", () => { + const out = selectedDispositions([ + { value: "", checked: true }, + { value: "verified", checked: true }, + ]); + assert.deepEqual(out, ["verified"]); +}); diff --git a/src/security_scanner/runtime/ui/test/test_ui_logic.mjs b/src/security_scanner/runtime/ui/test/test_ui_logic.mjs new file mode 100644 index 0000000..b56dce9 --- /dev/null +++ b/src/security_scanner/runtime/ui/test/test_ui_logic.mjs @@ -0,0 +1,337 @@ +// M3 — L2 pure-logic tests (design.md rev2 §7 L2). `node --test`, npm 0. +// +// Imports the pure render substrate (../static/logic.mjs) and exercises: +// - selectDisplayValue 미평가 ≠ '0' (available=false must not render zero) +// - stalenessClass thresholds (fresh/warn/crit boundaries) +// - computeStalenessMs null → Infinity (never 0 for never-evaluated) +// - hasFindings key-absent vs null vs [] +// - backoffDelay base + exponential + cap +// - each panelModel (freshness/coverage/backlog/findings) +// - alertModel threshold (breach>0 OR crit staleness; quiet otherwise) + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + STALE_WARN_MS, + STALE_CRIT_MS, + BACKOFF_BASE_MS, + BACKOFF_MAX_MS, + computeStalenessMs, + stalenessClass, + selectDisplayValue, + relativeTime, + backoffDelay, + hasFindings, + freshnessPanelModel, + coveragePanelModel, + backlogPanelModel, + findingsPanelModel, + alertModel, +} from "../static/logic.mjs"; + +// --- selectDisplayValue: 미평가 ≠ '0' -------------------------------------- + +test("selectDisplayValue: available=false renders 미평가, never 0", () => { + const result = selectDisplayValue({ available: false, totalBreaches: 0 }); + assert.equal(result.text, "미평가"); + assert.notEqual(result.text, "0"); + assert.notEqual(result.text, 0); + assert.equal(result.severity, "muted"); +}); + +test("selectDisplayValue: null freshness renders 미평가", () => { + const result = selectDisplayValue(null); + assert.equal(result.text, "미평가"); + assert.notEqual(result.text, "0"); +}); + +test("selectDisplayValue: available with 0 breaches is ok '0' (distinct from 미평가)", () => { + const result = selectDisplayValue({ available: true, totalBreaches: 0 }); + assert.equal(result.text, "0"); + assert.equal(result.severity, "ok"); +}); + +test("selectDisplayValue: available with breaches is crit", () => { + const result = selectDisplayValue({ available: true, totalBreaches: 3 }); + assert.equal(result.text, "3"); + assert.equal(result.severity, "crit"); +}); + +// --- stalenessClass thresholds --------------------------------------------- + +test("stalenessClass: fresh below warn threshold", () => { + assert.equal(stalenessClass(0), "fresh"); + assert.equal(stalenessClass(STALE_WARN_MS), "fresh"); // boundary inclusive of fresh + assert.equal(stalenessClass(STALE_WARN_MS - 1), "fresh"); +}); + +test("stalenessClass: warn between warn and crit thresholds", () => { + assert.equal(stalenessClass(STALE_WARN_MS + 1), "warn"); + assert.equal(stalenessClass(STALE_CRIT_MS), "warn"); // boundary still warn +}); + +test("stalenessClass: crit above crit threshold and Infinity", () => { + assert.equal(stalenessClass(STALE_CRIT_MS + 1), "crit"); + assert.equal(stalenessClass(Infinity), "crit"); +}); + +// --- computeStalenessMs: null → Infinity ----------------------------------- + +test("computeStalenessMs: null evaluatedAt → Infinity (never 0)", () => { + const ms = computeStalenessMs({ freshness: { available: false, evaluatedAt: null } }); + assert.equal(ms, Infinity); + assert.notEqual(ms, 0); +}); + +test("computeStalenessMs: absent freshness → Infinity", () => { + assert.equal(computeStalenessMs({}), Infinity); + assert.equal(computeStalenessMs(null), Infinity); + assert.equal(computeStalenessMs(undefined), Infinity); +}); + +test("computeStalenessMs: invalid timestamp → Infinity", () => { + assert.equal( + computeStalenessMs({ freshness: { evaluatedAt: "not-a-date" } }), + Infinity, + ); +}); + +test("computeStalenessMs: valid past timestamp → finite non-negative ms", () => { + const past = new Date(Date.now() - 5000).toISOString(); + const ms = computeStalenessMs({ freshness: { available: true, evaluatedAt: past } }); + assert.ok(Number.isFinite(ms), "expected finite staleness"); + assert.ok(ms >= 4000, `expected >= ~5s of staleness, got ${ms}`); +}); + +// --- hasFindings: key-absent vs null vs [] --------------------------------- + +test("hasFindings: key absent → false", () => { + assert.equal(hasFindings({ freshness: {} }), false); + assert.equal(hasFindings({}), false); +}); + +test("hasFindings: key present (null/[]/array) → true", () => { + assert.equal(hasFindings({ findings: null }), true); + assert.equal(hasFindings({ findings: [] }), true); + assert.equal(hasFindings({ findings: [{ findingId: "x" }] }), true); +}); + +test("hasFindings: non-object → false", () => { + assert.equal(hasFindings(null), false); + assert.equal(hasFindings(undefined), false); + assert.equal(hasFindings("findings"), false); +}); + +// --- backoffDelay: base + exponential + cap -------------------------------- + +test("backoffDelay: <=1 failure → base", () => { + assert.equal(backoffDelay(0), BACKOFF_BASE_MS); + assert.equal(backoffDelay(1), BACKOFF_BASE_MS); +}); + +test("backoffDelay: grows exponentially", () => { + assert.equal(backoffDelay(2), BACKOFF_BASE_MS * 2); + assert.equal(backoffDelay(3), BACKOFF_BASE_MS * 4); +}); + +test("backoffDelay: capped at BACKOFF_MAX_MS", () => { + assert.equal(backoffDelay(100), BACKOFF_MAX_MS); + assert.ok(backoffDelay(50) <= BACKOFF_MAX_MS); + assert.equal(BACKOFF_MAX_MS, 60000); +}); + +// --- panelModel: freshness ------------------------------------------------- + +test("freshnessPanelModel: available=false → muted 미평가, never zeros", () => { + const m = freshnessPanelModel({ available: false }); + assert.equal(m.severity, "muted"); + assert.equal(m.state, "muted"); + const total = m.fields.find((f) => f.key === "totalBreaches"); + assert.equal(total.value, "미평가"); + assert.notEqual(total.value, "0"); +}); + +test("freshnessPanelModel: available with breaches → crit ok-state", () => { + const m = freshnessPanelModel({ + available: true, + totalBreaches: 2, + incrementalBreaches: 1, + baselineBreaches: 1, + reposEvaluated: 10, + evaluatedAt: new Date().toISOString(), + }); + assert.equal(m.severity, "crit"); + assert.equal(m.state, "ok"); + assert.equal(m.fields.find((f) => f.key === "totalBreaches").value, "2"); +}); + +test("freshnessPanelModel: available with 0 breaches → ok", () => { + const m = freshnessPanelModel({ available: true, totalBreaches: 0 }); + assert.equal(m.severity, "ok"); + assert.equal(m.state, "ok"); +}); + +// --- panelModel: coverage -------------------------------------------------- + +test("coveragePanelModel: gap>0 → warn", () => { + const m = coveragePanelModel({ + orgTotal: 500, + included: 480, + excluded: 20, + covered: 400, + coverageGap: 80, + }); + assert.equal(m.severity, "warn"); + assert.equal(m.fields.find((f) => f.key === "coverageGap").value, "80"); +}); + +test("coveragePanelModel: gap=0 → ok", () => { + const m = coveragePanelModel({ + orgTotal: 100, + included: 100, + excluded: 0, + covered: 100, + coverageGap: 0, + }); + assert.equal(m.severity, "ok"); + assert.equal(m.state, "ok"); +}); + +test("coveragePanelModel: null → muted", () => { + const m = coveragePanelModel(null); + assert.equal(m.severity, "muted"); + assert.deepEqual(m.fields, []); +}); + +// --- panelModel: backlog --------------------------------------------------- + +test("backlogPanelModel: empty queue → empty state", () => { + const m = backlogPanelModel({ backlog: 0, jobCountsByStatus: {} }); + assert.equal(m.state, "empty"); + assert.equal(m.severity, "ok"); +}); + +test("backlogPanelModel: non-empty → warn with per-status fields", () => { + const m = backlogPanelModel({ + backlog: 12, + jobCountsByStatus: { QUEUED: 7, RUNNING: 5 }, + }); + assert.equal(m.severity, "warn"); + assert.equal(m.state, "ok"); + assert.equal(m.fields.find((f) => f.key === "backlog").value, "12"); + assert.equal(m.fields.find((f) => f.key === "QUEUED").value, "7"); + assert.equal(m.fields.find((f) => f.key === "RUNNING").value, "5"); +}); + +// --- panelModel: findings (server-pre-sorted; never re-sorted) ------------- + +test("findingsPanelModel: key absent → loading", () => { + const m = findingsPanelModel({ freshness: {} }); + assert.equal(m.state, "loading"); + assert.deepEqual(m.rows, []); +}); + +test("findingsPanelModel: present empty [] → empty", () => { + const m = findingsPanelModel({ findings: [] }); + assert.equal(m.state, "empty"); +}); + +test("findingsPanelModel: present rows → ok, 10 wire keys verbatim, order preserved", () => { + const wire = [ + { + findingId: "f-2", + repo: "org/b", + ruleId: "r2", + severity: "high", + confidence: "high", + status: "open", + disposition: "unreviewed", + filePath: "b.py", + lineStart: 10, + secretHash: "h2", + }, + { + findingId: "f-1", + repo: "org/a", + ruleId: "r1", + severity: "low", + confidence: "low", + status: "open", + disposition: "verified", + filePath: "a.py", + lineStart: 1, + secretHash: "h1", + }, + ]; + const m = findingsPanelModel({ findings: wire }); + assert.equal(m.state, "ok"); + assert.equal(m.severity, "ok"); + assert.equal(m.rows.length, 2); + // server order preserved (NOT re-sorted by disposition) + assert.equal(m.rows[0].findingId, "f-2"); + assert.equal(m.rows[1].findingId, "f-1"); + // exactly the 10 wire keys projected + assert.deepEqual(Object.keys(m.rows[0]).sort(), [ + "confidence", + "disposition", + "filePath", + "findingId", + "lineStart", + "repo", + "ruleId", + "secretHash", + "severity", + "status", + ]); +}); + +// --- alertModel threshold -------------------------------------------------- + +test("alertModel: breach>0 (fresh) → crit alert", () => { + const recent = new Date().toISOString(); + const alert = alertModel({ + freshness: { available: true, totalBreaches: 4, evaluatedAt: recent }, + }); + assert.ok(alert, "expected an alert"); + assert.equal(alert.level, "crit"); + assert.match(alert.msg, /4/); +}); + +test("alertModel: crit staleness (never evaluated) → crit alert even with 0 breaches", () => { + const alert = alertModel({ + freshness: { available: false, totalBreaches: 0, evaluatedAt: null }, + }); + assert.ok(alert, "never-evaluated must surface (silence impossible)"); + assert.equal(alert.level, "crit"); +}); + +test("alertModel: fresh + 0 breaches → no alert (quiet)", () => { + const recent = new Date().toISOString(); + const alert = alertModel({ + freshness: { available: true, totalBreaches: 0, evaluatedAt: recent }, + }); + assert.equal(alert, null); +}); + +test("alertModel: stale-crit timestamp → crit alert", () => { + const old = new Date(Date.now() - (STALE_CRIT_MS + 60000)).toISOString(); + const alert = alertModel({ + freshness: { available: true, totalBreaches: 0, evaluatedAt: old }, + }); + assert.ok(alert, "expected stale-crit alert"); + assert.equal(alert.level, "crit"); +}); + +// --- relativeTime (supporting; used by panels) ----------------------------- + +test("relativeTime: null/invalid → em-dash, never '0초 전'", () => { + assert.equal(relativeTime(null), "—"); + assert.equal(relativeTime(undefined), "—"); + assert.equal(relativeTime("nope"), "—"); +}); + +test("relativeTime: recent → seconds-ago phrasing", () => { + const recent = new Date(Date.now() - 3000).toISOString(); + assert.match(relativeTime(recent), /초 전/); +}); diff --git a/tests/test_cli.py b/tests/test_cli.py index 868cdc5..311db7b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -739,4 +739,5 @@ def test_subcommand_registration_order_is_stable(): "disposition", "reconcile", "read-api", + "dashboard", ] diff --git a/tests/test_dashboard_a11y_static.py b/tests/test_dashboard_a11y_static.py new file mode 100644 index 0000000..cd8b1b0 --- /dev/null +++ b/tests/test_dashboard_a11y_static.py @@ -0,0 +1,319 @@ +"""D6b — static a11y assertions (design.md rev2 §1 D6b / §5.6 M5 / §7). + +This is the BLOCKING substitute for the best-effort axe-core run (D6): pure +Python, no browser, no new deps. It parses the shipped ``style.css`` and +``index.html`` straight off the package static root and asserts the contract: + + 1. ``style.css`` defines the four status tokens (--ok/--warn/--crit/--muted) — + the color surface axe would otherwise audit for contrast. + 2. never-color-alone: each of the SIX states {loading, ok, error, stale, + muted(=미평가), empty} carries a NON-color cue — a text label and/or a shape + icon — somewhere in ``index.html`` (or a documented status class). Meaning + must survive with color stripped. + 3. heading levels do not skip (h1 → h2, never an orphan h3) and the required + landmarks (banner / nav / main / four labelled sections / aside) are present. + +We use stdlib ``html.parser`` so there is zero browser/JS dependency: this gates +the goal even where Playwright/axe are absent (vacuous-skip is forbidden). +""" + +from __future__ import annotations + +import re +from html.parser import HTMLParser + +from security_scanner.runtime.dashboard import STATIC_ROOT + +STYLE_CSS = STATIC_ROOT / "style.css" +INDEX_HTML = STATIC_ROOT / "index.html" + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _read(path) -> str: + return path.read_text(encoding="utf-8") + + +class _DomParser(HTMLParser): + """Collect the bits we need: heading levels, landmark roles, attrs, classes.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.heading_levels: list[int] = [] + self.tags: list[str] = [] # all start-tag names, in order + self.roles: list[str] = [] # explicit role="" values + self.classes: set[str] = set() + self.attrs_seen: list[dict[str, str | None]] = [] + # landmark-bearing elements (tag, attrs) for landmark checks + self.elements: list[tuple[str, dict[str, str | None]]] = [] + self.text_chunks: list[str] = [] + + def handle_starttag(self, tag, attrs): + attr = dict(attrs) + self.tags.append(tag) + self.attrs_seen.append(attr) + self.elements.append((tag, attr)) + if tag in ("h1", "h2", "h3", "h4", "h5", "h6"): + self.heading_levels.append(int(tag[1])) + role = attr.get("role") + if role: + self.roles.append(role) + cls = attr.get("class") + if cls: + self.classes.update(cls.split()) + + def handle_data(self, data): + chunk = data.strip() + if chunk: + self.text_chunks.append(chunk) + + +def _parse_index() -> _DomParser: + parser = _DomParser() + parser.feed(_read(INDEX_HTML)) + return parser + + +# --------------------------------------------------------------------------- +# 1. style.css defines the four status tokens +# --------------------------------------------------------------------------- + + +def test_style_defines_four_status_tokens(): + """--ok/--warn/--crit/--muted must be declared (the contrast surface).""" + css = _read(STYLE_CSS) + for token in ("--ok", "--warn", "--crit", "--muted"): + assert re.search(rf"{re.escape(token)}\s*:\s*#[0-9a-fA-F]{{3,8}}", css), ( + f"status token {token} missing a hex value in style.css" + ) + + +def test_style_tokens_match_design_values(): + """The four tokens carry the exact design.md §5.6 hex values.""" + css = _read(STYLE_CSS) + expected = { + "--ok": "#4ade80", + "--warn": "#fbbf24", + "--crit": "#f87171", + "--muted": "#94a3b8", + } + for token, hexval in expected.items(): + assert re.search(rf"{re.escape(token)}\s*:\s*{hexval}\b", css, re.IGNORECASE), ( + f"{token} should be {hexval}" + ) + + +def test_style_has_severity_cascade(): + """A data-severity cascade carries COLOR (text/icon cues carry meaning).""" + css = _read(STYLE_CSS) + for sev in ("ok", "warn", "crit", "muted"): + assert f'[data-severity="{sev}"]' in css, ( + f'data-severity="{sev}" rule missing' + ) + + +def test_style_density_and_a11y_primitives(): + """Density + a11y primitives the spec calls out explicitly (§5.6).""" + css = _read(STYLE_CSS) + # tabular figures for dense metric alignment + assert "tabular-nums" in css + # 44px touch targets + assert "44px" in css + # responsive breakpoints 1024 / 768 + assert "1024px" in css and "768px" in css + # findings horizontal-scroll wrapper + assert "overflow-x" in css + # prefers-reduced-motion respected + assert "prefers-reduced-motion" in css + # 12-column grid + assert "repeat(12" in css + + +# --------------------------------------------------------------------------- +# 2. never-color-alone: every state has a non-color cue +# --------------------------------------------------------------------------- + +# Each of the six states maps to (1) a status class present in the markup and +# (2) at least one shape icon glyph. The text label is verified by the class +# block carrying visible Korean text alongside the icon. +_SHAPE_ICONS = {"●", "▲", "✕", "○", "▼", "↻"} + +_STATE_CLASSES = { + "loading": "state-loading", + "ok": "state-content", + "error": "state-error", + "empty": "state-empty", + "muted": "state-muted", # 미평가 + # `stale` is surfaced by the stale-banner (text "STALE" + ▲ warning glyph, + # shape agreeing with its amber/warn color), not a per-panel state block; it + # keeps last-good content visible (design §6). + "stale": "stale-banner", +} + + +def test_every_state_has_a_status_class(): + """All six states have a documented status class present in index.html.""" + parser = _parse_index() + for state, cls in _STATE_CLASSES.items(): + assert cls in parser.classes, ( + f"state {state!r}: class {cls!r} not found in index.html" + ) + + +def test_every_state_carries_a_non_color_cue(): + """Each state ships a text label and/or a shape icon — never color alone. + + We require a shape icon glyph to appear in the document AND the state's class + block to carry visible text, so meaning survives with color removed. + """ + html = _read(INDEX_HTML) + parser = _parse_index() + + # at least one shape icon glyph is present for the iconographic cue. + present_icons = {g for g in _SHAPE_ICONS if g in html} + assert present_icons, "no shape-icon glyphs found — color would be the only cue" + + # every state class is paired with a state-icon element somewhere (icon cue) + # and the document carries human-readable Korean labels (text cue). + assert "state-icon" in parser.classes, "state-icon (shape cue) missing" + + # text labels for each state (Korean, non-color cue). + text_cues = { + "loading": "불러오는 중", + "ok": None, # ok renders live content; cue is the rendered values + "error": "불러올 수 없습니다", + "empty": "없음", + "muted": "미평가", + "stale": "STALE", + } + joined = " ".join(parser.text_chunks) + for state, label in text_cues.items(): + if label is None: + continue + assert label in joined, ( + f"state {state!r}: text label {label!r} (non-color cue) missing" + ) + + +def test_status_icons_are_aria_hidden(): + """Shape icons are decorative (aria-hidden); the adjacent text is the label.""" + parser = _parse_index() + icon_elements = [ + attr + for tag, attr in parser.elements + if "state-icon" in (attr.get("class") or "").split() + or "banner-icon" in (attr.get("class") or "").split() + or "btn-icon" in (attr.get("class") or "").split() + ] + assert icon_elements, "expected at least one decorative icon span" + for attr in icon_elements: + assert attr.get("aria-hidden") == "true", ( + "decorative icon must be aria-hidden so SR reads the text label only" + ) + + +# --------------------------------------------------------------------------- +# 3. heading levels do not skip + required landmarks present +# --------------------------------------------------------------------------- + + +def test_heading_levels_do_not_skip(): + """Heading sequence never jumps a level (h1 → h2, never an orphan h3+).""" + parser = _parse_index() + levels = parser.heading_levels + assert levels, "no headings found in index.html" + assert levels[0] == 1, f"first heading should be h1, got h{levels[0]}" + seen_max = 0 + for lvl in levels: + # a heading may never be more than one deeper than the deepest seen. + assert lvl <= seen_max + 1, ( + f"heading level skip: h{lvl} after max depth h{seen_max}" + ) + seen_max = max(seen_max, lvl) + + +def test_single_h1(): + """Exactly one h1 (the dashboard title).""" + parser = _parse_index() + assert parser.heading_levels.count(1) == 1, "there must be exactly one h1" + + +def test_required_landmarks_present(): + """banner / nav / main / aside landmarks + four labelled sections exist.""" + parser = _parse_index() + + # header[role=banner] + has_banner = any( + tag == "header" and attr.get("role") == "banner" + for tag, attr in parser.elements + ) + assert has_banner, "header[role=banner] landmark missing" + + # nav + assert "nav" in parser.tags, "nav landmark missing" + + # main + assert "main" in parser.tags, "main landmark missing" + + # aside (top-level alert stream) + assert "aside" in parser.tags, "aside landmark missing" + + # four labelled sections (panels) via aria-labelledby + labelled_sections = [ + attr + for tag, attr in parser.elements + if tag == "section" and attr.get("aria-labelledby") + ] + assert len(labelled_sections) == 4, ( + f"expected 4 aria-labelledby sections, found {len(labelled_sections)}" + ) + + +def test_two_skip_links_present(): + """Two skip links (design §5.5) provide keyboard bypass.""" + parser = _parse_index() + skip_links = [ + attr + for tag, attr in parser.elements + if tag == "a" and "skip-link" in (attr.get("class") or "").split() + ] + assert len(skip_links) == 2, f"expected 2 skip links, found {len(skip_links)}" + for attr in skip_links: + href = attr.get("href") or "" + assert href.startswith("#"), f"skip link href should be a fragment: {href!r}" + + +def test_announcers_present_and_distinct(): + """status (polite) + alert (role=alert, no aria-live dup) announcers exist.""" + parser = _parse_index() + by_id = {attr.get("id"): attr for _tag, attr in parser.elements if attr.get("id")} + + status = by_id.get("status-announcer") + assert status is not None, "#status-announcer missing" + assert status.get("role") == "status" + assert status.get("aria-live") == "polite" + + alert = by_id.get("alert-announcer") + assert alert is not None, "#alert-announcer missing" + assert alert.get("role") == "alert" + # role=alert implies assertive aria-live; duplicating it double-announces. + assert alert.get("aria-live") is None, "role=alert must NOT also set aria-live" + + +def test_sortable_headers_have_aria_sort(): + """Every sortable findings column header carries aria-sort (on the ).""" + parser = _parse_index() + th_with_buttons = [ + attr + for tag, attr in parser.elements + if tag == "th" and attr.get("aria-sort") is not None + ] + # 10 wire-key columns are sortable (the leading "선택" column is not). + assert len(th_with_buttons) == 10, ( + f"expected 10 aria-sort headers, found {len(th_with_buttons)}" + ) + for attr in th_with_buttons: + assert attr.get("aria-sort") == "none", "initial aria-sort should be 'none'" diff --git a/tests/test_dashboard_server.py b/tests/test_dashboard_server.py new file mode 100644 index 0000000..71c1200 --- /dev/null +++ b/tests/test_dashboard_server.py @@ -0,0 +1,168 @@ +"""M2 dashboard server tests (design §5.2 + §8 M2, in-process WSGI, no socket). + +Exercises ``make_dashboard_app`` as a callable (no real bind): + + - ``test_all_routes_200``: all 8 dashboard paths (``/``, ``/app.js``, + ``/style.css`` static + the 5 read routes) return 200 with the right + Content-Type. The static placeholders shipped with the package back ``/`` etc; + the read routes are delegated to the M7 read-API WSGI app. + - dispatch: an unknown path (``/bogus``) falls through to static and 404s; a + POST to a read route (``/snapshot``) is delegated and comes back 405 from the + read API (the dashboard does not handle methods — read_api does). + - ``test_nonloopback_bind_rejected``: ``run_dashboard`` refuses a non-loopback + bind without authentication BEFORE binding a socket (F9 trust invariant). +""" + +from __future__ import annotations + +import json +import pathlib + +import pytest + +from security_scanner.runtime.dashboard import ( + STATIC_ROOT, + make_dashboard_app, + run_dashboard, +) +from security_scanner.runtime.read_api import ReadApiServerConfig +from security_scanner.storage.base import ( + BreachCounter, + CatalogEntry, + QueueBacklog, + RepoHealth, +) + + +class _SnapshotStore: + """In-process store exposing the full read-API surface for all 5 routes.""" + + def read_breach_counter(self) -> BreachCounter | None: + return BreachCounter( + incremental_breaches=1, + baseline_breaches=0, + total_breaches=1, + repos_evaluated=3, + evaluated_at="2026-06-19T12:00:00+00:00", + coverage_gap=1, + ) + + def read_all_catalog_entries(self) -> list[CatalogEntry]: + return [ + CatalogEntry( + repo_id="repo_a", + repo_url="https://example.com/repo_a", + included=True, + first_seen="2026-06-01T00:00:00+00:00", + last_reconciled="2026-06-19T00:00:00+00:00", + excluded_reason=None, + ) + ] + + def read_all_repo_health(self) -> list[RepoHealth]: + return [RepoHealth(repo_id="repo_a")] + + def read_queue_backlog(self) -> QueueBacklog: + return QueueBacklog( + job_counts_by_status={"pending": 2, "leased": 1}, + backlog=3, + ) + + def read_all(self): + return [] + + def read_for_scan_run(self, scan_run_id): + raise AssertionError("scan-run read not expected") + + +def _call(app, method: str, path: str): + """Drive the WSGI app in-process; return (status, headers dict, body bytes).""" + captured: dict[str, object] = {} + + def start_response(status, headers): + captured["status"] = status + captured["headers"] = dict(headers) + + body = b"".join( + app( + {"REQUEST_METHOD": method, "PATH_INFO": path, "QUERY_STRING": ""}, + start_response, + ) + ) + return captured["status"], captured["headers"], body + + +# --------------------------------------------------------------------------- +# All 8 routes 200 with the right Content-Type +# --------------------------------------------------------------------------- + + +def test_all_routes_200(): + """All 8 dashboard paths return 200 with a correct Content-Type (D4).""" + app = make_dashboard_app(_SnapshotStore(), STATIC_ROOT) + + # 3 static paths backed by the package placeholders (/ -> index.html). + static_expectations = { + "/": "text/html", + "/app.js": "application/javascript", + "/style.css": "text/css", + } + for path, content_type_prefix in static_expectations.items(): + status, headers, body = _call(app, "GET", path) + assert status == "200 OK", f"{path} -> {status}" + assert headers["Content-Type"].startswith(content_type_prefix), ( + f"{path} Content-Type={headers['Content-Type']!r}" + ) + assert headers["X-Content-Type-Options"] == "nosniff" + assert headers["Content-Length"] == str(len(body)) + + # 5 read routes delegated to the M7 read-API WSGI app (JSON). + for path in ("/snapshot", "/freshness", "/coverage", "/backlog", "/findings"): + status, headers, body = _call(app, "GET", path) + assert status == "200 OK", f"{path} -> {status}" + assert headers["Content-Type"] == "application/json", path + json.loads(body) # well-formed JSON + + +# --------------------------------------------------------------------------- +# Dispatch: unknown path -> static 404; non-GET read route -> read-API 405 +# --------------------------------------------------------------------------- + + +def test_unknown_path_falls_through_to_static_404(): + """A path outside READ_ROUTES is served as static and 404s when absent.""" + app = make_dashboard_app(_SnapshotStore(), STATIC_ROOT) + status, _headers, _body = _call(app, "GET", "/bogus.html") + assert status == "404 Not Found" + + +def test_post_to_read_route_is_delegated_and_405(): + """POST /snapshot is delegated to the read API, which returns 405 (not 404). + + Proves the dashboard does NOT handle methods itself: a read route is handed to + read_api regardless of method, and read_api answers 405 for a non-GET. + """ + app = make_dashboard_app(_SnapshotStore(), STATIC_ROOT) + status, headers, _body = _call(app, "POST", "/snapshot") + assert status == "405 Method Not Allowed" + assert headers["Content-Type"] == "application/json" + + +# --------------------------------------------------------------------------- +# F9 trust invariant: non-loopback bind without auth refused before any socket +# --------------------------------------------------------------------------- + + +def test_nonloopback_bind_rejected(): + """run_dashboard refuses a non-loopback bind without auth (ValueError). + + The validation happens before any socket bind; we assert the ValueError so a + real listener is never created. + """ + config = ReadApiServerConfig(host="0.0.0.0", port=8787, require_auth=False) + with pytest.raises(ValueError, match="non-loopback bind without authentication"): + run_dashboard( + config, + store=_SnapshotStore(), + static_dir=pathlib.Path(STATIC_ROOT), + ) diff --git a/tests/test_dashboard_smoke.py b/tests/test_dashboard_smoke.py new file mode 100644 index 0000000..671ec85 --- /dev/null +++ b/tests/test_dashboard_smoke.py @@ -0,0 +1,296 @@ +"""D5/D6 — live dashboard smoke (best-effort, env-gated; design.md rev2 §1 D5, +§7 L3, §8 M6). + +This is the **best-effort** live acceptance pass. Its blocking substitutes are +D5b (``node --test`` pure logic) and D6b (``tests/test_dashboard_a11y_static.py`` +static parser), so this file is NEVER required for goal completion — it auto-skips +when its environment is absent (Playwright not installed, Docker/compose down). +Per design §1: a vacuous skip here cannot pass the goal; D5b/D6b always gate. + +When ``RUN_DASHBOARD_SMOKE=1`` (an INDEPENDENT gate from ``RUN_DDB_LOCAL_SMOKE``) +and Playwright + a Docker/compose v2 + the dynamodb-local service are all present, +it: + + 1. brings up dynamodb-local with ``docker compose`` (v2 plugin) — endpoint + ``http://localhost:4567`` (``${SECURITY_SCANNER_DYNAMO_HOST_PORT:-4567}``), + table ``SecurityScannerLocal`` (the same fixtures the integration tests use); + 2. seeds a store so the panels have live content (breach + queue + catalog); + 3. launches ``security-scanner dashboard`` on a free loopback port (subprocess); + 4. loads the page with Playwright (Chromium) and asserts the live acceptance + bar: 4 panels render; ``available=false`` shows "미평가" (never 0); the + disposition filter round-trips to ``/findings``; staleness is surfaced; and + a breach/stale threshold raises the alert surface. axe-core contrast/region + is run when available (D6); its absence is skipped, not failed. + +Network/binary discovery failures degrade to ``pytest.skip`` (best-effort), never +a hard failure, so this file is safe to leave in the always-collected suite. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import socket +import subprocess +import time +import urllib.error +import urllib.request + +import pytest + +# --- gate 1: explicit opt-in (independent of RUN_DDB_LOCAL_SMOKE) ----------- +pytestmark = pytest.mark.skipif( + os.environ.get("RUN_DASHBOARD_SMOKE") != "1", + reason="live dashboard smoke; RUN_DASHBOARD_SMOKE=1 (best-effort; D5b/D6b gate)", +) + +# Endpoint + table pinned to the repo's existing integration fixtures (§7 L3). +DYNAMO_HOST_PORT = os.environ.get("SECURITY_SCANNER_DYNAMO_HOST_PORT", "4567") +DYNAMO_ENDPOINT = f"http://localhost:{DYNAMO_HOST_PORT}" +DYNAMO_TABLE = "SecurityScannerLocal" +COMPOSE_SERVICE = "dynamodb-local" + + +# --------------------------------------------------------------------------- +# best-effort capability probes -> pytest.skip (never a hard failure) +# --------------------------------------------------------------------------- + + +def _require_playwright(): + if importlib.util.find_spec("playwright") is None: + pytest.skip("playwright not installed (D5b/D6b are the blocking gate)") + from playwright.sync_api import sync_playwright # noqa: PLC0415 + + return sync_playwright + + +def _require_docker_compose_v2() -> list[str]: + """Return the ``docker compose`` v2 argv, or skip if unavailable.""" + docker = shutil.which("docker") + if docker is None: + pytest.skip("docker not on PATH (best-effort live smoke)") + try: + proc = subprocess.run( + [docker, "compose", "version"], + capture_output=True, + text=True, + timeout=20, + ) + except (OSError, subprocess.SubprocessError) as exc: # pragma: no cover + pytest.skip(f"docker compose v2 probe failed: {exc}") + if proc.returncode != 0: + pytest.skip("docker compose (v2 plugin) unavailable") + return [docker, "compose"] + + +def _free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_http(url: str, timeout_s: float = 60.0) -> None: + """Poll a URL until it answers (any HTTP status), or skip on timeout.""" + deadline = time.time() + timeout_s + last_err: Exception | None = None + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2): # noqa: S310 + return + except urllib.error.HTTPError: + return # an HTTP error still means the server is up + except (urllib.error.URLError, OSError) as exc: + last_err = exc + time.sleep(0.5) + pytest.skip(f"service at {url} never came up: {last_err}") + + +def _wait_dynamo(timeout_s: float = 60.0) -> None: + """DynamoDB Local answers 400 to a bare GET — that means it is listening.""" + _wait_http(DYNAMO_ENDPOINT, timeout_s=timeout_s) + + +# --------------------------------------------------------------------------- +# fixtures: dynamodb-local (compose) + seeded store + dashboard subprocess +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def _dynamo_local(): + """Bring up the dynamodb-local compose service for the module, then down.""" + compose = _require_docker_compose_v2() + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env = {**os.environ, "SECURITY_SCANNER_DYNAMO_HOST_PORT": DYNAMO_HOST_PORT} + up = subprocess.run( + [*compose, "up", "-d", COMPOSE_SERVICE], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=180, + ) + if up.returncode != 0: + pytest.skip(f"compose up {COMPOSE_SERVICE} failed:\n{up.stderr}") + try: + _wait_dynamo() + yield DYNAMO_ENDPOINT + finally: + subprocess.run( + [*compose, "stop", COMPOSE_SERVICE], + cwd=repo_root, + env=env, + capture_output=True, + text=True, + timeout=120, + ) + + +@pytest.fixture(scope="module") +def _seeded_store(_dynamo_local): + """Create the table and seed minimal live content for the panels.""" + from security_scanner.storage.adapters.nosql_db.transport import ( # noqa: PLC0415 + DynamoDbCompatibleConfig, + ) + from security_scanner.storage.factory import create_finding_store # noqa: PLC0415 + + cfg = DynamoDbCompatibleConfig( + table_name=DYNAMO_TABLE, + endpoint_url=DYNAMO_ENDPOINT, + region_name="us-east-1", + aws_access_key_id="dummy", + aws_secret_access_key="dummy", + ) + store = create_finding_store("dynamodb", dynamodb_config=cfg) + store.ensure_table() + # The panels tolerate an empty store (available=false -> 미평가, empty queue), + # which is exactly the "silence is impossible" case D5 must show. We keep the + # seed minimal and rely on the UI's empty/미평가 rendering. + return cfg + + +@pytest.fixture(scope="module") +def _dashboard_url(_seeded_store): + """Launch ``security-scanner dashboard`` on a free loopback port.""" + port = _free_loopback_port() + env = { + **os.environ, + "SECURITY_SCANNER_STORAGE_BACKEND": "dynamodb", + "SECURITY_SCANNER_DYNAMO_ENDPOINT": DYNAMO_ENDPOINT, + "SECURITY_SCANNER_DYNAMO_TABLE": DYNAMO_TABLE, + } + proc = subprocess.Popen( + [ + "security-scanner", + "dashboard", + "--storage-backend", + "dynamodb", + "--dynamodb-endpoint-url", + DYNAMO_ENDPOINT, + "--dynamodb-table", + DYNAMO_TABLE, + "--host", + "127.0.0.1", + "--port", + str(port), + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + url = f"http://127.0.0.1:{port}" + try: + _wait_http(f"{url}/snapshot", timeout_s=30.0) + yield url + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: # pragma: no cover + proc.kill() + + +# --------------------------------------------------------------------------- +# D5: live render + acceptance bar +# --------------------------------------------------------------------------- + + +def test_snapshot_endpoint_serves_panels(_dashboard_url): + """The live /snapshot returns freshness/coverage/backlog panels (no findings).""" + with urllib.request.urlopen(f"{_dashboard_url}/snapshot", timeout=5) as resp: # noqa: S310 + payload = json.loads(resp.read()) + assert "freshness" in payload + assert "coverage" in payload + assert "backlog" in payload + # /snapshot never carries findings (design §2 invariant). + assert "findings" not in payload + + +def test_findings_endpoint_round_trips(_dashboard_url): + """The /findings route answers JSON with a findings list (disposition filter).""" + url = f"{_dashboard_url}/findings?disposition=unreviewed" + with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310 + payload = json.loads(resp.read()) + assert "findings" in payload + assert isinstance(payload["findings"], list) + + +def test_page_renders_panels_and_never_silent(_dashboard_url): + """Playwright loads the page: 4 panels render; unevaluated shows 미평가, not 0.""" + sync_playwright = _require_playwright() + with sync_playwright() as p: + try: + browser = p.chromium.launch() + except Exception as exc: # pragma: no cover - browser binary missing + pytest.skip(f"chromium launch failed (run `playwright install`): {exc}") + page = browser.new_page() + page.goto(_dashboard_url, wait_until="networkidle") + + # 4 labelled panels (sections) are present in the live DOM. + sections = page.locator("main section[aria-labelledby]") + assert sections.count() == 4 + + # an empty store renders 미평가 for the health metric, never a bare 0 + # ("silence is structurally impossible" — design §0/§4). + body_text = page.inner_text("body") + assert "미평가" in body_text + + # the alert surface exists in the DOM (breach/stale would populate it). + assert page.locator("#alert-announcer").count() == 1 + assert page.locator("aside").count() >= 1 + + # --- D6 (best-effort): axe-core contrast + region, when available ----- + _maybe_run_axe(page) + + browser.close() + + +def _maybe_run_axe(page) -> None: + """Run axe-core for contrast+region if the bundle is available; else skip it. + + axe is best-effort (D6); D6b (static a11y parser) is the blocking gate. We + only fail on a genuine axe violation, never on axe's absence. + """ + # axe-core is not a project dependency. Only run it when an env-provided + # bundle path exists (e.g. AXE_CORE_MIN_JS=/path/to/axe.min.js); otherwise + # skip silently — D6b (static a11y parser) is the blocking gate. + axe_path = os.environ.get("AXE_CORE_MIN_JS") + if not (axe_path and os.path.isfile(axe_path)): + return + with open(axe_path, encoding="utf-8") as fh: + axe_min = fh.read() + + page.add_script_tag(content=axe_min) + result = page.evaluate( + """async () => { + const r = await axe.run(document, { + runOnly: { type: 'tag', values: ['wcag2aa'] }, + }); + return r.violations + .filter(v => ['color-contrast', 'region'].includes(v.id)) + .map(v => v.id); + }""" + ) + assert result == [], f"axe contrast/region violations: {result}" diff --git a/tests/test_dashboard_static.py b/tests/test_dashboard_static.py new file mode 100644 index 0000000..a215acf --- /dev/null +++ b/tests/test_dashboard_static.py @@ -0,0 +1,159 @@ +"""M2 static-serving guard tests (design §5.2 guard order, in-process, no socket). + +``serve_static`` (inside ``make_dashboard_app``) must enforce the EXACT guard +order: + + 1. null-byte in the path -> 400 BEFORE any resolve (an unquoted NUL would make + ``Path.resolve()`` raise -> a 500); + 2. resolve + ``is_relative_to`` containment -> 403 (traversal); + 3. extension allowlist -> 403; + 4. ``is_file`` -> 404; + 5. otherwise 200 with Content-Type / Content-Length / nosniff / Cache-Control + (``index.html`` -> ``no-store``). + +These drive a temp static dir so each case is exact and isolated, plus a +symlinked-ancestor case to prove resolving BOTH sides keeps a real file 200. +""" + +from __future__ import annotations + +import os + +import pytest + +from security_scanner.runtime.dashboard import make_dashboard_app + + +class _NullStore: + """A store that must never be touched (static tests never hit read routes).""" + + def __getattr__(self, name): + raise AssertionError(f"read route should not be reached: {name}") + + +def _static_app(tmp_path): + """Build a dashboard app rooted at a temp static dir with known files.""" + (tmp_path / "index.html").write_text("x") + (tmp_path / "style.css").write_text(":root{}") + (tmp_path / "secret.txt").write_text("not allowed extension") + return make_dashboard_app(_NullStore(), tmp_path) + + +def _call(app, path: str): + captured: dict[str, object] = {} + + def start_response(status, headers): + captured["status"] = status + captured["headers"] = dict(headers) + + body = b"".join( + app( + {"REQUEST_METHOD": "GET", "PATH_INFO": path, "QUERY_STRING": ""}, + start_response, + ) + ) + return captured["status"], captured["headers"], body + + +# --------------------------------------------------------------------------- +# Guard 1: null-byte -> 400 (BEFORE resolve) +# --------------------------------------------------------------------------- + + +def test_null_byte_path_returns_400(tmp_path): + app = _static_app(tmp_path) + status, _headers, _body = _call(app, "/style.css\x00.png") + assert status == "400 Bad Request" + + +# --------------------------------------------------------------------------- +# Guard 2: traversal -> 403 (is_relative_to containment) +# --------------------------------------------------------------------------- + + +def test_traversal_escape_returns_403(tmp_path): + app = _static_app(tmp_path) + status, _headers, _body = _call(app, "/../../etc/passwd") + assert status == "403 Forbidden" + + +def test_deep_traversal_to_real_file_returns_403(tmp_path): + """Even a traversal that resolves to an existing file is contained (403).""" + app = _static_app(tmp_path) + # climb out of the static root entirely, then back to a sibling. + status, _headers, _body = _call(app, "/../index.html") + assert status == "403 Forbidden" + + +# --------------------------------------------------------------------------- +# Guard 3: extension allowlist -> 403 +# --------------------------------------------------------------------------- + + +def test_disallowed_extension_returns_403(tmp_path): + app = _static_app(tmp_path) + status, _headers, _body = _call(app, "/secret.txt") + assert status == "403 Forbidden" + + +# --------------------------------------------------------------------------- +# Guard 4: allowed extension, missing file -> 404 +# --------------------------------------------------------------------------- + + +def test_allowed_extension_missing_file_returns_404(tmp_path): + app = _static_app(tmp_path) + status, _headers, _body = _call(app, "/missing.js") + assert status == "404 Not Found" + + +# --------------------------------------------------------------------------- +# Guard 5: 200 headers — nosniff + Content-Length + Cache-Control +# --------------------------------------------------------------------------- + + +def test_served_asset_has_nosniff_and_content_length(tmp_path): + app = _static_app(tmp_path) + status, headers, body = _call(app, "/style.css") + assert status == "200 OK" + assert headers["X-Content-Type-Options"] == "nosniff" + assert headers["Content-Length"] == str(len(body)) + assert headers["Content-Type"].startswith("text/css") + # non-index assets are cacheable. + assert headers["Cache-Control"] == "max-age=3600" + + +def test_index_html_is_no_store(tmp_path): + """The bootstrap shell (index.html, served for /) must be no-store.""" + app = _static_app(tmp_path) + status, headers, _body = _call(app, "/") + assert status == "200 OK" + assert headers["Cache-Control"] == "no-store" + assert headers["Content-Type"].startswith("text/html") + + +# --------------------------------------------------------------------------- +# Symlinked-ancestor: resolving BOTH sides keeps a real file 200 (design §5.2) +# --------------------------------------------------------------------------- + + +def test_symlinked_ancestor_still_serves_real_file(tmp_path): + """A symlink to the static root resolves to the same place -> real file 200. + + Mirrors the macOS ``/var`` -> ``/private/var`` case: the app resolves the root + once and the candidate per request, so containment holds through the symlink. + """ + real_root = tmp_path / "real" + real_root.mkdir() + (real_root / "app.js").write_text("export {};") + + link_root = tmp_path / "link" + try: + os.symlink(real_root, link_root) + except (OSError, NotImplementedError): + pytest.skip("symlinks unsupported on this platform") + + app = make_dashboard_app(_NullStore(), link_root) + status, headers, body = _call(app, "/app.js") + assert status == "200 OK" + assert headers["Content-Length"] == str(len(body)) diff --git a/tests/test_fr_trace.py b/tests/test_fr_trace.py new file mode 100644 index 0000000..2655c1b --- /dev/null +++ b/tests/test_fr_trace.py @@ -0,0 +1,396 @@ +"""D8 — FR-A1..A12 requirement trace (design.md rev2 §1 D8 / §8 M6). + +Every functional requirement FR-A1..A12 (requirements.md §「FR-A」) must map to +BOTH a concrete implementation site AND at least one automated test that gates +it. This file is that trace, and it is **executable**: it does not merely assert +that prose mentions each FR — it asserts that + + 1. every FR-A1..A12 row exists in the ``TRACE`` table (no FR silently dropped); + 2. each cited code file exists on disk (the implementation is real); and + 3. each cited **test node** actually exists in its file — + * Python ``tests/*.py`` nodes (``file::test_fn``) are parsed with ``ast`` + so a renamed/deleted test fails this trace rather than rotting silently; + * JS ``node --test`` cases (``file > "title"``) are confirmed by locating + the ``test("title", ...)`` registration in the ``.mjs`` source. + +The blocking substitutes (D5b/D6b: ``node --test`` logic + the static a11y +parser) are the gates for the FRs whose primary verification (D5/D6 Playwright/ +axe) is best-effort, so vacuous-skip is impossible: each browser-side FR still +has a non-browser test node cited here. + +Paths are repo-root relative. The trace is the single source of the FR↔proof +mapping referenced by D8; ``TRACE.md`` (worktree root) renders the same table +for humans and is kept in sync by ``test_trace_md_matches_table``. +""" + +from __future__ import annotations + +import ast +import pathlib +import re + +import pytest + +# Repo root = parent of this tests/ directory. +ROOT = pathlib.Path(__file__).resolve().parent.parent + +# Canonical FR-A1..A12 -> (summary, code sites, test-node citations). +# +# A "test node" is one of: +# * "tests/.py::test_" (pytest node id) +# * "src/.../ui/test/.mjs > " (node --test case title) +# +# Each FR lists at least one node that is BLOCKING (D1/D2/D3/D4/D6b/D7), so the +# trace can never rest solely on a best-effort (D5/D6) check. +TRACE: dict[str, dict[str, object]] = { + "FR-A1": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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": { + "summary": ( + "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)"', + ], + }, +} + +ALL_FRS = [f"FR-A{i}" for i in range(1, 13)] + + +# --------------------------------------------------------------------------- +# helpers: resolve a cited test node to a real definition +# --------------------------------------------------------------------------- + + +def _py_test_functions(py_path: pathlib.Path) -> set[str]: + """All top-level ``test_*`` function names defined in a python test file.""" + tree = ast.parse(py_path.read_text(encoding="utf-8"), filename=str(py_path)) + names: set[str] = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node.name.startswith("test_"): + names.add(node.name) + return names + + +def _mjs_test_titles(mjs_path: pathlib.Path) -> set[str]: + """All ``test("title", ...)`` titles registered in a node:test .mjs file.""" + source = mjs_path.read_text(encoding="utf-8") + # match test("...") or test('...'); titles here use only " in the trace. + titles: set[str] = set() + for m in re.finditer(r"""\btest\(\s*(['"])(.*?)\1""", source, re.DOTALL): + titles.add(m.group(2)) + return titles + + +def _node_exists(node: str) -> tuple[bool, str]: + """Return (exists, detail) for a cited test node id (py or mjs).""" + if "::" in node: + file_part, _, fn = node.partition("::") + path = ROOT / file_part + if not path.is_file(): + return False, f"file missing: {file_part}" + if fn not in _py_test_functions(path): + return False, f"test fn {fn!r} not defined in {file_part}" + return True, "ok" + if " > " in node: + file_part, _, title = node.partition(" > ") + file_part = file_part.strip() + title = title.strip().strip('"') + path = ROOT / file_part + if not path.is_file(): + return False, f"file missing: {file_part}" + if title not in _mjs_test_titles(path): + return False, f"node:test title {title!r} not found in {file_part}" + return True, "ok" + return False, f"unrecognized node id form: {node!r}" + + +# --------------------------------------------------------------------------- +# 1. every FR-A1..A12 is present in the trace (none silently dropped) +# --------------------------------------------------------------------------- + + +def test_all_frs_present_in_trace(): + """FR-A1..A12 each have a TRACE row (D8: full coverage, no gap).""" + missing = [fr for fr in ALL_FRS if fr not in TRACE] + assert not missing, f"FR(s) missing from TRACE: {missing}" + extra = [fr for fr in TRACE if fr not in ALL_FRS] + assert not extra, f"unexpected FR id(s) in TRACE (typo?): {extra}" + + +@pytest.mark.parametrize("fr", ALL_FRS) +def test_fr_has_code_and_test_citations(fr): + """Each FR cites at least one code site and at least one test node.""" + row = TRACE[fr] + code = row.get("code") or [] + tests = row.get("tests") or [] + assert code, f"{fr}: no code site cited" + assert tests, f"{fr}: no test node cited" + summary = row.get("summary") or "" + assert isinstance(summary, str) and summary.strip(), f"{fr}: empty summary" + + +# --------------------------------------------------------------------------- +# 2. every cited code file exists on disk +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fr", ALL_FRS) +def test_fr_code_files_exist(fr): + """Each cited implementation file is real (the FR is actually built).""" + for rel in TRACE[fr]["code"]: # type: ignore[index] + path = ROOT / rel + assert path.is_file(), f"{fr}: code file missing: {rel}" + + +# --------------------------------------------------------------------------- +# 3. every cited test node resolves to a real definition (py fn / mjs title) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fr", ALL_FRS) +def test_fr_test_nodes_exist(fr): + """Each cited test node resolves to a real pytest fn / node:test title. + + This is what makes the trace non-vacuous: a renamed or deleted test breaks + the FR mapping immediately instead of rotting into a stale citation. + """ + failures = [] + for node in TRACE[fr]["tests"]: # type: ignore[index] + ok, detail = _node_exists(node) + if not ok: + failures.append(f"{node} -> {detail}") + assert not failures, f"{fr}: unresolved test node(s):\n " + "\n ".join(failures) + + +# --------------------------------------------------------------------------- +# 4. browser-side FRs still have a NON-browser (blocking) test node +# --------------------------------------------------------------------------- + +# These FRs' primary verification (D5/D6) is best-effort (Playwright/axe). The +# trace must cite a blocking substitute (node --test logic, or the static a11y +# pytest parser) so the goal can never be "achieved" by a vacuous skip. +_BROWSER_FRS = {"FR-A1", "FR-A4", "FR-A5", "FR-A6", "FR-A7", "FR-A8", + "FR-A9", "FR-A10", "FR-A11", "FR-A12"} + + +@pytest.mark.parametrize("fr", sorted(_BROWSER_FRS)) +def test_browser_fr_has_blocking_substitute(fr): + """A browser-verified FR cites at least one blocking (.mjs or a11y) node.""" + nodes = TRACE[fr]["tests"] # type: ignore[index] + has_blocking = any( + (".mjs > " in n) # node --test logic (D3/D5b) + or n.startswith("tests/test_dashboard_a11y_static.py::") # D6b + or n.startswith("tests/test_read_api_findings_route.py::") # D1/D7 + or n.startswith("tests/test_dashboard_server.py::") # D4 + for n in nodes + ) + assert has_blocking, ( + f"{fr}: no blocking test node cited (vacuous-skip risk) -> {nodes}" + ) + + +# --------------------------------------------------------------------------- +# 5. TRACE.md (human-facing render) stays in sync with the table +# --------------------------------------------------------------------------- + + +def test_trace_md_matches_table(): + """TRACE.md (worktree root) lists every FR row from the TRACE table. + + The markdown is a human render of the same source of truth; we assert each + FR id and its first cited test node appear so the doc cannot drift silently. + """ + trace_md = ROOT / "TRACE.md" + assert trace_md.is_file(), "TRACE.md missing at worktree root" + text = trace_md.read_text(encoding="utf-8") + for fr in ALL_FRS: + assert fr in text, f"TRACE.md does not mention {fr}" diff --git a/tests/test_read_api_findings_route.py b/tests/test_read_api_findings_route.py new file mode 100644 index 0000000..6bb09fe --- /dev/null +++ b/tests/test_read_api_findings_route.py @@ -0,0 +1,268 @@ +"""M1 /findings route contract tests (design §5.1 + §8 M1, no socket). + +The /findings branch lives in ``build_read_api_wsgi_app``'s ``app()`` body (not a +store-only lambda route, since a lambda cannot read QUERY_STRING). It must: + + - parse ``disposition`` multi-values from QUERY_STRING (FR-A3); + - pre-validate every value against the Disposition vocabulary BEFORE touching + the store, returning a 400 JSON (not a 500 from a bubbled ValueError); + - treat 0 values as "no filter" (dispositions=None), never an empty list; + - apply ``order_findings_for_dashboard`` as the server-side sort authority + (FR-A7) so the wire is pre-sorted by DASHBOARD_DISPOSITION_ORDER; + - emit public-safe DTOs only (no raw secret / raw match anywhere — secretHash + is the only secret-derived field). + +The four existing store-only routes + the 405/404 behavior are unchanged +(covered by ``test_read_api.py``); this file only exercises the new branch. +""" + +from __future__ import annotations + +import json + +from security_scanner.core.finding.model import ( + DASHBOARD_DISPOSITION_ORDER, + Disposition, + Finding, + GitleaksFindingPayload, + Verdict, +) +from security_scanner.runtime.read_api import build_read_api_wsgi_app + +RAW_SECRET = "AKIAFAKEEXAMPLE000000" +RAW_MATCH = "api_key = AKIAFAKEEXAMPLE000000" + + +def _finding(line_start: int, verdict: str) -> Finding: + """A finding carrying a raw gitleaks secret + match, to prove redaction.""" + return Finding.create( + repo_full_name="fake-org/fake-repo", + rule_id="aws-access-key-id", + file_path="config/settings.py", + line_start=line_start, + raw_secret=RAW_SECRET, + source_tool="gitleaks", + scan_run_id="scan_abc12345", + rule_pack_version="secret-rules-0.1.0", + triage_verdict=verdict, + gitleaks=GitleaksFindingPayload( + rule_id="aws-access-key-id", + file="config/settings.py", + start_line=line_start, + match=RAW_MATCH, + secret=RAW_SECRET, + ), + ) + + +class _ListReader: + """In-process store whose ``read_all`` returns a fixed finding list. + + ``read_findings`` applies ``request.dispositions`` itself, so the reader + does no filtering — the route pre-validation + the M6 filter are what we + are exercising. + """ + + def __init__(self, findings: list[Finding]) -> None: + self._findings = list(findings) + + def read_all(self) -> list[Finding]: + return list(self._findings) + + def read_for_scan_run(self, scan_run_id: str) -> list[Finding]: + raise AssertionError("scan-run read not expected on the /findings route") + + +def _call(app, query_string: str): + """Drive the WSGI app in-process; return (status, headers, body bytes).""" + captured: dict[str, object] = {} + + def start_response(status, headers): + captured["status"] = status + captured["headers"] = dict(headers) + + body = b"".join( + app( + { + "REQUEST_METHOD": "GET", + "PATH_INFO": "/findings", + "QUERY_STRING": query_string, + }, + start_response, + ) + ) + return captured["status"], captured["headers"], body + + +# --------------------------------------------------------------------------- +# Filter behavior +# --------------------------------------------------------------------------- + + +def test_findings_route_filters_by_disposition(): + """A disposition multi-value filter slices the findings (FR-A3).""" + app = build_read_api_wsgi_app( + _ListReader( + [ + _finding(1, Verdict.NEEDS_REVIEW.value), # unreviewed + _finding(2, Verdict.TRUE_POSITIVE.value), # verified + _finding(3, Verdict.FALSE_POSITIVE.value), # false_positive + ] + ) + ) + + status, headers, body = _call( + app, + f"disposition={Disposition.UNREVIEWED.value}" + f"&disposition={Disposition.VERIFIED.value}", + ) + + assert status == "200 OK" + assert headers["Content-Type"] == "application/json" + payload = json.loads(body) + assert {f["disposition"] for f in payload["findings"]} == { + Disposition.UNREVIEWED.value, + Disposition.VERIFIED.value, + } + + +def test_findings_route_no_filter_returns_all(): + """0 disposition values => no filter (dispositions=None), all findings.""" + app = build_read_api_wsgi_app( + _ListReader( + [ + _finding(1, Verdict.NEEDS_REVIEW.value), + _finding(2, Verdict.TRUE_POSITIVE.value), + _finding(3, Verdict.FALSE_POSITIVE.value), + ] + ) + ) + + status, _headers, body = _call(app, "") + + assert status == "200 OK" + payload = json.loads(body) + assert len(payload["findings"]) == 3 + + +def test_findings_route_empty_collection_returns_empty_list(): + """An empty store yields an empty findings list, not a key absence.""" + app = build_read_api_wsgi_app(_ListReader([])) + status, _headers, body = _call(app, "") + assert status == "200 OK" + payload = json.loads(body) + assert payload == {"findings": []} + + +# --------------------------------------------------------------------------- +# Pre-validation: invalid disposition -> 400 (NOT a bubbled 500) +# --------------------------------------------------------------------------- + + +def test_findings_route_invalid_disposition_returns_400_before_store(): + """A typo'd disposition value returns 400 JSON before the store is touched.""" + + class _BoomReader: + def read_all(self): + raise AssertionError("store must NOT be read on an invalid filter") + + def read_for_scan_run(self, scan_run_id): + raise AssertionError("store must NOT be read on an invalid filter") + + app = build_read_api_wsgi_app(_BoomReader()) + + status, headers, body = _call(app, "disposition=bogus") + + assert status == "400 Bad Request" + assert headers["Content-Type"] == "application/json" + payload = json.loads(body) + assert payload["error"] == "invalid disposition" + assert payload["invalid"] == ["bogus"] + + +def test_findings_route_mixed_valid_and_invalid_still_400(): + """One bad value among valid ones still rejects the whole request (400).""" + + class _BoomReader: + def read_all(self): + raise AssertionError("store must NOT be read on an invalid filter") + + def read_for_scan_run(self, scan_run_id): + raise AssertionError("store must NOT be read on an invalid filter") + + app = build_read_api_wsgi_app(_BoomReader()) + + status, _headers, body = _call( + app, f"disposition={Disposition.VERIFIED.value}&disposition=nope" + ) + + assert status == "400 Bad Request" + payload = json.loads(body) + assert payload["invalid"] == ["nope"] + + +# --------------------------------------------------------------------------- +# Server sort authority (FR-A7): wire pre-sorted by DASHBOARD_DISPOSITION_ORDER +# --------------------------------------------------------------------------- + + +def test_findings_route_orders_by_dashboard_disposition_order(): + """The server applies order_findings_for_dashboard before projection. + + Input is deliberately in the WRONG order (false_positive first) so a no-op + would fail; the wire must come back unreviewed -> verified -> false_positive. + """ + app = build_read_api_wsgi_app( + _ListReader( + [ + _finding(1, Verdict.FALSE_POSITIVE.value), # false_positive + _finding(2, Verdict.TRUE_POSITIVE.value), # verified + _finding(3, Verdict.NEEDS_REVIEW.value), # unreviewed + ] + ) + ) + + status, _headers, body = _call(app, "") + + assert status == "200 OK" + payload = json.loads(body) + wire_order = [f["disposition"] for f in payload["findings"]] + assert wire_order == list(DASHBOARD_DISPOSITION_ORDER) + + +# --------------------------------------------------------------------------- +# D7: no raw secret anywhere in the payload (secretHash only) +# --------------------------------------------------------------------------- + + +def test_no_raw_secret(): + """No raw secret / raw match leaks in the /findings payload or evidence. + + Only the salted ``secretHash`` survives; the raw secret and the raw scanner + match must be absent from the serialized wire and from every per-finding + object (there is no nested evidence object on the wire — secretHash is a + top-level field — but we assert raw-secret absence over the whole blob and + confirm the surviving hash is the salted digest). + """ + app = build_read_api_wsgi_app( + _ListReader([_finding(7, Verdict.TRUE_POSITIVE.value)]) + ) + + status, _headers, body = _call(app, "") + + assert status == "200 OK" + serialized = body.decode("utf-8") + assert RAW_SECRET not in serialized + assert RAW_MATCH not in serialized + + payload = json.loads(body) + finding = payload["findings"][0] + # No raw-secret-bearing keys leak through the wire. + assert "secret" not in finding + assert "match" not in finding + assert "evidence" not in finding + assert "gitleaks" not in finding + # The salted hash IS present so an operator can correlate without the secret. + assert finding["secretHash"] is not None + assert finding["secretHash"].startswith("salted-sha256:") + assert RAW_SECRET not in finding["secretHash"] From 22e471d6e2fec4da9e1dd54805bc577e4eee1d91 Mon Sep 17 00:00:00 2001 From: pureliture <tkdgur1756@naver.com> Date: Sun, 21 Jun 2026 12:49:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(dashboard):=20address=20PR=20#56=20revi?= =?UTF-8?q?ew=20=E2=80=94=20polling=20backoff=20+=20findings=20error=20sta?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gemini-code-assist 리뷰 후속(M8 UI): - app.js: 폴링 실패 시 setInterval을 clearInterval하고 backoff 타이머만 재시도를 구동, 성공 시 정기 폴 재개. 기존엔 5s interval과 backoff가 동시에 돌아 지수 backoff가 무력화되고 장애 시 요청 폭주(HIGH). - logic.mjs + app.js: findingsPanelModel에 error 상태 추가 + fetchFindings 실패 시 캐시 없으면 {error:true} 전달 → 최초 /findings 실패가 'loading' 영구 고착되지 않고 기존 error UI(index.html/style.css) 노출(MEDIUM ×2). - test_ui_logic.mjs: findingsPanelModel error 케이스 추가(node --test 44 pass). thread-safety(공유 boto3 store)는 loopback 단일 운영자 trust model로 위험이 제한되어 별도 추적 이슈로 분리. CodeQL red는 전부 pre-existing non-M8 alert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt --- src/security_scanner/runtime/ui/static/app.js | 18 +++++++++++++++++- .../runtime/ui/static/logic.mjs | 5 +++++ .../runtime/ui/test/test_ui_logic.mjs | 6 ++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/security_scanner/runtime/ui/static/app.js b/src/security_scanner/runtime/ui/static/app.js index 9afb4ff..68dfba5 100644 --- a/src/security_scanner/runtime/ui/static/app.js +++ b/src/security_scanner/runtime/ui/static/app.js @@ -369,6 +369,11 @@ async function fetchSnapshot() { state.stale = false; applySnapshot(snapshot); renderStaleBanner(false); + // (re)arm the steady cadence on first success or after backoff tore it + // down; during normal polling intervalId is non-null so this is a no-op. + if (state.intervalId === null && typeof setInterval !== "undefined") { + state.intervalId = setInterval(fetchSnapshot, POLL_INTERVAL_MS); + } } catch (_err) { state.failures += 1; state.stale = true; @@ -407,6 +412,9 @@ async function fetchFindings() { // findings are non-critical for the live signal; keep last-good rows. if (state.lastFindings) { renderFindingsPanel(state.lastFindings); + } else { + // no cache: surface the error state instead of being stuck on loading. + renderFindingsPanel({ error: true }); } } finally { state.findingsInFlight = false; @@ -429,6 +437,13 @@ function scheduleBackoff() { if (typeof setTimeout === "undefined") { return; } + // stop the steady cadence so only the backoff timer drives retries — without + // this the 5s interval keeps firing alongside backoff (request storm) and the + // exponential backoff is defeated. + if (state.intervalId !== null) { + clearInterval(state.intervalId); + state.intervalId = null; + } if (state.backoffTimerId !== null) { clearTimeout(state.backoffTimerId); } @@ -446,8 +461,9 @@ function startPolling() { return; } stopPolling(); + // The steady setInterval is (re)armed by fetchSnapshot's success path, so a + // failed first poll goes straight to backoff instead of running both timers. fetchSnapshot(); // immediate first poll - state.intervalId = setInterval(fetchSnapshot, POLL_INTERVAL_MS); } function stopPolling() { diff --git a/src/security_scanner/runtime/ui/static/logic.mjs b/src/security_scanner/runtime/ui/static/logic.mjs index ae55966..73e4d25 100644 --- a/src/security_scanner/runtime/ui/static/logic.mjs +++ b/src/security_scanner/runtime/ui/static/logic.mjs @@ -297,6 +297,11 @@ export function backlogPanelModel(backlogDto) { * @returns {{fields: Array, severity: string, state: string, rows: Array}} */ export function findingsPanelModel(resp) { + if (resp && resp.error) { + // first-load (or refetch) failure with no cached rows — surface the error + // state instead of being stuck on "loading" forever (design.md §6). + return { fields: [], severity: "crit", state: "error", rows: [] }; + } if (!hasFindings(resp)) { return { fields: [], severity: "muted", state: "loading", rows: [] }; } diff --git a/src/security_scanner/runtime/ui/test/test_ui_logic.mjs b/src/security_scanner/runtime/ui/test/test_ui_logic.mjs index b56dce9..41a55cb 100644 --- a/src/security_scanner/runtime/ui/test/test_ui_logic.mjs +++ b/src/security_scanner/runtime/ui/test/test_ui_logic.mjs @@ -237,6 +237,12 @@ test("findingsPanelModel: present empty [] → empty", () => { assert.equal(m.state, "empty"); }); +test("findingsPanelModel: error flag → error (escapes stuck loading)", () => { + const m = findingsPanelModel({ error: true }); + assert.equal(m.state, "error"); + assert.deepEqual(m.rows, []); +}); + test("findingsPanelModel: present rows → ok, 10 wire keys verbatim, order preserved", () => { const wire = [ { From 47ad080e6ccd474309e0863b4e42a57b1d3d7acd Mon Sep 17 00:00:00 2001 From: pureliture <tkdgur1756@naver.com> Date: Sun, 21 Jun 2026 12:58:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(dashboard):=20thread-safe=20per-request?= =?UTF-8?q?=20store=20(no=20defer)=20=E2=80=94=20Closes=20#57?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #56 리뷰의 마지막 항목(공유 boto3 store thread-safety)을 PR 내에서 해결. make_dashboard_app/run_dashboard가 단일 store 대신 store_factory를 받아 read 요청마다 자체 store(boto3 resource)를 생성 → ThreadingWSGIServer 요청 스레드 간 boto3 resource/Session 공유 제거. 정적 서빙은 store 미접근(무영향). CLI는 lambda: store_from_args(args) factory 전달. 테스트 호출부 factory로 갱신. 게이트: pytest 1149 passed/4 skipped, node --test 44/44, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018hXHR6Uj9nGZq84ksfP8Lt --- .../cli/commands/dashboard.py | 6 +++-- src/security_scanner/runtime/dashboard.py | 23 +++++++++++++------ tests/test_dashboard_server.py | 8 +++---- tests/test_dashboard_static.py | 4 ++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/security_scanner/cli/commands/dashboard.py b/src/security_scanner/cli/commands/dashboard.py index 649873f..e591184 100644 --- a/src/security_scanner/cli/commands/dashboard.py +++ b/src/security_scanner/cli/commands/dashboard.py @@ -57,8 +57,10 @@ def cmd_dashboard(args: argparse.Namespace) -> int: config = ReadApiServerConfig(host=args.host, port=args.port) try: - store = store_from_args(args) - run_dashboard(config, store=store) + # 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) diff --git a/src/security_scanner/runtime/dashboard.py b/src/security_scanner/runtime/dashboard.py index 5d91dde..f0e16c8 100644 --- a/src/security_scanner/runtime/dashboard.py +++ b/src/security_scanner/runtime/dashboard.py @@ -77,15 +77,21 @@ class ThreadingWSGIServer( block_on_close = True -def make_dashboard_app(store: Any, static_dir: pathlib.Path): +def make_dashboard_app(store_factory, static_dir: pathlib.Path): """Build the dashboard WSGI app: read-API delegation + static serving. + ``store_factory`` is a zero-arg callable returning a store. The server is + threaded (:class:`ThreadingWSGIServer` spawns a thread per request) and boto3 + resource/Session objects are NOT thread-safe, so a read request builds its OWN + store via ``store_factory()`` rather than sharing one across request threads + (gemini-code-assist review, PR #56). Static serving never touches the store. + Dispatch is purely by ``PATH_INFO``: a path in :data:`READ_ROUTES` is - delegated to the M7 read-API WSGI app (which owns method handling — a non-GET - on a read route comes back 405 from read_api, not from here); everything else - is served as a static asset under ``static_dir`` by :func:`serve_static`. + delegated to a per-request M7 read-API WSGI app (which owns method handling — + a non-GET on a read route comes back 405 from read_api, not from here); + everything else is served as a static asset under ``static_dir`` by + :func:`serve_static`. """ - read_app = build_read_api_wsgi_app(store) static_root = static_dir.resolve() def serve_static(path_info: str, start_response): @@ -139,6 +145,9 @@ def serve_static(path_info: str, start_response): def app(environ: dict[str, Any], start_response): path = environ.get("PATH_INFO", "/") if path in READ_ROUTES: + # per-request store + read app: never share a boto3 resource across + # the server's request threads (thread-safety, PR #56). + read_app = build_read_api_wsgi_app(store_factory()) return read_app(environ, start_response) return serve_static(path, start_response) @@ -161,7 +170,7 @@ def _respond(start_response, status: str, body: bytes): def run_dashboard( config: ReadApiServerConfig, *, - store: Any, + store_factory, static_dir: pathlib.Path = STATIC_ROOT, ) -> None: """Validate the F9 trust invariant, bind, serve, and shut down cleanly. @@ -177,7 +186,7 @@ def run_dashboard( """ validate_read_api_server_config(config) - app = make_dashboard_app(store, static_dir) + app = make_dashboard_app(store_factory, static_dir) server = wsgiref.simple_server.make_server( config.host, config.port, diff --git a/tests/test_dashboard_server.py b/tests/test_dashboard_server.py index 71c1200..b9224e8 100644 --- a/tests/test_dashboard_server.py +++ b/tests/test_dashboard_server.py @@ -99,7 +99,7 @@ def start_response(status, headers): def test_all_routes_200(): """All 8 dashboard paths return 200 with a correct Content-Type (D4).""" - app = make_dashboard_app(_SnapshotStore(), STATIC_ROOT) + app = make_dashboard_app(lambda: _SnapshotStore(), STATIC_ROOT) # 3 static paths backed by the package placeholders (/ -> index.html). static_expectations = { @@ -131,7 +131,7 @@ def test_all_routes_200(): def test_unknown_path_falls_through_to_static_404(): """A path outside READ_ROUTES is served as static and 404s when absent.""" - app = make_dashboard_app(_SnapshotStore(), STATIC_ROOT) + app = make_dashboard_app(lambda: _SnapshotStore(), STATIC_ROOT) status, _headers, _body = _call(app, "GET", "/bogus.html") assert status == "404 Not Found" @@ -142,7 +142,7 @@ def test_post_to_read_route_is_delegated_and_405(): Proves the dashboard does NOT handle methods itself: a read route is handed to read_api regardless of method, and read_api answers 405 for a non-GET. """ - app = make_dashboard_app(_SnapshotStore(), STATIC_ROOT) + app = make_dashboard_app(lambda: _SnapshotStore(), STATIC_ROOT) status, headers, _body = _call(app, "POST", "/snapshot") assert status == "405 Method Not Allowed" assert headers["Content-Type"] == "application/json" @@ -163,6 +163,6 @@ def test_nonloopback_bind_rejected(): with pytest.raises(ValueError, match="non-loopback bind without authentication"): run_dashboard( config, - store=_SnapshotStore(), + store_factory=lambda: _SnapshotStore(), static_dir=pathlib.Path(STATIC_ROOT), ) diff --git a/tests/test_dashboard_static.py b/tests/test_dashboard_static.py index a215acf..a113eeb 100644 --- a/tests/test_dashboard_static.py +++ b/tests/test_dashboard_static.py @@ -36,7 +36,7 @@ def _static_app(tmp_path): (tmp_path / "index.html").write_text("<!doctype html><title>x") (tmp_path / "style.css").write_text(":root{}") (tmp_path / "secret.txt").write_text("not allowed extension") - return make_dashboard_app(_NullStore(), tmp_path) + return make_dashboard_app(lambda: _NullStore(), tmp_path) def _call(app, path: str): @@ -153,7 +153,7 @@ def test_symlinked_ancestor_still_serves_real_file(tmp_path): except (OSError, NotImplementedError): pytest.skip("symlinks unsupported on this platform") - app = make_dashboard_app(_NullStore(), link_root) + app = make_dashboard_app(lambda: _NullStore(), link_root) status, headers, body = _call(app, "/app.js") assert status == "200 OK" assert headers["Content-Length"] == str(len(body))