From 0ae5f81296dfe3122bff0727adfe407d82ab7aa5 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 12:52:20 -0400 Subject: [PATCH 01/21] =?UTF-8?q?feat(serve):=20SA=20scaffold=20=E2=80=94?= =?UTF-8?q?=20package,=20CLI=20subcommand,=20healthz=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of argus serve — the local read-only web UI that shares the findings_view renderer with argus browse (TUI). This commit wires up the scaffolding so subsequent phases can focus purely on routes and templates: - pyproject.toml [serve] extra with fastapi, uvicorn[standard], jinja2, python-multipart. Added to [all] alongside browse. - argus/serve/__init__.py — optional-import guard (ServeUnavailable) + launch(root, port, open_browser) entry point. Mirrors the argus/browse/ shape so the CLI handler patterns are symmetrical. - argus/serve/app.py — create_app(root) factory and run_app() uvicorn runner. Binds 127.0.0.1 only by design — localhost-only is the product shape (no auth, no multi-user, no CSRF to implement). Single /healthz route for now; real views land in SB/SC/SD/SE. --open uses stdlib webbrowser (handles URLs across platforms; doesn't require the [browse] extra's path-oriented opener). - argus/cli.py — `argus serve [PATH] [--port N] [--open]` subcommand wired into the parser + dispatch table. Friendly ServeUnavailable error when the extra isn't installed, same pattern as cmd_browse. - argus/tests/serve/test_scaffold.py — 7 tests: * subcommand parsing (default / root / port / open) * ServeUnavailable friendly error with install hint * /healthz returns status + resolved root (both explicit path and cwd-default) Route tests use FastAPI's TestClient so no live uvicorn needed; marked ``pytest.importorskip("fastapi")`` so the suite stays green when the [serve] extra isn't installed. - docs/cli-reference.md regenerated for the new subcommand. Phase plan (tracked in roadmap): - SA (this commit) — scaffold - SB — dashboard landing page for a known scan path - SC — findings table + scan metadata routes - SD — picker flow + filesystem discovery - SE — HTMX filter interactivity - SF — docs, --open default, ADR --- argus/cli.py | 60 +++++++++++++++++ argus/serve/__init__.py | 73 +++++++++++++++++++++ argus/serve/app.py | 100 ++++++++++++++++++++++++++++ argus/tests/serve/__init__.py | 0 argus/tests/serve/test_scaffold.py | 101 +++++++++++++++++++++++++++++ docs/cli-reference.md | 28 ++++++++ pyproject.toml | 4 +- 7 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 argus/serve/__init__.py create mode 100644 argus/serve/app.py create mode 100644 argus/tests/serve/__init__.py create mode 100644 argus/tests/serve/test_scaffold.py diff --git a/argus/cli.py b/argus/cli.py index 1e050b0e..e5c88991 100644 --- a/argus/cli.py +++ b/argus/cli.py @@ -150,6 +150,7 @@ def build_parser() -> argparse.ArgumentParser: _build_completion_parser(subparsers) _build_cache_parser(subparsers) _build_browse_parser(subparsers) + _build_serve_parser(subparsers) return parser @@ -194,6 +195,64 @@ def cmd_browse(args: argparse.Namespace) -> int: return EXIT_ERROR +def _build_serve_parser(subparsers: argparse._SubParsersAction) -> None: + """Add the 'serve' subcommand — local read-only web UI.""" + serve_parser = subparsers.add_parser( + "serve", + help="Launch a local web UI to browse scan findings", + description=( + "Serve a read-only web view of argus scan results on " + "localhost:\n" + " argus serve # picker rooted at CWD\n" + " argus serve /path/to/results/ # load that scan directly\n" + " argus serve --port 9090 --open # custom port, open browser\n\n" + "Bound to 127.0.0.1 only by design — single-user, no auth, no\n" + "mutations. For enterprise multi-user deployments see\n" + "argus-portal (separate track).\n\n" + "Requires the 'serve' extra: pip install 'argus-security[serve]'" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + serve_parser.add_argument( + "root", + nargs="?", + default=None, + metavar="PATH", + help="Starting folder for the picker, or a direct argus-results.json " + "path (default: current working directory)", + ) + serve_parser.add_argument( + "--port", + type=int, + default=8080, + help="TCP port to listen on (default: 8080)", + ) + serve_parser.add_argument( + "--open", + dest="open_browser", + action="store_true", + help="Open the default browser at the server URL after startup", + ) + + +def cmd_serve(args: argparse.Namespace) -> int: + """Execute the serve subcommand — launch the local web UI.""" + try: + from argus.serve import launch, ServeUnavailable + except ImportError as exc: # pragma: no cover — defensive + print(f"Error: could not import argus.serve: {exc}", file=sys.stderr) + return EXIT_ERROR + try: + return launch( + root=args.root, + port=args.port, + open_browser=args.open_browser, + ) + except ServeUnavailable as exc: + print(f"Error: {exc}", file=sys.stderr) + return EXIT_ERROR + + def _build_init_parser(subparsers: argparse._SubParsersAction) -> None: """Add the 'init' subcommand for project initialization.""" init_parser = subparsers.add_parser( @@ -2355,6 +2414,7 @@ def main(argv: list[str] | None = None) -> None: "completion": cmd_completion, "cache": cmd_cache, "browse": cmd_browse, + "serve": cmd_serve, } handler = handlers.get(args.command) diff --git a/argus/serve/__init__.py b/argus/serve/__init__.py new file mode 100644 index 00000000..38f71dbe --- /dev/null +++ b/argus/serve/__init__.py @@ -0,0 +1,73 @@ +"""Local read-only web UI — ``argus serve``. + +A FastAPI app bundled with the argus SDK that serves the same findings +as the argus-results.json produced by ``argus scan``. Scoped to a +single user on localhost — no auth system, no database, no mutations. +Intended for owners/managers/execs who want easy insight into their +products without digging through CI or learning a TUI. + +Two front-ends share the renderer: +- TUI (argus browse) → Textual widgets wrapping finding_detail_rows +- Web (argus serve, here) → Jinja templates consuming the same dict + +Both read from argus.core.findings_view so filter/sort/summary logic +is identical across surfaces. + +This package is optional: install with ``pip install argus-security[serve]``. +Importing it without the extra raises :class:`ServeUnavailable` with an +install hint rather than a bare ImportError. +""" + +from __future__ import annotations + + +class ServeUnavailable(RuntimeError): + """Raised when ``argus serve`` is invoked without the ``serve`` extra.""" + + +def _require_web_stack() -> None: + """Import-guard so the CLI can surface a friendly install hint. + + Three packages are load-bearing: FastAPI (the framework), Jinja2 + (templates), and uvicorn (ASGI server). python-multipart is pulled in + transitively by FastAPI when we add form endpoints; surfacing it + individually here makes the missing-dep error more actionable if + that slips from an upstream change. + """ + missing: list[str] = [] + for mod in ("fastapi", "jinja2", "uvicorn"): + try: + __import__(mod) + except ImportError: + missing.append(mod) + if missing: + raise ServeUnavailable( + "The local web UI needs the 'serve' extra. " + "Install it with: pip install 'argus-security[serve]' " + f"(missing: {', '.join(missing)})" + ) + + +def launch( + root: str | None = None, + *, + port: int = 8080, + open_browser: bool = False, +) -> int: + """Start the local argus serve web UI. + + Returns a process-style exit code suitable for ``sys.exit()``. The + app module is imported lazily so importing ``argus.serve.launch`` + doesn't crash when FastAPI isn't installed. + + Binds to ``127.0.0.1`` only — localhost-only is the product shape + (see argus/serve/app.py docstring). There is no ``--bind`` flag by + design; if a future deployment needs network exposure, that's a + separate design decision that requires auth etc. + """ + _require_web_stack() + from argus.serve.app import run_app + return run_app(root=root, port=port, open_browser=open_browser) + + +__all__ = ["ServeUnavailable", "launch"] diff --git a/argus/serve/app.py b/argus/serve/app.py new file mode 100644 index 00000000..e513749c --- /dev/null +++ b/argus/serve/app.py @@ -0,0 +1,100 @@ +"""FastAPI application factory and uvicorn runner for ``argus serve``. + +Phase SA shape: the app is created with a ``/healthz`` route only, so +the subcommand scaffolding, import-guard, and uvicorn invocation can be +exercised end-to-end before the dashboard / findings / picker routes +land in subsequent phases. + +The app is deliberately bound to ``127.0.0.1``; there is no ``--bind`` +flag by design. Localhost-only is the product shape — no auth, no +multi-user, no CSRF or session handling to implement. Any future +network-exposed deployment is a separate design decision. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + + +logger = logging.getLogger("argus.serve") + + +def create_app(root: str | None = None) -> FastAPI: + """Build the FastAPI app, stashing the picker root path on app.state. + + ``root`` is the filesystem starting point for the picker: either a + directory that will be used as-is, or a single argus-results.json + that loads the dashboard immediately. Validation of the path (does + it exist? is it a valid results file?) is deferred to route + handlers in later phases so startup stays cheap. + """ + app = FastAPI( + title="Argus", + description="Local read-only view of argus scan findings.", + version="0.1.0", # independent of argus SDK version + docs_url=None, # no /docs — this is a user-facing UI, not an API + redoc_url=None, + ) + app.state.root = Path(root).resolve() if root else Path.cwd() + + @app.get("/healthz") + async def healthz() -> JSONResponse: + """Liveness check. Returns the effective picker root. + + Handy for scripts that poll the server during startup and for + smoke-testing the uvicorn binding behind a reverse proxy in + the rare ``--bind`` case users set up themselves. + """ + return JSONResponse({ + "status": "ok", + "root": str(app.state.root), + }) + + return app + + +def run_app( + *, + root: str | None, + port: int, + open_browser: bool, +) -> int: + """Create the app and serve it via uvicorn on ``127.0.0.1:``. + + Returns 0 on a clean shutdown (Ctrl+C), 2 on an error during bind + or serve. The ``open_browser`` flag opens the user's default + browser at the server URL after uvicorn starts listening. + """ + import uvicorn + + app = create_app(root=root) + url = f"http://127.0.0.1:{port}" + + if open_browser: + # ``webbrowser`` is stdlib, handles URL-vs-file dispatch and the + # platform opener shell-out internally, and doesn't require the + # [browse] extra (``argus.browse.app._platform_opener_argv`` is + # path-oriented and would mis-route a URL). Not fatal on failure + # — uvicorn still prints the URL below. + import webbrowser + try: + webbrowser.open(url, new=1) + except Exception as exc: # noqa: BLE001 — webbrowser can raise broadly on headless systems + logger.debug("webbrowser.open failed: %s — skipping auto-open", exc) + + logger.info("argus serve listening on %s (Ctrl+C to stop)", url) + print(f"argus serve listening on {url} — Ctrl+C to stop") + + try: + uvicorn.run(app, host="127.0.0.1", port=port, log_level="info") + except KeyboardInterrupt: + return 0 + except OSError as exc: + logger.error("uvicorn failed to bind: %s", exc) + print(f"Error: uvicorn failed to bind on {url}: {exc}") + return 2 + return 0 diff --git a/argus/tests/serve/__init__.py b/argus/tests/serve/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/argus/tests/serve/test_scaffold.py b/argus/tests/serve/test_scaffold.py new file mode 100644 index 00000000..94005dd8 --- /dev/null +++ b/argus/tests/serve/test_scaffold.py @@ -0,0 +1,101 @@ +"""Phase SA tests for argus serve — package scaffolding. + +Covers: +- CLI subcommand parsing and defaults +- Friendly ServeUnavailable error when the [serve] extra isn't installed +- /healthz route on the FastAPI app (skipped when extra not installed) + +Route tests use httpx.AsyncClient via FastAPI's TestClient so they +don't need a live uvicorn; the full-stack uvicorn startup is exercised +manually during development and by CI integration tests later. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from argus.cli import build_parser, cmd_serve, EXIT_ERROR + + +class TestServeSubcommandParsing: + def test_serve_default_args(self): + parser = build_parser() + args = parser.parse_args(["serve"]) + assert args.command == "serve" + assert args.root is None + assert args.port == 8080 + assert args.open_browser is False + + def test_serve_with_root_path(self): + parser = build_parser() + args = parser.parse_args(["serve", "/path/to/results"]) + assert args.root == "/path/to/results" + + def test_serve_custom_port(self): + parser = build_parser() + args = parser.parse_args(["serve", "--port", "9090"]) + assert args.port == 9090 + + def test_serve_open_flag(self): + parser = build_parser() + args = parser.parse_args(["serve", "--open"]) + assert args.open_browser is True + + +class TestServeUnavailableFriendlyError: + def test_missing_extra_returns_exit_error_and_prints_hint(self, capsys): + """When FastAPI isn't installed, cmd_serve exits EXIT_ERROR with a hint.""" + from argus.serve import ServeUnavailable + import argparse + + def fake_launch(**_kwargs): + raise ServeUnavailable( + "The local web UI needs the 'serve' extra. " + "Install it with: pip install 'argus-security[serve]'" + ) + + with patch("argus.serve.launch", fake_launch): + rc = cmd_serve(argparse.Namespace( + root=None, port=8080, open_browser=False, + )) + assert rc == EXIT_ERROR + err = capsys.readouterr().err + assert "argus-security[serve]" in err + + +# --------------------------------------------------------------------------- +# Route tests — only meaningful when the [serve] extra is present. +# FastAPI's TestClient spins up the app without uvicorn. +# --------------------------------------------------------------------------- + +fastapi = pytest.importorskip("fastapi") + + +class TestHealthzRoute: + def test_healthz_returns_ok_and_root(self, tmp_path): + from fastapi.testclient import TestClient + from argus.serve.app import create_app + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/healthz") + assert resp.status_code == 200 + payload = resp.json() + assert payload["status"] == "ok" + # Root was resolved; TestClient gives us the absolute path. + assert str(tmp_path.resolve()) == payload["root"] + + def test_healthz_defaults_root_to_cwd_when_none(self, tmp_path, monkeypatch): + from fastapi.testclient import TestClient + from argus.serve.app import create_app + + monkeypatch.chdir(tmp_path) + app = create_app(root=None) + client = TestClient(app) + resp = client.get("/healthz") + assert resp.status_code == 200 + # Defaulting to cwd is what the picker starts navigating from + # when the user launches `argus serve` with no path arg. + assert str(tmp_path.resolve()) == resp.json()["root"] diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 019b47e1..f135603b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -285,6 +285,34 @@ argus browse [-h] [PATH] - `results` — Results directory or argus-results.json path (default: ./argus-results/) +### `argus serve` + +Serve a read-only web view of argus scan results on localhost: + argus serve # picker rooted at CWD + argus serve /path/to/results/ # load that scan directly + argus serve --port 9090 --open # custom port, open browser + +Bound to 127.0.0.1 only by design — single-user, no auth, no +mutations. For enterprise multi-user deployments see +argus-portal (separate track). + +Requires the 'serve' extra: pip install 'argus-security[serve]' + +``` +argus serve [-h] [--port PORT] [--open] [PATH] +``` + +**Arguments:** + +- `root` — Starting folder for the picker, or a direct argus-results.json path (default: current working directory) + +**Options:** + +| Flag | Description | Default | +|------|-------------|---------| +| `--port` | TCP port to listen on (default: 8080) | `8080` | +| `--open` | Open the default browser at the server URL after startup | `false` | + ## Quick Reference ```bash diff --git a/pyproject.toml b/pyproject.toml index 6afc07fc..4badd762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,10 @@ completion = ["argcomplete>=3.0"] mcp = ["mcp>=1.0.0"] # Interactive findings browser (argus browse) browse = ["textual>=0.80"] +# Local read-only web UI (argus serve) +serve = ["fastapi>=0.110", "uvicorn[standard]>=0.30", "jinja2>=3.1", "python-multipart>=0.0.9"] # All optional features -all = ["argus-security[ai,completion,mcp,browse]"] +all = ["argus-security[ai,completion,mcp,browse,serve]"] [project.scripts] argus = "argus.cli:main" From 057550e4a36a1f28d982c9ae335e49899524f9ba Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 12:57:10 -0400 Subject: [PATCH 02/21] feat(serve): SB dashboard landing page with scan resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the executive-summary view onto the ``/`` route. Supports both launch-root-based and ``?scan=``-based scan selection so the URL stays bookmarkable and the future picker (SD) can hand off simply by redirecting to ``/?scan=...``. - argus/serve/app.py * Jinja2Templates + StaticFiles mounts. * ``_resolve_scan(raw, launch_root)`` — ``(results_file, error)`` resolution: query-param path beats launch root; directories get ``argus-results.json`` appended; friendly error messages for missing paths and directories without results files. * ``/`` renders ``summary.html.j2`` using compute_summary() from argus.core.findings_view — same aggregate logic powering the TUI dashboard, so the two surfaces can't drift. * CSP + clickjacking headers attached via middleware to every response (defense-in-depth alongside the tag in the base template). * Errors from load_summary caught and rendered as the placeholder state rather than bubbling up to a 500. - argus/serve/templates/base.html.j2 * Layout: header with nav, main slot, footer. Nav shows the Dashboard link active and stubs for Findings / Switch scan (wired in SC/SD). - argus/serve/templates/summary.html.j2 * Empty state with actionable hint when no scan is in scope. * At-a-glance severity cards (total + per-severity when > 0). * Scan quality warnings banner (SPDX-2.1 / purl coverage / grype unknown-subject) — loud up top so empty scans can't be mistaken for clean. * Per-product breakdown with top-3 findings per product. * Per-scanner contribution counts. - argus/serve/static/argus.css * Minimal classless styles, dark-first, self-contained. No CDN, no build step. ~4 KB. Severity color tokens exported as CSS variables for future theme-toggle. - argus/tests/serve/test_dashboard.py (12 tests) * ``_resolve_scan``: file / directory / missing / fallback-to-root * ``/`` empty state when root has no results * ``/`` populated state when root has results * ``?scan=`` overrides launch root * malformed JSON renders error placeholder (not 500) * CSP + X-Frame-Options headers on every response * ``/static/argus.css`` served with correct content-type Note: starlette ≥0.32 expects ``request=`` on TemplateResponse; keyword form used throughout so regressions fail at import time, not at render time. --- argus/serve/app.py | 139 +++++++++++++++++--- argus/serve/static/argus.css | 140 ++++++++++++++++++++ argus/serve/templates/base.html.j2 | 49 +++++++ argus/serve/templates/summary.html.j2 | 114 +++++++++++++++++ argus/tests/serve/test_dashboard.py | 178 ++++++++++++++++++++++++++ 5 files changed, 599 insertions(+), 21 deletions(-) create mode 100644 argus/serve/static/argus.css create mode 100644 argus/serve/templates/base.html.j2 create mode 100644 argus/serve/templates/summary.html.j2 create mode 100644 argus/tests/serve/test_dashboard.py diff --git a/argus/serve/app.py b/argus/serve/app.py index e513749c..8567c391 100644 --- a/argus/serve/app.py +++ b/argus/serve/app.py @@ -1,9 +1,9 @@ """FastAPI application factory and uvicorn runner for ``argus serve``. -Phase SA shape: the app is created with a ``/healthz`` route only, so -the subcommand scaffolding, import-guard, and uvicorn invocation can be -exercised end-to-end before the dashboard / findings / picker routes -land in subsequent phases. +Phase SB shape: ``/`` renders the executive-summary dashboard when a +scan is in scope (either the launch root pointed at an +``argus-results.json`` directly, or the request's ``?scan=`` +query param resolves to one). ``/healthz`` stays for liveness checks. The app is deliberately bound to ``127.0.0.1``; there is no ``--bind`` flag by design. Localhost-only is the product shape — no auth, no @@ -13,47 +13,144 @@ from __future__ import annotations +import json import logging from pathlib import Path -from fastapi import FastAPI -from fastapi.responses import JSONResponse +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from argus.browse.loader import RESULTS_FILENAME, load_summary +from argus.core.findings_view import compute_summary logger = logging.getLogger("argus.serve") +_SERVE_DIR = Path(__file__).resolve().parent +_TEMPLATES_DIR = _SERVE_DIR / "templates" +_STATIC_DIR = _SERVE_DIR / "static" + +# Strict CSP — matches the tag in base.html.j2. Kept +# in sync at both layers so the protection doesn't silently evaporate +# if the meta tag gets dropped. +_CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'" -def create_app(root: str | None = None) -> FastAPI: - """Build the FastAPI app, stashing the picker root path on app.state. - ``root`` is the filesystem starting point for the picker: either a - directory that will be used as-is, or a single argus-results.json - that loads the dashboard immediately. Validation of the path (does - it exist? is it a valid results file?) is deferred to route - handlers in later phases so startup stays cheap. +def _resolve_scan( + raw: str | None, + *, + launch_root: Path, +) -> tuple[Path | None, str | None]: + """Return ``(results_file_path, error_message)`` for a scan reference. + + Resolution rules: + 1. If the caller supplied a path (via ``?scan=...``), use it. + 2. Otherwise fall back to the server's launch root. + 3. If the resolved path points at a directory, look for + ``argus-results.json`` inside it. + 4. If the resolved path is a file, use it as-is. + 5. If nothing matches, return ``(None, "reason")`` so the view + can render the empty-state placeholder with an actionable + error. """ + target = Path(raw).expanduser() if raw else launch_root + try: + target = target.resolve() + except OSError as exc: + return None, f"Could not resolve path: {exc}" + + if not target.exists(): + return None, f"Path does not exist: {target}" + + if target.is_dir(): + candidate = target / RESULTS_FILENAME + if candidate.is_file(): + return candidate, None + return None, ( + f"No {RESULTS_FILENAME} inside {target}. " + "Pick a results directory or pass a specific JSON path via ?scan=..." + ) + + if target.is_file(): + return target, None + + return None, f"Unsupported path kind: {target}" + + +def create_app(root: str | None = None) -> FastAPI: + """Build the FastAPI app, wire templates / static, register routes.""" app = FastAPI( title="Argus", description="Local read-only view of argus scan findings.", - version="0.1.0", # independent of argus SDK version - docs_url=None, # no /docs — this is a user-facing UI, not an API + version="0.1.0", + docs_url=None, redoc_url=None, ) app.state.root = Path(root).resolve() if root else Path.cwd() - @app.get("/healthz") - async def healthz() -> JSONResponse: - """Liveness check. Returns the effective picker root. + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + app.state.templates = templates + app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") + + @app.middleware("http") + async def _csp_headers(request: Request, call_next): + """Attach the CSP + click-jacking headers to every response. - Handy for scripts that poll the server during startup and for - smoke-testing the uvicorn binding behind a reverse proxy in - the rare ``--bind`` case users set up themselves. + Belt-and-suspenders: the base template also sets a CSP via + ````, but headers are the primary defense + (they apply to non-HTML responses and can't be stripped by a + downstream template edit). """ + response = await call_next(request) + response.headers["Content-Security-Policy"] = _CSP + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + return response + + @app.get("/healthz") + async def healthz() -> JSONResponse: return JSONResponse({ "status": "ok", "root": str(app.state.root), }) + @app.get("/", response_class=HTMLResponse) + async def dashboard(request: Request, scan: str | None = None) -> Response: + """Executive-summary dashboard for the active scan context. + + ``?scan=`` overrides the launch root — makes the URL + bookmarkable and lets the future picker hand off a chosen + scan by pointing back at ``/?scan=...``. + """ + results_path, error = _resolve_scan(scan, launch_root=app.state.root) + context = { + "scan_param": scan, + "scan_label": None, + "summary": None, + "error": error, + } + if results_path is not None: + try: + scan_summary, resolved = load_summary(results_path) + context["scan_label"] = str(resolved) + context["summary"] = compute_summary( + [f for r in scan_summary.results for f in r.findings], + top_n=3, + ) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + context["error"] = str(exc) + # Starlette ≥0.32 uses the ``(request, name, context)`` signature; + # keyword form is the forward-compatible style that works across + # versions and catches regressions at import time rather than at + # template-render time. + return templates.TemplateResponse( + request=request, + name="summary.html.j2", + context=context, + ) + return app diff --git a/argus/serve/static/argus.css b/argus/serve/static/argus.css new file mode 100644 index 00000000..c2a7f19a --- /dev/null +++ b/argus/serve/static/argus.css @@ -0,0 +1,140 @@ +/* argus serve — minimal classless styles, self-contained (no CDN). + * + * Dark-first, high-contrast, readable at a distance. Intentionally + * spare so the dashboard content carries the weight. No frameworks, + * no resets beyond what's needed to keep browser defaults from + * fighting us. CSS variables up top so a future theme-toggle can + * flip the palette without touching layout rules. + */ + +:root { + --bg: #0f1419; + --surface: #1b2128; + --surface-alt: #232a33; + --border: #313843; + --fg: #e6e8eb; + --fg-muted: #a0a8b3; + --accent: #5cb85c; + --crit: #e74c3c; + --high: #ff8c42; + --med: #f1c40f; + --low: #3498db; + --info: #95a5a6; + --radius: 6px; + --gap: 1rem; + --max-width: 72rem; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; padding: 0; + background: var(--bg); + color: var(--fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", + "SF Pro Text", "Inter", sans-serif; + font-size: 16px; + line-height: 1.5; +} + +header, main, footer { padding: 1rem 1.5rem; max-width: var(--max-width); margin: 0 auto; } + +header { + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--border); + margin-bottom: 1.5rem; +} +header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; } +header .crumbs { color: var(--fg-muted); font-size: 0.9rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +header nav a { + color: var(--fg-muted); text-decoration: none; margin-left: 1rem; + padding: 0.25rem 0.5rem; border-radius: var(--radius); +} +header nav a:hover { background: var(--surface); color: var(--fg); } +header nav a[aria-current="page"] { color: var(--fg); background: var(--surface); } + +h1, h2, h3 { margin: 1.5rem 0 0.75rem; } +h2 { font-size: 1.25rem; } +h3 { font-size: 1rem; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.05em; } + +a { color: var(--accent); } +a:hover { opacity: 0.85; } + +code, pre, kbd { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + background: var(--surface); border-radius: 4px; padding: 0.1em 0.3em; + font-size: 0.92em; +} +pre { padding: 1rem; overflow-x: auto; } + +table { + border-collapse: collapse; width: 100%; + background: var(--surface); border-radius: var(--radius); overflow: hidden; +} +th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); } +th { background: var(--surface-alt); font-weight: 600; color: var(--fg-muted); text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--surface-alt); } + +/* Severity badges used across dashboard + findings table */ +.sev { + display: inline-block; padding: 0.1em 0.5em; border-radius: 4px; + font-size: 0.8rem; font-weight: 600; letter-spacing: 0.03em; + text-transform: uppercase; +} +.sev-critical { background: var(--crit); color: #fff; } +.sev-high { background: var(--high); color: #fff; } +.sev-medium { background: var(--med); color: #1f1f1f; } +.sev-low { background: var(--low); color: #fff; } +.sev-info { background: var(--info); color: #fff; } +.sev-unknown { background: var(--border); color: var(--fg); } + +/* Summary cards — the top-of-dashboard at-a-glance tiles */ +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: var(--gap); margin: 1rem 0; } +.card { + background: var(--surface); padding: 1rem; border-radius: var(--radius); + border-left: 3px solid var(--border); +} +.card .label { color: var(--fg-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +.card .value { font-size: 1.75rem; font-weight: 700; margin-top: 0.25rem; } +.card.critical { border-left-color: var(--crit); } +.card.high { border-left-color: var(--high); } +.card.medium { border-left-color: var(--med); } +.card.low { border-left-color: var(--low); } +.card.info { border-left-color: var(--info); } + +/* Warning banner for scan-quality issues (SPDX-2.1 detected, low purl + * coverage, grype unknown-subject). Distinct from findings severity — + * these are meta-issues about the scan's trustworthiness. */ +.warnings { + background: var(--surface); border-left: 3px solid var(--high); + padding: 0.75rem 1rem; border-radius: var(--radius); + margin: 1rem 0; +} +.warnings h3 { color: var(--high); margin-top: 0; } +.warnings ul { margin: 0.25rem 0 0; padding-left: 1.25rem; } + +/* Empty / placeholder state — shown when no scan is loaded yet. */ +.placeholder { + background: var(--surface); padding: 2rem; border-radius: var(--radius); + text-align: center; color: var(--fg-muted); +} +.placeholder h2 { margin-top: 0; color: var(--fg); } + +footer { + border-top: 1px solid var(--border); margin-top: 3rem; + color: var(--fg-muted); font-size: 0.85rem; text-align: center; +} + +/* Per-product / per-scanner lists — minor decoration so the dashboard + * doesn't read as one long wall of numbers. */ +.product-row, .scanner-row { + display: flex; align-items: baseline; justify-content: space-between; + padding: 0.5rem 0; border-bottom: 1px solid var(--border); +} +.product-row:last-child, .scanner-row:last-child { border-bottom: none; } +.product-row .name, .scanner-row .name { font-weight: 500; } +.product-row .counts, .scanner-row .counts { color: var(--fg-muted); font-size: 0.9rem; } +.product-row .top { margin-top: 0.5rem; margin-left: 1rem; } +.product-row .top li { list-style: none; margin: 0.25rem 0; font-size: 0.9rem; color: var(--fg-muted); } +.product-row .top li .sev { margin-right: 0.5rem; font-size: 0.7rem; } diff --git a/argus/serve/templates/base.html.j2 b/argus/serve/templates/base.html.j2 new file mode 100644 index 00000000..22c41f58 --- /dev/null +++ b/argus/serve/templates/base.html.j2 @@ -0,0 +1,49 @@ +{#- + Base layout for every argus serve page. + Intentionally minimal: header with nav + scan context, main content + block, footer. Templates override only ``title`` and ``content``. + CSP header is set by the FastAPI response; we add a meta tag as + defense-in-depth for when users open a saved page offline. +-#} + + + + + + + {% block title %}Argus{% endblock %} + + + +
+
+

🛡 argus

+
+ +
+ +
+ {% if scan_label %} +

+ Scan: {{ scan_label }} +

+ {% endif %} + {% block content %}{% endblock %} +
+ + + + diff --git a/argus/serve/templates/summary.html.j2 b/argus/serve/templates/summary.html.j2 new file mode 100644 index 00000000..1d6255ff --- /dev/null +++ b/argus/serve/templates/summary.html.j2 @@ -0,0 +1,114 @@ +{% extends "base.html.j2" %} +{% block title %}Argus — Executive Summary{% endblock %} +{% block content %} + +{#- Empty state: no scan is in scope yet. The picker (SD) will + replace this with something navigable; for now, tell the user + what's up so the page isn't just blank. -#} +{% if summary is none %} +
+

No scan loaded

+

Point argus serve at a results directory or a specific + argus-results.json, or pass ?scan=/path/... + to this URL.

+ {% if error %} +

Error: {{ error }}

+ {% endif %} +
+ +{% else %} + +

Executive Summary

+ + {#- At-a-glance severity cards. Card color encoded via class so + severity-neutral CSS rules ("card") set layout and the + severity-specific modifier sets the border accent. -#} +
+
+
Total findings
+
{{ summary.total }}
+
+ {% for sev, icon in [ + ("critical", "🚨"), + ("high", "⚠️"), + ("medium", "🟡"), + ("low", "🔵"), + ("info", "ℹ️") + ] + %} + {% set count = summary.by_severity.get(sev, 0) %} + {% if count %} +
+
{{ icon }} {{ sev.capitalize() }}
+
{{ count }}
+
+ {% endif %} + {% endfor %} +
+ + {#- Quality warnings — SPDX-2.1 detected, low-purl-coverage, + grype unknown-subject. Loud banner so empty scans aren't + mistaken for clean ones. -#} + {% if summary.quality_warnings %} +
+

Scan quality warnings

+
    + {% for w in summary.quality_warnings %} +
  • {{ w }}
  • + {% endfor %} +
+
+ {% endif %} + + {#- Per-product: total + crit + high counts + top-3 findings. -#} + {% if summary.per_product %} +
+

Per product

+ {% for p in summary.per_product %} +
+
+
{{ p.product }}
+
    + {% for f in p.top %} +
  • + {{ f.severity }} + {{ f.id }} + {% if f.metadata and f.metadata.package %} + {{ f.metadata.package }}@{{ f.metadata.installed_version or '?' }} + {% endif %} + — {{ f.title }} +
  • + {% endfor %} + {% if not p.top %} +
  • No findings
  • + {% endif %} +
+
+
+ total {{ p.total }} + · {{ p.by_severity.critical or 0 }} + · {{ p.by_severity.high or 0 }} +
+
+ {% endfor %} +
+ {% endif %} + + {#- Per-scanner contribution — useful for spotting when one + scanner is providing 90% of findings (suggests a config issue + or that the others produced nothing because of input format). -#} + {% if summary.per_scanner %} +
+

Per scanner

+ {% for s in summary.per_scanner %} +
+ {{ s.scanner }} + {{ s.total }} finding{{ s.total != 1 and 's' or '' }} +
+ {% endfor %} +
+ {% endif %} + +{% endif %} + +{% endblock %} diff --git a/argus/tests/serve/test_dashboard.py b/argus/tests/serve/test_dashboard.py new file mode 100644 index 00000000..93ba2225 --- /dev/null +++ b/argus/tests/serve/test_dashboard.py @@ -0,0 +1,178 @@ +"""Phase SB tests — executive-summary dashboard route. + +Uses FastAPI's TestClient so we can exercise the full Jinja render +pipeline against in-memory app instances. The templates, CSS, and +static mount are all read from the packaged assets, so these tests +also guard against template/path drift. +""" + +from __future__ import annotations + +import json + +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient # noqa: E402 — pytest importorskip above + +from argus.serve.app import _resolve_scan, create_app # noqa: E402 + + +def _write_results(dir_path, payload): + """Drop a valid argus-results.json inside ``dir_path``.""" + p = dir_path / "argus-results.json" + p.write_text(json.dumps(payload)) + return p + + +def _sample_payload(): + """Minimal but structurally-valid results payload for rendering.""" + return { + "severity_threshold": None, + "results": [ + { + "scanner": "grype", + "findings": [ + { + "id": "CVE-2021-44228", + "severity": "critical", + "title": "log4j RCE", + "description": "Remote code execution via JNDI", + "location": "log4j-core@2.14.1", + "cwe": None, + "cve": "CVE-2021-44228", + "scanner": "grype", + "metadata": { + "package": "log4j-core", + "installed_version": "2.14.1", + "fixed_version": "2.17.1", + "sbom_source": "BVMS.spdx", + }, + }, + ], + "raw_report": None, + "sarif_report": None, + "metadata": {}, + "critical_count": 1, + "high_count": 0, + "medium_count": 0, + "low_count": 0, + "total_count": 1, + }, + ], + } + + +class TestResolveScan: + def test_file_path_used_as_is(self, tmp_path): + f = _write_results(tmp_path, _sample_payload()) + result, err = _resolve_scan(str(f), launch_root=tmp_path) + assert err is None + assert result == f.resolve() + + def test_directory_path_finds_json_inside(self, tmp_path): + _write_results(tmp_path, _sample_payload()) + result, err = _resolve_scan(str(tmp_path), launch_root=tmp_path) + assert err is None + assert result.name == "argus-results.json" + + def test_directory_without_results_file_gives_actionable_error(self, tmp_path): + result, err = _resolve_scan(str(tmp_path), launch_root=tmp_path) + assert result is None + # Error message should name the expected filename AND the dir + # so the user knows exactly what's missing. + assert "argus-results.json" in err + assert str(tmp_path) in err + + def test_missing_path_returns_friendly_error(self, tmp_path): + result, err = _resolve_scan(str(tmp_path / "nope"), launch_root=tmp_path) + assert result is None + assert "does not exist" in err + + def test_fallback_to_launch_root_when_no_query_param(self, tmp_path): + _write_results(tmp_path, _sample_payload()) + result, err = _resolve_scan(None, launch_root=tmp_path) + assert err is None + assert result.parent == tmp_path.resolve() + + +class TestDashboardRoute: + def test_empty_state_when_root_has_no_results(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert resp.status_code == 200 + assert "No scan loaded" in resp.text + # Error message from _resolve_scan makes it into the placeholder + assert "argus-results.json" in resp.text + + def test_renders_summary_when_root_has_results(self, tmp_path): + _write_results(tmp_path, _sample_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert resp.status_code == 200 + # Dashboard content we expect from the fixture + assert "Executive Summary" in resp.text + assert "CVE-2021-44228" in resp.text + assert "log4j-core" in resp.text + assert "BVMS.spdx" in resp.text + # Severity card label shows up + assert "Critical" in resp.text + # Scan crumb shows the resolved path + assert "argus-results.json" in resp.text + + def test_scan_query_param_overrides_launch_root(self, tmp_path): + # Launch at an empty dir; point ?scan= at a populated sibling. + empty = tmp_path / "empty" + empty.mkdir() + populated = tmp_path / "run-a" + populated.mkdir() + _write_results(populated, _sample_payload()) + + app = create_app(root=str(empty)) + client = TestClient(app) + resp = client.get(f"/?scan={populated}") + assert resp.status_code == 200 + assert "CVE-2021-44228" in resp.text + + def test_malformed_results_json_shows_error_not_500(self, tmp_path): + (tmp_path / "argus-results.json").write_text("not json {") + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + # Template renders the error placeholder rather than crashing. + assert resp.status_code == 200 + assert "No scan loaded" in resp.text + + def test_csp_header_on_every_response(self, tmp_path): + _write_results(tmp_path, _sample_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + for path in ("/", "/healthz"): + resp = client.get(path) + assert "Content-Security-Policy" in resp.headers + assert "default-src 'self'" in resp.headers["Content-Security-Policy"] + assert resp.headers["X-Frame-Options"] == "DENY" + + def test_static_css_served(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/static/argus.css") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/css") + # Presence of a known class from our stylesheet — catches + # accidental path breakage between phases. + assert ".sev-critical" in resp.text + + +class TestDashboardAccessibility: + def test_no_scan_loaded_has_actionable_hint(self, tmp_path): + """Empty state must tell the user what to do, not just 'no scan'.""" + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + body = resp.text.lower() + # Mention of both ways to point at a scan — dir or file. + assert "results directory" in body or "results_json" in body or "argus-results.json" in body From e3307f6e7b449a1275092801da02134144454514 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 12:59:33 -0400 Subject: [PATCH 03/21] feat(serve): SC findings table route with query-param filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ``/findings`` — the table view that complements the executive dashboard. Every filter is a query param so the URL stays bookmarkable (paste into Slack, link from a ticket) and the page is refresh-safe without server-side session state. Routes & behavior: - ``GET /findings[?scan=...&min_severity=...&product=...&scanner=...&q=...]`` renders the shared ViewState-filtered finding list. - Filters degrade gracefully on bogus input — an unknown min_severity value falls back to no-filter rather than 500ing. URL-supplied params are untrusted input. - Empty-state shown when no scan in scope (same shape as the dashboard's); no-match state when filters exclude everything suggests widening the filter rather than just "0 results." Template (argus/serve/templates/findings.html.j2): - Plain GET form for the filter bar — all controls serialize as query params. No JavaScript yet; HTMX interactivity lands in SE and drops in under the same URL shape (form action already points at /findings so the markup won't need restructuring). - Table columns mirror the TUI's detail pane: severity badge, CVE/ID, package@version, fix, location, scanner, SBOM source, title. Pipe-escape isn't needed here (Jinja auto-escapes). - Severity badges styled via the existing argus.css .sev-* rules. Navigation: - base.html.j2 nav: Findings link now active-tracking (enabled instead of disabled placeholder). Switch-scan still stubbed until SD. Code reuse: - New ``_load_scan(scan)`` helper on the app dedupes the resolve+ load+error-translate logic between /, /findings, and upcoming routes. Pure refactor — / behavior unchanged. - Filter semantics come from ``ViewState.matches()`` in argus.core.findings_view — identical matching logic across TUI and web UI. Product/scanner dropdowns populated from unique_products() / unique_scanners() on the loaded summary. Tests (argus/tests/serve/test_findings.py, 10 cases): - empty state when no scan - default render shows every finding - each filter (severity, product, scanner, query) in isolation - filters combine with AND semantics - unknown severity input degrades to "no filter" - no-match empty state - dashboard nav now links to /findings --- argus/serve/app.py | 118 +++++++++++++--- argus/serve/templates/base.html.j2 | 6 +- argus/serve/templates/findings.html.j2 | 118 ++++++++++++++++ argus/tests/serve/test_findings.py | 182 +++++++++++++++++++++++++ 4 files changed, 401 insertions(+), 23 deletions(-) create mode 100644 argus/serve/templates/findings.html.j2 create mode 100644 argus/tests/serve/test_findings.py diff --git a/argus/serve/app.py b/argus/serve/app.py index 8567c391..6e4d3779 100644 --- a/argus/serve/app.py +++ b/argus/serve/app.py @@ -22,8 +22,14 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from argus.browse.loader import RESULTS_FILENAME, load_summary -from argus.core.findings_view import compute_summary +from argus.browse.loader import RESULTS_FILENAME, flatten_findings, load_summary +from argus.core.findings_view import ( + ViewState, + compute_summary, + unique_products, + unique_scanners, +) +from argus.core.models import Severity logger = logging.getLogger("argus.serve") @@ -116,6 +122,22 @@ async def healthz() -> JSONResponse: "root": str(app.state.root), }) + def _load_scan(scan: str | None) -> tuple[object, Path | None, str | None]: + """Shared scan-loading helper used by every view route. + + Returns ``(scan_summary, resolved_path, error_message)``. Exactly + one of summary and error_message is populated — callers use that + to decide between rendering data and the empty-state placeholder. + """ + results_path, error = _resolve_scan(scan, launch_root=app.state.root) + if error is not None: + return None, None, error + try: + scan_summary, resolved = load_summary(results_path) + return scan_summary, resolved, None + except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: + return None, None, str(exc) + @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request, scan: str | None = None) -> Response: """Executive-summary dashboard for the active scan context. @@ -124,30 +146,86 @@ async def dashboard(request: Request, scan: str | None = None) -> Response: bookmarkable and lets the future picker hand off a chosen scan by pointing back at ``/?scan=...``. """ - results_path, error = _resolve_scan(scan, launch_root=app.state.root) + scan_summary, resolved, error = _load_scan(scan) + summary = None + if scan_summary is not None: + summary = compute_summary(flatten_findings(scan_summary), top_n=3) + return templates.TemplateResponse( + request=request, + name="summary.html.j2", + context={ + "scan_param": scan, + "scan_label": str(resolved) if resolved else None, + "summary": summary, + "error": error, + }, + ) + + @app.get("/findings", response_class=HTMLResponse) + async def findings( + request: Request, + scan: str | None = None, + min_severity: str | None = None, + product: str | None = None, + scanner: str | None = None, + q: str | None = None, + ) -> Response: + """Filterable findings table. + + Every filter is a query param so the URL is bookmarkable and + the page stays refresh-safe. Filtering happens through the + shared ``ViewState`` so the server's idea of "match" and the + TUI's are identical — one source of truth for severity / query + / product / scanner semantics. + """ + scan_summary, resolved, error = _load_scan(scan) context = { "scan_param": scan, - "scan_label": None, - "summary": None, + "scan_label": str(resolved) if resolved else None, + "summary": scan_summary, "error": error, + "view": { + "min_severity": min_severity, + "product": product, + "scanner": scanner, + "query": q, + }, + "products": [], + "scanners": [], + "visible": [], + "total": 0, } - if results_path is not None: - try: - scan_summary, resolved = load_summary(results_path) - context["scan_label"] = str(resolved) - context["summary"] = compute_summary( - [f for r in scan_summary.results for f in r.findings], - top_n=3, - ) - except (FileNotFoundError, ValueError, json.JSONDecodeError) as exc: - context["error"] = str(exc) - # Starlette ≥0.32 uses the ``(request, name, context)`` signature; - # keyword form is the forward-compatible style that works across - # versions and catches regressions at import time rather than at - # template-render time. + if scan_summary is not None: + all_findings = flatten_findings(scan_summary) + context["total"] = len(all_findings) + context["products"] = unique_products(all_findings) + context["scanners"] = unique_scanners(all_findings) + + # Translate the ``min_severity`` string to the enum used by + # ViewState. An unknown value falls back to None (no filter) + # rather than 500-ing — user-supplied URLs are untrusted. + min_sev_enum = None + if min_severity: + try: + min_sev_enum = Severity.from_string(min_severity) + if min_sev_enum == Severity.UNKNOWN and min_severity.lower() != "unknown": + min_sev_enum = None + except (KeyError, ValueError): + min_sev_enum = None + + view_state = ViewState( + min_severity=min_sev_enum, + query=q or "", + product=product or None, + scanner=scanner or None, + ) + context["visible"] = [ + f for f in all_findings if view_state.matches(f) + ] + return templates.TemplateResponse( request=request, - name="summary.html.j2", + name="findings.html.j2", context=context, ) diff --git a/argus/serve/templates/base.html.j2 b/argus/serve/templates/base.html.j2 index 22c41f58..9a10a5ac 100644 --- a/argus/serve/templates/base.html.j2 +++ b/argus/serve/templates/base.html.j2 @@ -24,9 +24,9 @@ {%- set current = request.url.path -%} Dashboard - {#- Findings + picker links land in SC/SD. Stubs here so the - nav doesn't rewrite itself across phases. -#} - Findings + Findings + {#- Picker link lands in SD. -#} Switch scan diff --git a/argus/serve/templates/findings.html.j2 b/argus/serve/templates/findings.html.j2 new file mode 100644 index 00000000..6af1525a --- /dev/null +++ b/argus/serve/templates/findings.html.j2 @@ -0,0 +1,118 @@ +{% extends "base.html.j2" %} +{% block title %}Argus — Findings{% endblock %} +{% block content %} + +{% if summary is none %} +
+

No scan loaded

+ {% if error %}

Error: {{ error }}

{% endif %} +
+ +{% else %} + +

Findings

+ + {#- Filter bar. Every control is a plain GET form so the page stays + bookmarkable and search-engine friendly (and so SE can swap in + HTMX without rewriting the markup — same URL shape). -#} +
+ {%- if scan_param -%} + + {%- endif -%} + + + + {% if products %} + + {% endif %} + + {% if scanners %} + + {% endif %} + + + + + Reset +
+ +

+ Showing {{ visible|length }} of {{ total }} findings. +

+ + {% if visible %} + + + + + + + + + + + + + + + {% for f in visible %} + + + + + + + + + + + {% endfor %} + +
SeverityIDPackageFixLocationScannerSource SBOMTitle
{{ f.severity.value }} + {% if f.cve %}{{ f.cve }}{% else %}{{ f.id }}{% endif %} + + {% if f.metadata.package %} + {{ f.metadata.package }}@{{ f.metadata.installed_version or '?' }} + {% else %} + — + {% endif %} + {{ f.metadata.fixed_version or '—' }}{{ f.location or '—' }}{{ f.scanner or '—' }}{{ f.metadata.sbom_source or '—' }}{{ f.title }}
+ {% else %} +
+

No findings match the current filter. Try widening the severity + threshold or clearing the product / scanner selection.

+
+ {% endif %} + +{% endif %} + +{% endblock %} diff --git a/argus/tests/serve/test_findings.py b/argus/tests/serve/test_findings.py new file mode 100644 index 00000000..c8a85a5a --- /dev/null +++ b/argus/tests/serve/test_findings.py @@ -0,0 +1,182 @@ +"""Phase SC tests — findings table route. + +Covers query-param-driven filter behavior, ViewState sharing with the +TUI, and the empty-filter / no-match edge cases. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient # noqa: E402 + +from argus.serve.app import create_app # noqa: E402 + + +def _write_results(dir_path: Path, payload: dict) -> Path: + p = dir_path / "argus-results.json" + p.write_text(json.dumps(payload)) + return p + + +def _multi_finding_payload(): + """Two scanners, two products, mixed severities — enough to test every filter.""" + return { + "severity_threshold": None, + "results": [ + { + "scanner": "grype", + "findings": [ + { + "id": "CVE-A", "severity": "critical", "title": "log4j RCE", + "description": "", "location": "log4j-core@2.14.1", + "cwe": None, "cve": "CVE-2021-44228", "scanner": "grype", + "metadata": { + "package": "log4j-core", + "installed_version": "2.14.1", + "fixed_version": "2.17.1", + "sbom_source": "BVMS.spdx", + }, + }, + { + "id": "CVE-B", "severity": "medium", "title": "zlib overflow", + "description": "", "location": "zlib@1.2.12", + "cwe": None, "cve": "CVE-2023-45853", "scanner": "grype", + "metadata": { + "package": "zlib", + "installed_version": "1.2.12", + "sbom_source": "VRM.spdx", + }, + }, + ], + "raw_report": None, "sarif_report": None, "metadata": {}, + "critical_count": 1, "high_count": 0, "medium_count": 1, + "low_count": 0, "total_count": 2, + }, + { + "scanner": "trivy", + "findings": [ + { + "id": "CVE-C", "severity": "high", "title": "openssl issue", + "description": "", "location": "openssl@1.1.1", + "cwe": None, "cve": "CVE-2023-12345", "scanner": "trivy", + "metadata": { + "package": "openssl", + "installed_version": "1.1.1", + "sbom_source": "BVMS.spdx", + }, + }, + ], + "raw_report": None, "sarif_report": None, "metadata": {}, + "critical_count": 0, "high_count": 1, "medium_count": 0, + "low_count": 0, "total_count": 1, + }, + ], + } + + +class TestFindingsRoute: + def test_empty_state_when_no_scan(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings") + assert resp.status_code == 200 + assert "No scan loaded" in resp.text + + def test_renders_table_with_all_findings_by_default(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings") + assert resp.status_code == 200 + # All three findings from the fixture are present. + for cve in ("CVE-2021-44228", "CVE-2023-45853", "CVE-2023-12345"): + assert cve in resp.text + assert "Showing 3 of 3" in resp.text + + def test_severity_filter_trims_to_high_and_above(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings?min_severity=high") + assert resp.status_code == 200 + assert "CVE-2021-44228" in resp.text # critical + assert "CVE-2023-12345" in resp.text # high + # medium-severity zlib finding should be filtered out + assert "CVE-2023-45853" not in resp.text + + def test_product_filter_uses_sbom_source(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings?product=BVMS.spdx") + assert resp.status_code == 200 + # BVMS has log4j (critical) and openssl (high) + assert "CVE-2021-44228" in resp.text + assert "CVE-2023-12345" in resp.text + # VRM's zlib should not appear + assert "CVE-2023-45853" not in resp.text + + def test_scanner_filter(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings?scanner=trivy") + assert resp.status_code == 200 + assert "CVE-2023-12345" in resp.text # trivy finding + # grype-only findings excluded + assert "CVE-2021-44228" not in resp.text + assert "CVE-2023-45853" not in resp.text + + def test_query_filter_matches_cve(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings?q=log4j") + assert resp.status_code == 200 + assert "CVE-2021-44228" in resp.text + assert "CVE-2023-45853" not in resp.text + + def test_filters_combine_with_AND_semantics(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + # CRITICAL + BVMS product — only log4j should survive + resp = client.get("/findings?min_severity=critical&product=BVMS.spdx") + assert resp.status_code == 200 + assert "CVE-2021-44228" in resp.text + assert "CVE-2023-12345" not in resp.text # high, not critical + + def test_unknown_min_severity_falls_back_to_all(self, tmp_path): + """URL-driven input must never crash the route — bogus values + degrade to 'no filter' rather than a 500.""" + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings?min_severity=bogus") + assert resp.status_code == 200 + # All three findings still present (filter was ignored) + for cve in ("CVE-2021-44228", "CVE-2023-45853", "CVE-2023-12345"): + assert cve in resp.text + + def test_no_match_shows_actionable_empty_state(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings?q=zzznothingthere") + assert resp.status_code == 200 + assert "No findings match the current filter" in resp.text + assert "Showing 0 of 3" in resp.text + + def test_findings_nav_link_on_dashboard(self, tmp_path): + _write_results(tmp_path, _multi_finding_payload()) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + # Nav now points at /findings instead of being disabled + assert "/findings" in resp.text From 98e2f10d335f50118195ae30bfbc3a324af3c851 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 13:01:59 -0400 Subject: [PATCH 04/21] =?UTF-8?q?feat(serve):=20SD=20picker=20=E2=80=94=20?= =?UTF-8?q?one-level=20file=20browser=20with=20scan-ready=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ``/picker`` — a lightweight file browser the user navigates to switch between scans. Per SD scoping decision: no recursion. Each directory level is shown as-is; users click into subdirs themselves to find the scan they want. What makes it useful beyond a bare ``ls``: - Each subdirectory row carries a ``has_results`` flag. When a subdirectory contains ``argus-results.json`` directly, the picker advertises it as scan-ready with a "Load scan" button and a cheap finding-count peek (one open+json.load to answer "did this run find anything worth opening?"). Users can scan a parent directory of dated runs and visually pick which one mattered. - Dotfiles and well-known build dirs (node_modules, .git, .venv, __pycache__, .tox, .pytest_cache, .mypy_cache) hidden by default; ``?show_hidden=1`` surfaces them when users actually need to dig. - "Jump to path" text input for users who know exactly where their scans live and don't want to navigate. - Parent-directory link (``..``) when not at the filesystem root. Error handling: - Non-existent or non-directory ``?path=...`` renders the picker with a friendly error banner — no 500s on typo'd URLs. - ``PermissionError`` from ``iterdir()`` is caught and surfaced as a user-readable message instead of a traceback. - Malformed ``argus-results.json`` in a scan-ready subdir reduces to ``finding_count=None`` rather than breaking the whole listing. Hand-off: - Clicking "Load scan" on a directory row lands on ``/?scan=`` — the dashboard's ``?scan=`` handling resolves the ``argus-results.json`` inside. Same URL shape SC uses so everything stays bookmarkable. - base.html.j2 nav: Switch-scan link now active (was stubbed through SA/SB/SC). Implementation: - ``_list_directory(base, show_hidden)`` in app.py returns a list of entry dicts plus an error. Directories sort before files, alphabetical within each group. Pure function, unit-tested independently of the route. - Picker doesn't consume a scan context itself — it sets ``scan_param`` / ``scan_label`` to None in its template context so the base template's scan breadcrumb clears while users are actively switching. Tests (argus/tests/serve/test_picker.py, 14 cases): _list_directory (7): hidden filtering, show_hidden override, directories-first ordering, has_results detection, is_results_file flag, malformed JSON doesn't break listing, PermissionError surfaces cleanly. /picker route (7): lists subdirs, flags scan-ready dirs with finding count, "Load this scan" button when current dir has results, non-directory path shows error placeholder, missing path error, parent link rendered, ``?show_hidden`` flag surfaces dotfiles, nav link active-tracking works. --- argus/serve/app.py | 158 ++++++++++++++++++++++ argus/serve/templates/base.html.j2 | 4 +- argus/serve/templates/picker.html.j2 | 107 +++++++++++++++ argus/tests/serve/test_picker.py | 190 +++++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 argus/serve/templates/picker.html.j2 create mode 100644 argus/tests/serve/test_picker.py diff --git a/argus/serve/app.py b/argus/serve/app.py index 6e4d3779..ce2e15c0 100644 --- a/argus/serve/app.py +++ b/argus/serve/app.py @@ -229,9 +229,167 @@ async def findings( context=context, ) + @app.get("/picker", response_class=HTMLResponse) + async def picker( + request: Request, + path: str | None = None, + show_hidden: int = 0, + ) -> Response: + """Lightweight file-browser picker. + + One directory level at a time — explicitly not recursive, per + the SD scoping decision. Users navigate by clicking into + subdirs; each listed entry is flagged scan-ready when it + contains an ``argus-results.json`` directly inside it, so + nested results show up as one-click targets without us doing + a full filesystem walk. + """ + base = Path(path).expanduser() if path else app.state.root + try: + base = base.resolve() + except OSError as exc: + return templates.TemplateResponse( + request=request, + name="picker.html.j2", + context={ + "current": str(base), + "parent": None, + "entries": [], + "error": f"Cannot resolve path: {exc}", + "has_results": False, + "show_hidden": bool(show_hidden), + "scan_param": None, + "scan_label": None, + }, + ) + + if not base.exists() or not base.is_dir(): + return templates.TemplateResponse( + request=request, + name="picker.html.j2", + context={ + "current": str(base), + "parent": None, + "entries": [], + "error": ( + f"{base} is not a directory. " + "Pick a folder; individual JSON files can be loaded " + "via the dashboard URL (?scan=...)." + ), + "has_results": False, + "show_hidden": bool(show_hidden), + "scan_param": None, + "scan_label": None, + }, + ) + + entries, error = _list_directory(base, show_hidden=bool(show_hidden)) + has_results = (base / RESULTS_FILENAME).is_file() + + return templates.TemplateResponse( + request=request, + name="picker.html.j2", + context={ + "current": str(base), + "parent": str(base.parent) if base.parent != base else None, + "entries": entries, + "error": error, + "has_results": has_results, + "show_hidden": bool(show_hidden), + # Picker isn't scoped to a loaded scan — clear the header + # breadcrumb so users don't think they're still in scan + # context while they're actively switching away from it. + "scan_param": None, + "scan_label": None, + }, + ) + return app +# Common noise in argus workflows; hidden from the default picker listing +# but surfaced via ``?show_hidden=1`` when the user actually needs to dig. +_HIDDEN_BY_DEFAULT = { + "node_modules", ".git", ".venv", "venv", "__pycache__", + ".tox", ".pytest_cache", ".mypy_cache", +} + + +def _list_directory( + base: Path, + *, + show_hidden: bool, +) -> tuple[list[dict], str | None]: + """Return ``(entries, error)`` for picker consumption. + + Each entry dict carries: + - ``name`` : bare filename + - ``path`` : absolute path (string, ready for URL encoding) + - ``is_dir`` : True if it's a directory + - ``is_results_file`` : True if it's named argus-results.json + - ``has_results`` : True if it's a directory that contains + argus-results.json directly inside + - ``finding_count`` : total findings if has_results (cheap + read — one open+json.load) else None + + Directories first, then files, alphabetical within each group. + Readability trumps performance here: picker content is interactive + and users wait at the rendering boundary, so we do the small I/O + that makes the status column useful. + """ + import json as _json + try: + raw = sorted(base.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except PermissionError as exc: + return [], f"Permission denied: {exc}" + except OSError as exc: + return [], f"Could not list directory: {exc}" + + entries: list[dict] = [] + for item in raw: + name = item.name + # Filter rules: hide dotfiles and the well-known build/cache + # directories unless the user explicitly opted in. + if not show_hidden and ( + name.startswith(".") or name in _HIDDEN_BY_DEFAULT + ): + continue + + is_dir = item.is_dir() + is_results_file = not is_dir and name == RESULTS_FILENAME + + has_results = False + finding_count = None + if is_dir: + candidate = item / RESULTS_FILENAME + if candidate.is_file(): + has_results = True + # Peek at the finding count so the picker row can + # advertise scan size — users picking among dated + # scan dirs can see which one had activity worth + # looking at. Best-effort only; a parse failure + # reduces to "no count shown" rather than erroring. + try: + data = _json.loads(candidate.read_text(encoding="utf-8")) + finding_count = sum( + len(r.get("findings", [])) + for r in data.get("results", []) + ) + except (OSError, _json.JSONDecodeError, TypeError): + finding_count = None + + entries.append({ + "name": name, + "path": str(item), + "is_dir": is_dir, + "is_results_file": is_results_file, + "has_results": has_results, + "finding_count": finding_count, + }) + + return entries, None + + def run_app( *, root: str | None, diff --git a/argus/serve/templates/base.html.j2 b/argus/serve/templates/base.html.j2 index 9a10a5ac..c47975f6 100644 --- a/argus/serve/templates/base.html.j2 +++ b/argus/serve/templates/base.html.j2 @@ -26,8 +26,8 @@ {% if current == "/" %}aria-current="page"{% endif %}>Dashboard Findings - {#- Picker link lands in SD. -#} - Switch scan + Switch scan diff --git a/argus/serve/templates/picker.html.j2 b/argus/serve/templates/picker.html.j2 new file mode 100644 index 00000000..2aa30364 --- /dev/null +++ b/argus/serve/templates/picker.html.j2 @@ -0,0 +1,107 @@ +{% extends "base.html.j2" %} +{% block title %}Argus — Switch scan{% endblock %} +{% block content %} + +

Switch scan

+

+ Navigate into a folder that contains an argus-results.json and + click Load this scan. Nested results show as scan-ready + folders so you can pick a single run from a parent directory of scans. +

+ +

+ {{ current }} +

+ +{% if error %} +
+

Error

+

{{ error }}

+
+{% endif %} + +
+ {%- if has_results -%} + + ▶ Load this scan + + + argus-results.json detected here. + + {% else %} + No argus-results.json in this folder — click into a subfolder below, or type a path to jump. + {% endif -%} +
+ +
+ + +
+ + + + + + + + + + + {%- if parent -%} + + + + + + {%- endif -%} + + {%- for entry in entries -%} + + + + + + {%- endfor -%} + + {%- if not entries and not parent -%} + + {%- endif -%} + +
TypeNameStatus
📁..parent directory
{%- if entry.is_dir -%}📁{%- else -%}📄{%- endif -%} + {%- if entry.is_dir -%} + {{ entry.name }}/ + {%- else -%} + {{ entry.name }} + {%- endif -%} + + {%- if entry.has_results -%} + + ▶ Load scan ({{ entry.finding_count }} finding{{ entry.finding_count != 1 and 's' or '' }}) + + {%- elif entry.is_results_file -%} + + ▶ Load scan + + {%- else -%} + + {%- endif -%} +
Empty directory.
+ +

+ Dotfiles and common build dirs (node_modules, .git, + .venv) are hidden by default. + {%- if not show_hidden -%} + Show hidden + {%- else -%} + Hide hidden + {%- endif -%} +

+ +{% endblock %} diff --git a/argus/tests/serve/test_picker.py b/argus/tests/serve/test_picker.py new file mode 100644 index 00000000..7961541a --- /dev/null +++ b/argus/tests/serve/test_picker.py @@ -0,0 +1,190 @@ +"""Phase SD tests — picker navigation + scan-ready detection.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient # noqa: E402 + +from argus.serve.app import _list_directory, create_app # noqa: E402 + + +def _write_results(dir_path: Path, findings_count: int = 0) -> Path: + """Drop a results file with N findings for the status-column peek.""" + results = [{ + "scanner": "grype", + "findings": [ + { + "id": f"CVE-{i}", "severity": "high", "title": "x", + "description": "", "location": None, "cwe": None, + "cve": None, "scanner": "grype", "metadata": {}, + } + for i in range(findings_count) + ], + "raw_report": None, "sarif_report": None, "metadata": {}, + "critical_count": 0, "high_count": findings_count, + "medium_count": 0, "low_count": 0, + "total_count": findings_count, + }] + p = dir_path / "argus-results.json" + p.write_text(json.dumps({ + "severity_threshold": None, + "results": results, + })) + return p + + +class TestListDirectory: + def test_filters_hidden_by_default(self, tmp_path): + (tmp_path / "visible").mkdir() + (tmp_path / ".git").mkdir() + (tmp_path / "node_modules").mkdir() + (tmp_path / ".env").write_text("") + (tmp_path / "config.yml").write_text("") + + entries, err = _list_directory(tmp_path, show_hidden=False) + names = {e["name"] for e in entries} + assert names == {"visible", "config.yml"} + assert err is None + + def test_show_hidden_reveals_everything(self, tmp_path): + (tmp_path / "visible").mkdir() + (tmp_path / ".git").mkdir() + entries, _ = _list_directory(tmp_path, show_hidden=True) + names = {e["name"] for e in entries} + assert names == {"visible", ".git"} + + def test_directories_listed_before_files(self, tmp_path): + (tmp_path / "a-file.txt").write_text("") + (tmp_path / "b-dir").mkdir() + entries, _ = _list_directory(tmp_path, show_hidden=False) + # Directory `b-dir` comes before file `a-file.txt` despite + # alphabetical order — directories-first is the UX contract + # so the picker feels like a file browser. + assert entries[0]["name"] == "b-dir" + assert entries[1]["name"] == "a-file.txt" + + def test_has_results_flag_set_on_scan_dirs(self, tmp_path): + scan_dir = tmp_path / "run-01" + scan_dir.mkdir() + _write_results(scan_dir, findings_count=3) + + (tmp_path / "empty-dir").mkdir() + + entries, _ = _list_directory(tmp_path, show_hidden=False) + by_name = {e["name"]: e for e in entries} + assert by_name["run-01"]["has_results"] is True + assert by_name["run-01"]["finding_count"] == 3 + assert by_name["empty-dir"]["has_results"] is False + + def test_is_results_file_flag_for_direct_json(self, tmp_path): + _write_results(tmp_path, findings_count=1) + entries, _ = _list_directory(tmp_path, show_hidden=False) + by_name = {e["name"]: e for e in entries} + assert by_name["argus-results.json"]["is_results_file"] is True + + def test_malformed_results_doesnt_break_listing(self, tmp_path): + # A broken JSON in a subdir used to 500 the whole picker row; + # the finding_count peek is best-effort only. + scan = tmp_path / "broken-scan" + scan.mkdir() + (scan / "argus-results.json").write_text("{not json") + entries, _ = _list_directory(tmp_path, show_hidden=False) + by_name = {e["name"]: e for e in entries} + assert by_name["broken-scan"]["has_results"] is True + # Count is None because the file didn't parse — but the row + # still renders. + assert by_name["broken-scan"]["finding_count"] is None + + def test_permission_error_yields_empty_entries_plus_error(self, tmp_path, monkeypatch): + """iterdir() raising PermissionError should surface as a + user-readable error rather than a 500.""" + def _boom(self): + raise PermissionError("denied") + monkeypatch.setattr(Path, "iterdir", _boom) + entries, err = _list_directory(tmp_path, show_hidden=False) + assert entries == [] + assert err is not None + assert "Permission denied" in err + + +class TestPickerRoute: + def test_picker_lists_subdirs(self, tmp_path): + (tmp_path / "run-01").mkdir() + (tmp_path / "run-02").mkdir() + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get(f"/picker?path={tmp_path}") + assert resp.status_code == 200 + assert "run-01" in resp.text + assert "run-02" in resp.text + + def test_picker_flags_scan_ready_dirs(self, tmp_path): + scan = tmp_path / "run-01" + scan.mkdir() + _write_results(scan, findings_count=5) + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get(f"/picker?path={tmp_path}") + # "Load scan" affordance shown for scan-ready dirs + assert "Load scan" in resp.text + assert "5 findings" in resp.text + + def test_load_this_scan_button_when_current_dir_has_results(self, tmp_path): + _write_results(tmp_path, findings_count=2) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get(f"/picker?path={tmp_path}") + assert "Load this scan" in resp.text + + def test_non_directory_path_shows_error_not_500(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + # Point at a file, not a dir — route should render the error + # placeholder rather than 500. + results = _write_results(tmp_path, findings_count=1) + resp = client.get(f"/picker?path={results}") + assert resp.status_code == 200 + assert "is not a directory" in resp.text + + def test_missing_path_shows_error(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get(f"/picker?path={tmp_path}/does-not-exist") + assert resp.status_code == 200 + assert "is not a directory" in resp.text + + def test_parent_link_rendered_when_not_at_root(self, tmp_path): + sub = tmp_path / "sub" + sub.mkdir() + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get(f"/picker?path={sub}") + # ".." row lets the user navigate up one level + assert "parent directory" in resp.text + + def test_show_hidden_flag_surfaces_dotfiles(self, tmp_path): + (tmp_path / ".hidden").mkdir() + app = create_app(root=str(tmp_path)) + client = TestClient(app) + + resp = client.get(f"/picker?path={tmp_path}") + assert ".hidden" not in resp.text + + resp = client.get(f"/picker?path={tmp_path}&show_hidden=1") + assert ".hidden" in resp.text + + def test_switch_scan_nav_link_active(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/picker") + # Nav link for the picker is now live (not stubbed). + assert 'aria-current="page"' in resp.text + # ...and specifically on the Switch scan anchor. + assert "Switch scan" in resp.text From b99236948cdd7d8a1d358563ab9f90d231a08f3b Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 13:05:15 -0400 Subject: [PATCH 05/21] feat(serve): SE progressive-enhancement filter refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ``?partial=1`` branch on /findings that returns just the table fragment, paired with a tiny vanilla-JS auto-filter script that swaps it into the page as the user changes filters. No full page reload, no new JS framework dependency, bookmarkable URLs preserved via history.replaceState. Why vanilla JS instead of HTMX: - Scope is tiny: one form, one target, one endpoint. - ~80 LOC of vanilla beats pulling a ~15 KB library for a single-page interaction. - If we grow more interactive surfaces (live-reload when the results file changes, picker filter-as-you-type, etc.) we'll switch to HTMX then — the partial-endpoint shape is already HTMX-compatible. Progressive-enhancement shape: - The form submits as a plain GET without JS (the Apply button is the no-JS fallback). JS-live sessions get auto-filter on dropdown change and debounced (300 ms) search-input keystrokes. - Same ``?scan=...&min_severity=...&product=...&scanner=...&q=...`` URL shape on both paths. Sharing the URL with a colleague gives them the exact same view. - Same template partial (_findings_table.html.j2) powers both code paths so the row markup can't drift. Refactor was a pure split — findings.html.j2 now {% include %}s the partial instead of duplicating the table. Files: - argus/serve/static/auto-filter.js — 80-line vanilla script with inline trust-boundary docstring explaining why innerHTML is safe here (Jinja autoescape + CSP 'script-src self'). - argus/serve/templates/_findings_table.html.j2 — extracted partial (new file). - argus/serve/templates/findings.html.j2 — now includes the partial + adds data-auto-filter attribute, #findings-target swap div, and the script tag. - argus/serve/app.py — /findings gains ``?partial=1`` flag that switches the rendered template. Tests (argus/tests/serve/test_auto_filter.py, 5 cases): - partial response has the table fragment but no / @@ -51,5 +85,12 @@ · localhost-only · healthz + + {#- Theme-toggle JS loaded at end-of-body with defer so the header + renders without a flash. When localStorage already has a saved + preference the script applies it here; otherwise the @media + prefers-color-scheme rule in argus.css has already done the + right thing. -#} + diff --git a/argus/serve/templates/summary.html.j2 b/argus/serve/templates/summary.html.j2 index 06547a7a..abf4dff5 100644 --- a/argus/serve/templates/summary.html.j2 +++ b/argus/serve/templates/summary.html.j2 @@ -72,6 +72,88 @@ {% endif %} + {#- Scan metadata panel — when this scan ran, how long it took, and + which scanner versions contributed. Collapsed by default so it + doesn't crowd the executive summary above but available one + click away for "what commit was this against / how long did it + take?" questions that come up in audit reviews. -#} + {% if metadata %} + + {#- Tiny script to turn the epoch mtime into a human-readable + local time. Doesn't run on SSR (mtime renders as a raw int + first) so pages stay readable under strict CSP / no-JS. -#} + + {% endif %} + {#- Per-product: total + crit + high counts + top-3 findings. Each row anchors to /findings pre-filtered to the product so users drill straight into the backlog for that product. When there diff --git a/argus/tests/serve/test_recent_scans.py b/argus/tests/serve/test_recent_scans.py new file mode 100644 index 00000000..d12585ec --- /dev/null +++ b/argus/tests/serve/test_recent_scans.py @@ -0,0 +1,227 @@ +"""Tests for the recent-scans header dropdown + its collector helper.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient # noqa: E402 + +from argus.serve.app import _collect_recent_scans, create_app # noqa: E402 + + +def _write_results(dir_path: Path, count: int = 1) -> Path: + """Drop a valid argus-results.json with ``count`` dummy findings.""" + findings = [{ + "id": f"CVE-{i}", "severity": "low", "title": "x", + "description": "", "location": f"pkg-{i}", + "cwe": None, "cve": None, "scanner": "grype", + "metadata": {}, + } for i in range(count)] + payload = { + "severity_threshold": None, + "results": [{ + "scanner": "grype", + "findings": findings, + "raw_report": None, "sarif_report": None, "metadata": {}, + "critical_count": 0, "high_count": 0, + "medium_count": 0, "low_count": count, "total_count": count, + }], + } + p = dir_path / "argus-results.json" + p.write_text(json.dumps(payload)) + return p + + +class TestCollectRecentScans: + def test_lists_subdirs_when_root_is_parent(self, tmp_path): + # launch_root is a parent-of-runs; each subdir is a separate scan. + (tmp_path / "run-1").mkdir() + (tmp_path / "run-2").mkdir() + _write_results(tmp_path / "run-1", count=3) + _write_results(tmp_path / "run-2", count=7) + + scans = _collect_recent_scans(tmp_path) + labels = {s["label"] for s in scans} + assert labels == {"run-1", "run-2"} + # Finding count peeked from the JSON. + counts = {s["label"]: s["count"] for s in scans} + assert counts == {"run-1": 3, "run-2": 7} + + def test_skips_non_scan_subdirs(self, tmp_path): + (tmp_path / "run-1").mkdir() + _write_results(tmp_path / "run-1", count=1) + # Empty folder — no argus-results.json — should not appear. + (tmp_path / "just-a-folder").mkdir() + + scans = _collect_recent_scans(tmp_path) + labels = [s["label"] for s in scans] + assert labels == ["run-1"] + + def test_looks_at_parent_when_launch_root_is_itself_a_scan(self, tmp_path): + # argus serve → launch_root contains argus-results.json. + # Recent-scans should show siblings in the parent, not treat the + # scan's own subdirs (if any) as runs. + run = tmp_path / "run-1" + run.mkdir() + sibling = tmp_path / "run-2" + sibling.mkdir() + _write_results(run, count=1) + _write_results(sibling, count=1) + + scans = _collect_recent_scans(run) + labels = {s["label"] for s in scans} + # Includes both run-1 (the launch root) and run-2 (sibling). + assert labels == {"run-1", "run-2"} + + def test_sorts_newest_first(self, tmp_path): + old = tmp_path / "older" + new = tmp_path / "newer" + old.mkdir() + new.mkdir() + _write_results(old) + time.sleep(0.02) + _write_results(new) + + scans = _collect_recent_scans(tmp_path) + assert [s["label"] for s in scans] == ["newer", "older"] + + def test_symlink_deduplicates_against_its_target(self, tmp_path): + # "latest" pointing at "run-1" is the argus scan convention; + # without dedup the dropdown would show both rows for the same + # real run. + run = tmp_path / "run-1" + run.mkdir() + _write_results(run) + (tmp_path / "latest").symlink_to(run, target_is_directory=True) + + scans = _collect_recent_scans(tmp_path) + # One entry only; the resolved path collapses both candidates. + assert len(scans) == 1 + + def test_current_flag_set_when_resolved_matches(self, tmp_path): + run1 = tmp_path / "run-1" + run2 = tmp_path / "run-2" + run1.mkdir() + run2.mkdir() + _write_results(run1) + _write_results(run2) + + results_file = run1 / "argus-results.json" + scans = _collect_recent_scans(tmp_path, current=results_file) + by_label = {s["label"]: s for s in scans} + assert by_label["run-1"]["is_current"] is True + assert by_label["run-2"]["is_current"] is False + + def test_empty_root_returns_empty_list(self, tmp_path): + assert _collect_recent_scans(tmp_path) == [] + + def test_malformed_scan_falls_back_to_zero_count(self, tmp_path): + run = tmp_path / "run-1" + run.mkdir() + (run / "argus-results.json").write_text("not json {") + + scans = _collect_recent_scans(tmp_path) + # Malformed → still surface the run but with count=0 so the user + # isn't silently hidden from a broken run. + assert len(scans) == 1 + assert scans[0]["count"] == 0 + + def test_limit_caps_list_size(self, tmp_path): + for i in range(20): + d = tmp_path / f"run-{i:02d}" + d.mkdir() + _write_results(d) + + scans = _collect_recent_scans(tmp_path, limit=5) + assert len(scans) == 5 + + +class TestRecentScansHeaderDropdown: + """Header renders the dropdown when there are 2+ scan-ready dirs.""" + + def test_dropdown_renders_when_multiple_scans_exist(self, tmp_path): + (tmp_path / "run-1").mkdir() + (tmp_path / "run-2").mkdir() + _write_results(tmp_path / "run-1") + _write_results(tmp_path / "run-2") + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert "recent-scans-menu" in resp.text + assert "Recent runs" in resp.text + assert "run-1" in resp.text + assert "run-2" in resp.text + + def test_dropdown_hidden_for_single_scan_deployments(self, tmp_path): + # Use a subdir as launch root so the parent (tmp_path) is + # guaranteed to have no sibling scans — pytest's session-wide + # tmp_path hierarchy can otherwise contain leftover scan + # fixtures from other tests and leak a false positive here. + root = tmp_path / "project" + root.mkdir() + _write_results(root) # only scan is launch_root itself + + app = create_app(root=str(root)) + client = TestClient(app) + resp = client.get("/") + # Exactly one entry → don't render the dropdown noise. + assert "recent-scans-menu" not in resp.text + + def test_current_scan_highlighted_in_dropdown(self, tmp_path): + run1 = tmp_path / "run-1" + run2 = tmp_path / "run-2" + run1.mkdir() + run2.mkdir() + _write_results(run1) + _write_results(run2) + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get(f"/?scan={run1}") + # The active scan picks up aria-current + the highlight class. + assert "recent-scans-current" in resp.text + assert 'aria-current="true"' in resp.text + + def test_dropdown_also_renders_on_findings_page(self, tmp_path): + (tmp_path / "run-1").mkdir() + (tmp_path / "run-2").mkdir() + _write_results(tmp_path / "run-1") + _write_results(tmp_path / "run-2") + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/findings") + assert "recent-scans-menu" in resp.text + + def test_dropdown_also_renders_on_picker_page(self, tmp_path): + (tmp_path / "run-1").mkdir() + (tmp_path / "run-2").mkdir() + _write_results(tmp_path / "run-1") + _write_results(tmp_path / "run-2") + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/picker") + assert "recent-scans-menu" in resp.text + + def test_dropdown_href_preserves_scan_switching(self, tmp_path): + run1 = tmp_path / "run-1" + run2 = tmp_path / "run-2" + run1.mkdir() + run2.mkdir() + _write_results(run1) + _write_results(run2) + + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + # Each row links to /?scan=; clicking switches runs. + assert f"scan={run1}".replace("/", "%2F") in resp.text or str(run1) in resp.text + assert f"scan={run2}".replace("/", "%2F") in resp.text or str(run2) in resp.text diff --git a/argus/tests/serve/test_scan_metadata.py b/argus/tests/serve/test_scan_metadata.py new file mode 100644 index 00000000..f01c08f7 --- /dev/null +++ b/argus/tests/serve/test_scan_metadata.py @@ -0,0 +1,167 @@ +"""Tests for the scan metadata panel on the dashboard. + +Exercises the _scan_metadata extractor plus the dashboard rendering +of scanner versions, durations, container execution, and image +digests. Metadata lives inside ScanResult.metadata today; the panel +surfaces what's there without failing on missing fields. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient # noqa: E402 + +from argus.serve.app import _scan_metadata, create_app # noqa: E402 + + +def _write_scan(dir_path: Path, scanner_blocks: list[dict]) -> Path: + """Drop a results JSON with explicit per-scanner metadata.""" + results = [] + for block in scanner_blocks: + results.append({ + "scanner": block["scanner"], + "findings": block.get("findings", []), + "raw_report": None, + "sarif_report": None, + "metadata": block.get("metadata", {}), + "critical_count": 0, "high_count": 0, + "medium_count": 0, "low_count": 0, "total_count": 0, + }) + payload = {"severity_threshold": None, "results": results} + p = dir_path / "argus-results.json" + p.write_text(json.dumps(payload)) + return p + + +class TestScanMetadataExtractor: + def test_none_summary_returns_none(self): + assert _scan_metadata(None, None) is None + + def test_collects_per_scanner_execution_fields(self, tmp_path): + # Fake the ScanSummary via the real loader so we test the + # extractor against actual models, not dict shims. + from argus.browse.loader import load_summary + + _write_scan(tmp_path, [{ + "scanner": "bandit", + "metadata": { + "execution": "container", + "image": "ghcr.io/huntridge-labs/argus/scanner-bandit:0.7.0", + "digest": "sha256:abc123", + "tool_version": "1.7.5", + "duration_ms": 250, + }, + }]) + scan_summary, resolved = load_summary(tmp_path / "argus-results.json") + + md = _scan_metadata(scan_summary, resolved) + assert md is not None + assert md["scanner_count"] == 1 + assert md["scanners"][0]["scanner"] == "bandit" + assert md["scanners"][0]["tool_version"] == "1.7.5" + assert md["scanners"][0]["execution"] == "container" + assert md["scanners"][0]["digest"] == "sha256:abc123" + assert md["scanners"][0]["duration_ms"] == 250 + assert md["total_duration_ms"] == 250 + + def test_sums_durations_across_scanners(self, tmp_path): + from argus.browse.loader import load_summary + _write_scan(tmp_path, [ + {"scanner": "bandit", "metadata": {"duration_ms": 100}}, + {"scanner": "grype", "metadata": {"duration_ms": 250}}, + {"scanner": "trivy", "metadata": {"duration_ms": 75}}, + ]) + scan_summary, resolved = load_summary(tmp_path / "argus-results.json") + md = _scan_metadata(scan_summary, resolved) + assert md["total_duration_ms"] == 425 + assert md["scanner_count"] == 3 + + def test_total_duration_none_when_no_scanner_reported(self, tmp_path): + # Older scans without duration_ms metadata — total should be + # None (not zero) so the template hides the "0s total" line + # rather than implying an instant scan. + from argus.browse.loader import load_summary + _write_scan(tmp_path, [{"scanner": "bandit", "metadata": {}}]) + scan_summary, resolved = load_summary(tmp_path / "argus-results.json") + md = _scan_metadata(scan_summary, resolved) + assert md["total_duration_ms"] is None + + def test_scan_file_and_mtime_captured(self, tmp_path): + from argus.browse.loader import load_summary + p = _write_scan(tmp_path, [{"scanner": "bandit", "metadata": {}}]) + scan_summary, resolved = load_summary(p) + md = _scan_metadata(scan_summary, resolved) + assert md["scan_file"] == str(resolved) + # Non-null mtime — browsers render via the small JS helper. + assert md["scan_mtime"] is not None + + +class TestScanMetadataUI: + def test_panel_renders_on_dashboard_when_scan_loaded(self, tmp_path): + _write_scan(tmp_path, [{ + "scanner": "bandit", + "metadata": { + "execution": "container", + "image": "img:1.0", + "digest": "sha256:abc", + "tool_version": "1.7", + "duration_ms": 120, + }, + }]) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert "scan-metadata" in resp.text + assert "Scan metadata" in resp.text + # Per-scanner row surfaces its tool version + duration. + assert "1.7" in resp.text + assert "120 ms" in resp.text + # Digest shown for container executions so audit questions + # have the SHA-pinned image handy. + assert "sha256:abc" in resp.text + + def test_panel_absent_for_empty_state(self, tmp_path): + # No scan loaded → no metadata panel. + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert "Scan metadata" not in resp.text + + def test_panel_survives_missing_duration(self, tmp_path): + # Older scans without duration_ms should still render rather + # than crashing the page. + _write_scan(tmp_path, [{"scanner": "bandit", "metadata": {"tool_version": "1.7"}}]) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert resp.status_code == 200 + assert "scan-metadata" in resp.text + # Em-dash for the missing duration column. + assert "—" in resp.text + + def test_scanner_count_chip_renders(self, tmp_path): + _write_scan(tmp_path, [ + {"scanner": "bandit", "metadata": {"duration_ms": 100}}, + {"scanner": "grype", "metadata": {"duration_ms": 200}}, + ]) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + # Summary chips show scanner count + total duration so the + # collapsed panel still conveys something useful at rest. + assert "2 scanners" in resp.text + assert "0.3s total" in resp.text + + def test_mtime_js_loaded(self, tmp_path): + _write_scan(tmp_path, [{"scanner": "bandit", "metadata": {}}]) + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + # scan-mtime.js humanizes the epoch timestamp client-side. + assert "scan-mtime.js" in resp.text diff --git a/argus/tests/serve/test_theme_toggle.py b/argus/tests/serve/test_theme_toggle.py new file mode 100644 index 00000000..3ac4bc99 --- /dev/null +++ b/argus/tests/serve/test_theme_toggle.py @@ -0,0 +1,81 @@ +"""Tests for the light/dark theme toggle. + +The actual palette swap happens via CSS custom properties — these +tests assert the toggle button renders, the JS loads, and the CSS +contains both theme blocks. Runtime toggle behavior is JS-land +and covered by the live UI walkthrough rather than a headless unit. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("fastapi") + +from fastapi.testclient import TestClient # noqa: E402 + +from argus.serve.app import create_app # noqa: E402 + + +class TestThemeToggleUI: + def test_toggle_button_renders_in_header(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + # Button with the theme-toggle class + id is in the header. + assert 'id="theme-toggle"' in resp.text + assert "theme-toggle" in resp.text + # Accessible label is present so screen readers can announce it. + assert 'aria-label="Toggle color theme"' in resp.text + + def test_toggle_script_loaded(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/") + assert "theme-toggle.js" in resp.text + + def test_toggle_renders_on_every_page(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + for path in ("/", "/findings", "/picker"): + resp = client.get(path) + assert 'id="theme-toggle"' in resp.text, ( + f"theme toggle missing on {path}" + ) + + +class TestThemeCssBothVariants: + """Both palette tokens must be in the stylesheet so the toggle + can swap them at runtime. Each theme's deep-bg is the canary.""" + + def test_dark_palette_present(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/static/argus.css") + # Dark palette is the :root default. + assert "#0b0f0d" in resp.text + # Explicit [data-theme="dark"] block also exists for users + # who override a light OS preference. + assert '[data-theme="dark"]' in resp.text + + def test_light_palette_present(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/static/argus.css") + # Light palette is anchored on these three tokens. + assert "#f5f7f0" in resp.text # light deep-bg + assert '[data-theme="light"]' in resp.text + # And auto-activates for users with a light OS preference. + assert "prefers-color-scheme: light" in resp.text + + def test_on_accent_token_keeps_ctas_readable(self, tmp_path): + app = create_app(root=str(tmp_path)) + client = TestClient(app) + resp = client.get("/static/argus.css") + # Both themes pin the CTA text color to argus-on-accent + # (which stays dark in both themes) so lime buttons never + # flip to low-contrast off-white on top of lime. + assert "--argus-on-accent" in resp.text + assert "color: var(--argus-on-accent)" in resp.text From d80a11bd6a8f7a30407c217690ff5e746ecd6ada Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 16:38:47 -0400 Subject: [PATCH 18/21] docs(serve): record Phase 2 completions and scope deferrals in roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-launch additions (SG-SO) captured as shipped items so the roadmap reflects what's actually in the code: - SG drill-downs, SH detail disclosure, SI sortable columns - SJ path-scope constraint, SK export, SL scan diff - SM recent-scans dropdown, SN metadata panel, SO theme toggle Two ideas pitched during the same design round are declined with explicit reasoning so future "why don't we…" issues can point back here: - Keyboard shortcuts — lower payoff in a web surface where bookmarking + mouse already handle the common flows; revisit if users ask. - Triage annotations — would break the read-only model, invent a new cross-run persistence schema we have no consumer for, and duplicate vuln-management functionality that belongs to argus-portal. --- docs/developer/SDK-ROADMAP.md | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/docs/developer/SDK-ROADMAP.md b/docs/developer/SDK-ROADMAP.md index 4c95c46c..2fc4cb66 100644 --- a/docs/developer/SDK-ROADMAP.md +++ b/docs/developer/SDK-ROADMAP.md @@ -364,6 +364,67 @@ Shipped on `feat/serve-webui` across six commit-sized phases - Secret-redaction on finding text — not serve-specific; applies equally to CLI / TUI / JSON export. Tackle globally when it lands. +### Phase 2 additions (post-launch, shipped) + +Iteration after dogfooding the initial build. Same scoping rules +apply: read-only, localhost-only, no new persistence. + +- [x] **SG** — Drill-downs on dashboard cards and per-product / + per-scanner rows; each deep-links into `/findings` with the + matching filter pinned. +- [x] **SH** — Findings row detail (native `
` disclosure + inside each title cell, rendering `finding_detail_rows` — same + source of truth the TUI uses). +- [x] **SI** — Sortable column headers on the findings table + (Severity / ID / Location / Scanner), aria-sort state reflected. +- [x] **SJ** — Path-scope constraint: `?scan=` and `/picker?path=` + reject targets outside the launch root unless the user relaunches + with a broader `--root`. Defense-in-depth even though cross-origin + readback is already blocked by the browser SOP. +- [x] **SK** — Export routes (`/export?format=csv|json|markdown|sarif`) + reusing `argus/browse/export.py`; each format exposed in the + findings UI with both Download (browser save) and Copy (clipboard + via `navigator.clipboard.writeText`) actions. +- [x] **SL** — Scan diff (`/diff?a=&b=`): new / fixed / + severity-changed / still-open buckets keyed off the + `(scanner, id, location)` identity tuple. Picker surfaces + checkboxes on scan-ready rows + a "Compare selected" button. +- [x] **SM** — Recent-scans dropdown in the header, auto-populated + from scan-ready siblings of the launch root; symlink-deduplicated + so `latest/` doesn't double-count. +- [x] **SN** — Scan metadata panel on the dashboard exposing + per-scanner tool versions, container image digests, durations, + aggregate duration, and the scan file's mtime. +- [x] **SO** — Light/dark theme toggle with `prefers-color-scheme` + default and a localStorage override. Brand palette unchanged in + dark; light variant derived from the same tokens with deeper + severity hues for legibility on a bright surface. + +### Future ideas (not on the roadmap) + +Deliberately not pursuing for now — recording here so the decision +doesn't have to be re-litigated when someone files a "what about +X?" issue. + +- **Keyboard shortcuts** (`/` focus search, `j/k` row nav, `Enter` + expand detail). Considered during the post-launch walkthrough and + deferred: browser URL bookmarking already handles the most common + flows, and keyboard shortcuts are an expected affordance in TUIs + like `argus browse` but a lower payoff in a web surface where + mouse + click is the dominant interaction mode. Could revisit if + users ask, but not planned. +- **Triage annotations** (mark false-positive, accepted risk, fix + scheduled). Considered and declined. Adding these would mean: + 1. Writing state to a sidecar file, which breaks the strict + read-only model serve is built around. + 2. Inventing a schema for persisting + recalling triage state + across scan runs, with no standard way to surface it back into + later scans or report it to a security review POC. + 3. Duplicating effort with `argus-portal`, which has first-class + vuln management in its scope. + Routes argus into vuln-management territory without a downstream + consumer that uses the data — not worth the complexity. + ### Relationship to `argus-portal` The two are complementary, not competing: From 3cf71078d2e7a71507f03f4f7d88cd5e9325a353 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 17:32:56 -0400 Subject: [PATCH 19/21] fix(serve): guard recent-scans peek against non-dict JSON payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _collect_recent_scans opens every argus-results.json under the launch root to peek finding counts for the dropdown. The peek assumed a dict shape ({"results": [...]}) and called .get() on the parsed JSON directly. When the top-level payload was a list — which happens in argus.browse.loader's test_rejects_non_object_payload fixture — the call surfaced as an AttributeError that bubbled up through the whole findings route. Under pytest the failure only appeared in the full suite: each test got its own tmp_path, but the session root /tmp/pytest-of-*/ was shared, and _collect_recent_scans walks launch_root.parent when launch_root is itself a scan dir. A later serve test would see the loader test's list-shaped sibling and 500. Every nested lookup is now type-guarded: the peek checks isinstance before calling .get() or len(), and any shape mismatch degrades to count=0 rather than raising. Three tests added covering the three realistic bad shapes — bare list, results-as-non-list, findings-as-non-list. --- argus/serve/app.py | 13 +++++++--- argus/tests/serve/test_recent_scans.py | 33 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/argus/serve/app.py b/argus/serve/app.py index 7502f8db..800497de 100644 --- a/argus/serve/app.py +++ b/argus/serve/app.py @@ -285,13 +285,20 @@ def _collect_recent_scans( # Cheap finding-count peek — the same pattern ``_list_directory`` # uses when flagging scan-ready picker rows. Doesn't load the - # whole scan; just counts the "findings" arrays. + # whole scan; just counts the "findings" arrays. Every access + # is type-guarded so a malformed results file (a stray list, + # a nested non-dict result block, string under ``findings``) + # degrades to count=0 instead of crashing the dropdown. count = 0 try: with results_file.open() as fh: data = json.load(fh) - for r in data.get("results", []): - count += len(r.get("findings", [])) + if isinstance(data, dict): + for r in data.get("results", []) or []: + if isinstance(r, dict): + findings = r.get("findings") or [] + if isinstance(findings, list): + count += len(findings) except (OSError, json.JSONDecodeError): count = 0 diff --git a/argus/tests/serve/test_recent_scans.py b/argus/tests/serve/test_recent_scans.py index d12585ec..47e4928f 100644 --- a/argus/tests/serve/test_recent_scans.py +++ b/argus/tests/serve/test_recent_scans.py @@ -132,6 +132,39 @@ def test_malformed_scan_falls_back_to_zero_count(self, tmp_path): assert len(scans) == 1 assert scans[0]["count"] == 0 + def test_non_dict_json_payload_doesnt_crash_peek(self, tmp_path): + # A well-formed JSON file whose TOP-LEVEL value isn't a dict + # (a bare list, for example) must not crash _collect_recent_scans. + # This actually surfaced across tests: argus.browse.loader has + # a test that writes a list-shaped argus-results.json to its + # tmp_path, and pytest's shared session root meant the picker + # walk could trip on that sibling while running a serve test. + # Every nested lookup is type-guarded so any shape -> count=0. + run = tmp_path / "run-list" + run.mkdir() + (run / "argus-results.json").write_text("[1, 2, 3]") + scans = _collect_recent_scans(tmp_path) + assert len(scans) == 1 + assert scans[0]["count"] == 0 + + def test_dict_with_non_list_results_doesnt_crash(self, tmp_path): + run = tmp_path / "run-weird" + run.mkdir() + (run / "argus-results.json").write_text('{"results": "oops"}') + scans = _collect_recent_scans(tmp_path) + assert len(scans) == 1 + assert scans[0]["count"] == 0 + + def test_result_block_without_findings_list_doesnt_crash(self, tmp_path): + run = tmp_path / "run-weird" + run.mkdir() + (run / "argus-results.json").write_text( + '{"results": [{"scanner": "x", "findings": "not-a-list"}]}' + ) + scans = _collect_recent_scans(tmp_path) + assert len(scans) == 1 + assert scans[0]["count"] == 0 + def test_limit_caps_list_size(self, tmp_path): for i in range(20): d = tmp_path / f"run-{i:02d}" From 29ac6748f781a3b24a1601c484affc124d82cecc Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 17:43:32 -0400 Subject: [PATCH 20/21] build: pin httpx[socks] into the ai extra for SOCKS-proxy envs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anthropic + openai Python SDKs instantiate an httpx.Client during construction, even in tests that never make a real HTTP call (test_default_base_url just reads a config string, for example). When the developer's environment has a SOCKS proxy env var set — common in corporate networks with ALL_PROXY=socks5:// or HTTPS_PROXY=socks5:// — httpx eagerly tries to build a SOCKS transport and raises ImportError because socksio isn't in the stdlib. 36 unrelated tests fail the moment the first provider is constructed, all with the same error. CI doesn't hit this because CI's shell has no SOCKS env vars, so httpx's default transport initializes cleanly. Only local dev environments see it. httpx[socks] resolves via httpx's existing ``socks`` extra and pulls in socksio. The dep goes on the ``ai`` extra rather than the core requirements because the SDKs it supports are themselves opt-in — users who skip [ai] never construct a provider and never need SOCKS support. --- pyproject.toml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9d5785d0..ca65b4e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,20 @@ dependencies = [ ] [project.optional-dependencies] -# AI classification for SCN detector (argus classify --enable-ai) -ai = ["anthropic>=0.39.0", "openai>=1.0.0", "requests>=2.31.0"] +# AI classification for SCN detector (argus classify --enable-ai). +# httpx[socks] is pulled in explicitly — anthropic + openai both +# instantiate an httpx.Client during SDK construction, and httpx +# only picks up a SOCKS proxy transport when the socksio package +# is present. Developer environments with an ``ALL_PROXY``/``HTTPS_PROXY`` +# that uses ``socks5://`` would otherwise ImportError every test +# that builds a provider object, even tests that never make an +# actual HTTP call. +ai = [ + "anthropic>=0.39.0", + "openai>=1.0.0", + "requests>=2.31.0", + "httpx[socks]>=0.24", +] # Shell tab completion completion = ["argcomplete>=3.0"] # MCP server for AI assistant integration From c0de9407a2f1356a3b118106f9c1ad2f396afe33 Mon Sep 17 00:00:00 2001 From: eFAILution Date: Fri, 24 Apr 2026 18:22:53 -0400 Subject: [PATCH 21/21] ci: install argus-security[all] before running unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serve test modules guard their imports with pytest.importorskip("fastapi"). CI's dep install was only running pip install -r requirements.txt, which doesn't include fastapi / uvicorn / jinja2. With those missing, every serve test skipped at module load — test bodies never executed, and codecov's patch metric correctly flagged 1200+ lines of added test code as uncovered. (Browse tests avoided the same issue by manually stubbing textual at import time — a workaround for the same root cause.) Installing the project with every optional extra resolves it properly: serve tests actually run against the real FastAPI routes, MCP server tests run against the real mcp package, AI classifier tests against the real anthropic / openai SDKs. No test skips anymore; codecov sees the real coverage of everything. Local patch coverage on PR #97 jumps from 12.05% to 97.34% with this change. The single-threshold 80% patch target is comfortably met and the non-test-file patch coverage alone is 87.82%. --- .github/workflows/test-unit.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 602c71c6..276cc32e 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -33,6 +33,15 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt + # Install the package itself with every optional extra so all + # test modules can actually run. Tests for optional features + # (argus browse / argus serve / MCP server / AI classifier) + # guard their imports with pytest.importorskip or module-level + # stubs — without the extras those tests either skip or run + # against fake stubs, and codecov's patch metric flags the + # unexercised test bodies as missing coverage. Installing + # [all] runs the real tests against the real code paths. + pip install -e '.[all]' pip install pytest-deadfixtures - name: Run all tests