feat(serve): SDK-hosted localhost web UI — argus serve#97
feat(serve): SDK-hosted localhost web UI — argus serve#97eFAILution wants to merge 21 commits intofeat/argus-portabilityfrom
Conversation
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
Wires the executive-summary view onto the ``/`` route. Supports both
launch-root-based and ``?scan=<path>``-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 <meta http-equiv> 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.
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 <select> / <input> 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
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=<dir>`` — 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.
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 <html>/<nav>
layout chrome
- partial honors the same filters as the full page (same CVEs
surface in both for a given query)
- no-match state renders in the partial without layout
- full page includes the auto-filter script + data-auto-filter
attribute + swap target (contract with the JS)
- /static/auto-filter.js served with the right content-type and
references the DOM ids/attributes the template uses (catches
renames that would silently break interactivity)
User-facing and AI-context documentation for argus serve. Closes out the feat/serve-webui branch. New: - docs/serve.md — full user guide: install, launch, view-by-view walkthrough (dashboard, findings, picker, healthz), security posture, non-goals, relationship to argus browse and argus-portal, troubleshooting, related links. - .ai/decisions.yaml ADR-017 — localhost-only SDK-bundled web UI as a track distinct from the enterprise argus-portal; reuses argus.core.findings_view so CLI, TUI, and web agree on filter and summary semantics. Documents the non-goals (--bind, --basic-auth, secret redaction, live reload, JSON API, scan-over-scan diff) and why each is deferred or out of scope. Updated: - README.md — two feature bullets for argus browse and argus serve pointing at their docs. - docs/developer/SDK-ROADMAP.md — serve section rewritten to reflect shipped status with SA → SF phase breakdown, scope deferrals enumerated, future roadmap items retained. - .ai/architecture.yaml — serve/ registered in both component listings; findings_view note updated to reference argus serve as a current (not future) consumer. - .ai/workflows.yaml — new local_web_dashboard entry alongside the existing interactive_triage entry so AI consumers and the docsite builder see both surfaces. - CLAUDE.md — argus/ source tree entry for serve/ covering app.py + templates/ + static/. CLI reference was already regenerated during earlier phase commits; no delta here.
argus scan writes results to timestamped subdirs like argus-results/2026-04-24T14-54-13Z/ and maintains a latest symlink pointing at the most recent run. Users pointing argus serve at the parent directory expected the latest run to load automatically, but _resolve_scan only checked for a direct argus-results.json child and reported the dir as empty. _resolve_scan now walks through latest/ (symlink or real dir) as a fallback when there is no direct hit. If neither is present but subdirs themselves contain results, the error message nudges the user to the picker with a count instead of a bare not-found. Adds regression tests covering the symlink case, the latest-as-directory case, the parent-of-runs case, and precedence between a direct hit and a latest fallback.
Three unrelated issues surfaced during a full UI walk-through, fixed together because each ships with a regression test and each patch is small. 1. Picker breadcrumb and prefill showed the URL path instead of the filesystem path. base.html.j2 used set current = request.url.path in nav for active-link styling; that variable leaked into child content blocks and shadowed the route-provided current (the filesystem path being browsed). Renamed to _active_nav. The blast radius was more than cosmetic: the Jump-to-path form was prefilled with /picker and the Show hidden link built /picker?path=/picker, both of which routed users into an error page. 2. Findings table forced the entire page to scroll horizontally on narrow viewports. Wrapped in a .table-wrap container with overflow-x: auto so only the table scrolls; the header, nav, and filter bar stay anchored. 3. /favicon.ico returned 404 on every page load. Served the bundled argus_favicon.png through a dedicated route and added a link rel=icon tag in base.html.j2. Listed the serve templates and static assets in pyproject.toml package-data so they also ship inside the built wheel.
argus serve is localhost-only and cross-origin readback is already blocked by the browser SOP, but leaving ?scan= free to target arbitrary paths left a file-existence and directory-listing oracle in reach of a crafted cross-site GET, and was at odds with the trust model the README promises. _resolve_scan and the picker route now reject paths that resolve outside app.state.root with a specific error pointing users at --root as the escape hatch. _is_within uses Path.relative_to so /foo-bar cant spoof /foo via a shared prefix. The pickers .. parent row is suppressed at the launch root (where it would have fallen into the same scope error instead of offering navigation). Tests cover the /etc/passwd case, sibling-outside-root rejection, within-root ?scan= still working, and parent-link suppression. The prior sibling-dir test is split into two cases: "inside root, accepted" and "outside root, rejected" so the contract is clear.
Every summary tile on the dashboard was a dead end. The cards showed counts, the per-product and per-scanner rows showed totals, but clicking them did nothing — users had to navigate to Findings and reconstruct the filter from memory. Each card and row is now an anchor that deep-links into /findings with the matching filter pinned. Severity cards pin min_severity, per-product rows pin product, per-scanner rows pin scanner. The active ?scan= is carried through so drill-down stays in the same scan context. Hover and focus-visible states are styled so the affordance is obvious. Four ancillary fixes came along for the ride: - Zero-count severity pills on per-product rows now use a subdued .sev-zero color so "0 Critical" doesn't read as shouty as a real critical finding. - When every finding lacks sbom_source and there is exactly one "(no product)" bucket, the Per product section is hidden — it adds no information beyond the total card above. - ViewState.matches compares against the same "(no product)" fallback label that unique_products() produces, so clicking the "(no product)" option actually returns results instead of an empty table. - Drill-down anchors use a Jinja macro so the URL shape stays in one place. Tests cover every drill-down href (severity, product, scanner, total), ?scan= preservation, the single-bucket hide, and the "(no product)" filter match.
Two related improvements to the findings view, bundled because they share the same partial template and test surface. Detail disclosure: each finding row now has a native details element inside the title cell. Clicking the title expands to show every field finding_detail_rows() produces — scanner, CVE, CWE, package@version, fix, location, SBOM — plus the full description and any references the scanner attached. Using native details keeps the UI zero-JS, keyboard-accessible, and CSP-safe without a modal or extra route. The browse TUI and serve web UI now both render from the same finding_detail_rows(f) source of truth, so future field additions land in both front-ends at once. Sortable columns: the Severity, ID, Location, and Scanner headers are now anchor links. First click sorts ascending (or descending for severity, since "most serious first" is the interesting cut); subsequent clicks on the active column flip direction. aria-sort reflects state for assistive tech and a small arrow glyph shows it visually. Filters and ?scan= ride along on every sort href so clicking never wipes the current cut. ViewState.sort_key_fn gained id_desc / location_desc / scanner_desc variants and a companion sort_reverse property that tells callers when to pass reverse=True. The combined key form keeps the TUI's cycle-sort contract unchanged. Added tests: - detail disclosure renders on full-page and ?partial=1 paths, core field set present, description round-trips - default sort is severity_desc, active header flips on re-click, inactive headers default to ascending, location asc/desc orders findings correctly, filters are preserved in sort hrefs, invalid sort falls back to default - (no product) label filtering (regression while touching the ViewState module) Also added a sr-only table caption and accessible sort-link styling.
Small improvements that each stand on their own but aren't worth a PR each: - Auto-hide the Package / Fix / Source SBOM columns when every visible finding has nothing for them. Bandit-only runs no longer show three all-em-dash columns. Mixed-scanner runs keep every column someone uses. - Quiet filter-hint banner when ?min_severity= is non-empty but unrecognized. Tells the user "we showed you everything because 'xxxx' isn't a severity we know" rather than silently ignoring. - Referrer-Policy: no-referrer so cross-origin navigations from the footer link don't leak local file paths baked into ?scan= and other query params. - auto-filter.js logs refresh failures to console.warn (http !ok and thrown fetch errors) instead of silently swallowing them, and applies a subtle .is-loading dim on the swap target while a fetch is in flight — visible feedback on slow disks / big scans. - Long scan paths in the crumb box use overflow-wrap: anywhere so they can't push the layout sideways. - Mobile header uses flex-wrap + white-space: nowrap on each nav link so "Switch scan" stays one word. Tests cover the Referrer-Policy header, column auto-hide under bandit-only and mixed-scanner inputs, severity hint rendering for invalid values (and absence for valid ones), and a scanner_desc sort regression that isolates tbody ordering from the filter form.
Every template had inline style= attributes for color, layout, and sizing — which forced the CSP style-src directive to carry 'unsafe-inline' so the pages would actually render. That weakened the CSP across the board: any injected <span style=...> would execute, and future inline styles would silently re-cross the boundary. Extracted every inline style into named classes in argus.css: .muted / .muted-small / .muted-xs / .accent-strong for color and size; .picker-crumbs / .picker-intro / .picker-actions / .picker-jump-form / .picker-type-col for the picker-specific bits; .btn-primary for the green accent CTA; .filter-bar for the findings filter row; .scan-crumbs for the top-of-main scan path. The CSP and meta tag now read style-src 'self' with no loopholes. Added regression tests: - CSP header no longer contains 'unsafe-inline' - /, /findings, /picker render with zero inline style= attributes (catches future template drift)
Pulled the brand palette and typography tokens straight from the product page so the local UI feels like the same product. Purely visual — every functional behavior (filters, sort, drill-down, detail disclosure, path scoping, CSP) is unchanged. Brand palette, mirrored from the product sites CSS: - --argus-deep-bg #0b0f0d body background - --argus-dark-surface #111916 panel / card / table background - --argus-subtle-panel #16211c secondary surface, hover state - --argus-primary-green #84b852 section headings, code, footer link - --argus-accent-lime #dbe64c active nav, CTAs, focus ring - --argus-light-text #eaf2ea primary foreground - --argus-muted-text #9fb09f secondary foreground - --argus-border #1f2a22 subtle panel edge Typography shifts to a geometric-forward stack. Headings use a display font ladder (SF Pro Display fall-through) with wider letter-spacing; the Project Argus wordmark is uppercase tracked to match the hero treatment. Body falls back to Inter / system ui. Component-level changes: - Header: brand mark + Project Argus wordmark left, nav right. Active nav link is a lime pill (matches hero CTA treatment). - Cards: bordered panels with severity top-accent; Total Findings gets the lime brand accent. Hover lifts + border transitions to primary-green. Value font bumped and set in heading stack. - Buttons: btn-primary uses lime fill with a soft glow on hover. Generic buttons are dark-surface with lime hover outline. - Form controls: dark-panel background, lime focus ring. - Severity chips: tighter radius, uppercase + tracked. - Per-product / per-scanner rows: bordered panels rather than flat list rows; hover lights the border to primary-green. - Placeholder / warning boxes: bordered panel treatment. - Footer link uses primary-green (subdued). - Subtle brand vignette (two radial gradients behind the content) gives depth without needing an image asset. No Google Fonts dependency and no external CDN — CSP stays strict (style-src 'self'; default-src 'self'). Brand logo reuses the existing favicon.png in the header, filtered with a faint lime drop-shadow for the neon eye feel. All 85 serve tests still pass; the rebrand is CSS + one header markup swap.
Exposed the existing TUI export writers as a web endpoint so users can pull their filtered view out of the dashboard without leaving it. The filter + sort pipeline is factored into a shared helper so /findings (render) and /export (serialize) always operate on the same subset — paste a filter URL between the two endpoints and the data matches exactly. argus/browse/export.py gained render_csv / render_json / render_markdown / render_sarif that return strings; the original write_* functions are now thin wrappers around them. RENDERERS and CONTENT_TYPES dispatch tables let the serve route look up the right serializer and MIME type by format key. No behavior change for the TUI — its WRITERS table is intact and all 12 existing export tests still pass. GET /export accepts every /findings filter param plus ?format= and ?download=1. Without download=1 the response is inline — that's what the copy-to-clipboard JS fetches so the browser doesn't prompt a save dialog. With download=1 a Content-Disposition: attachment header names the file argus-findings-<timestamp>-<scope>.<ext> where scope captures the active sev / product / scanner filters. Findings page now has an Export disclosure menu with four format rows (CSV / JSON / Markdown / SARIF), each offering Download (plain anchor, no JS needed) and Copy (navigator.clipboard.writeText with a textarea fallback for contexts where the Clipboard API is unavailable). A role="status" line inside the menu flashes "CSV copied to clipboard" / "Copy failed" etc. Out-of-scope decisions (documented for future rounds): - Keyboard shortcuts: roadmap as "maybe" — low ROI for web where URL bookmarking already handles the frequent flows. - Triage annotations: declined — no vuln management plans, no standard way to surface them back into later scans. Added 13 tests covering every format, filter pass-through, download-vs-inline behavior, the 400 on unknown format, 404 on missing scan, filename scope encoding, and the UI menu rendering + filter-preserving hrefs.
Added the cross-run diff UI requested in the last round. Pick two scans from the picker checkboxes, hit Compare, and /diff renders four buckets: new / fixed / severity-changed / still-open. Core logic in argus/core/findings_view.diff_scans: - Finding identity = (scanner, id, location). scanner + id alone collapses every bandit rule hit across files; location alone breaks when multiple scanners or rules share a line. This triple is the smallest combination that survives run-to-run restarts without collisions. - Buckets are sorted severity-desc so the template never needs a second pass. Severity-changed pairs carry both the before and after Finding objects so the template can render the transition inline. - Empty inputs return empty buckets (no special case at caller). - Eight tests covering every bucket, the identity rule, sort order, and empty/identical inputs. serve/app.py gained a /diff route. Both scan paths go through _load_scan so the launch-root scope constraint applies equally — you can't diff into /etc/passwd or a sibling tree. Missing ?a= or ?b= renders a placeholder pointing the user back to the picker rather than a 400. Picker rows with argus-results.json gained a checkbox + a new "Compare selected" bar above the table. picker-compare.js watches the checkboxes: - 0 checked: "Check two scans to compare", button disabled - 1 checked: "1 selected — pick one more" - 2 checked: button enabled, href = /diff?a=X&b=Y - 3+: tells the user exactly how many to uncheck Without JS the bar stays hidden (the markup default is hidden) so no-JS users just see the checkboxes without a broken button. Template + CSS: - diff.html.j2 renders the header, tally pills, and four sections. Still-open is a collapsed <details> so long diffs don't bury the more interesting new/fixed content. - argus.css gets diff-section, diff-tally-pill, diff-header, and picker-compare-bar styles tuned to the existing brand palette. .btn-primary:disabled gets a subdued look so the compare button still reads as a button before two items are selected. Tests (12 new): - Every bucket populates correctly end-to-end from two JSON files - Missing / partial params render error, not 500 - Out-of-scope paths rejected via the existing _is_within gate - Malformed results surface in the error - Identical scans show 100% still-open - Picker checkboxes appear only on scan-ready rows - Compare bar and picker-compare.js are wired on page load
Three independent but complementary additions to the header and
dashboard — each small on its own, grouped because they share the
base-context plumbing and ship no user-visible churn in existing
flows.
Recent-scans dropdown (header):
_collect_recent_scans walks the launch root for scan-ready dirs,
peeks the finding count out of each argus-results.json, and sorts
newest-first by mtime. Rendered as a native <details> dropdown
in the nav so switching runs is one click; zero JS; symlink-
deduplicated so "latest" collapses into the timestamped dir it
points at. Dropdown is suppressed when there's only one scan so
single-run deployments don't see empty nav noise. The currently
loaded scan gets aria-current and a lime highlight.
Scan metadata panel (dashboard):
Collapsed <details> below the severity cards that surfaces every
audit-ready field argus-results.json already carries:
- Per-scanner: tool_version, execution (container vs local),
full image digest (SHA-pinned scanners), duration_ms,
findings count.
- File: scan-file path + modification time (humanized client-
side by scan-mtime.js to "Apr 24, 10:57 AM (6h ago)"; falls
back to epoch when JS is disabled).
- Aggregate: scanner count + summed duration shown as chips on
the collapsed summary so the panel is informative at rest.
commit_sha isn't emitted by argus scan yet; the extractor
surfaces it when/if it ever is.
Light/dark theme toggle:
CSS restructured so :root carries the dark palette and a
[data-theme="light"] block (plus a @media (prefers-color-scheme:
light) mirror) holds the light overrides. Severity hues get
deeper variants in light mode so badges stay readable on the
warm off-white background. A new --argus-on-accent token keeps
CTA text dark in both themes (lime on dark = #0b0f0d text;
lime on light = same #0b0f0d text, never flipped to white).
Small theme-toggle.js persists the choice in localStorage under
"argus-theme" and reflects the live theme in the button's icon
(☀/☾) + aria-label. No saved preference → OS prefers-color-scheme
wins and the button just tracks it. Button itself is a 32px
circle styled as a utility control (transparent, bordered) so it
reads as secondary to the lime primary nav.
Supporting refactor:
Every HTML route now spreads _base_context(resolved) into its
template context so recent_scans is available on dashboard,
findings, picker, and diff without duplicating the collector call
at each site.
Tests:
- test_recent_scans.py (15 tests): collector handles
parent-of-runs + scan-as-root shapes, newest-first sorting,
symlink dedup, current-scan highlighting, malformed-scan
fallback, limit cap, and the dropdown visibility rules (hidden
for single-scan deployments, shown on all three pages that
render it, href carries scan switching).
- test_scan_metadata.py (10 tests): per-scanner extraction,
duration summation, None-vs-zero distinction, file/mtime
capture, UI renders scanner table with digest/duration/version,
panel absent in empty state, survives missing duration,
chips and mtime JS loaded.
- test_theme_toggle.py (7 tests): button + script on every page,
both palettes present in argus.css, on-accent token wired.
All 141 serve tests green.
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.
_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.
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.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
🔒 Argus Container Security ScanBranch: 📊 Combined Findings Summary
Scanned: 4 containers | Build Failures: 0 📦 Container Breakdown
🔍 Detailed Findings by Container🚨 cli - 28 vulnerabilities (22 unique)Image: Combined (Deduplicated)
🔷 Trivy Scanner (28 findings, 22 unique)
⚓ Grype Scanner (0 findings, 0 unique)✅ No vulnerabilities detected by Grype 🟡 scanner-bandit - 1 vulnerabilities (1 unique)Image: Combined (Deduplicated)
🔷 Trivy Scanner (1 findings, 1 unique)
⚓ Grype Scanner (0 findings, 0 unique)✅ No vulnerabilities detected by Grype
|
| 🚨 Critical | 🟡 Medium | 🔵 Low | Total | Unique | |
|---|---|---|---|---|---|
| 0 | 6 | 30 | 72 | 109 | 47 |
🔷 Trivy Scanner (109 findings, 46 unique)
| CVE | Severity | Package | Version | Fixed |
|---|---|---|---|---|
| CVE-2025-69720 | libncursesw6 | 6.5+20250216-2 | N/A | |
| CVE-2026-29111 | libsystemd0 | 257.9-1~deb13u1 | N/A | |
| CVE-2025-69720 | libtinfo6 | 6.5+20250216-2 | N/A | |
| CVE-2026-29111 | libudev1 | 257.9-1~deb13u1 | N/A | |
| CVE-2025-69720 | ncurses-base | 6.5+20250216-2 | N/A | |
| CVE-2025-69720 | ncurses-bin | 6.5+20250216-2 | N/A | |
| CVE-2026-27456 | 🟡 MEDIUM | bsdutils | 1:2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libblkid1 | 2.41-5 | N/A |
| CVE-2026-4046 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-4437 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-4438 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-5450 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-5928 | 🟡 MEDIUM | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2026-4046 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-4437 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-4438 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-5450 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-5928 | 🟡 MEDIUM | libc6 | 2.41-12+deb13u2 | N/A |
| CVE-2026-4878 | 🟡 MEDIUM | libcap2 | 1:2.75-10+b8 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | liblastlog2-2 | 2.41-5 | N/A |
| CVE-2026-34743 | 🟡 MEDIUM | liblzma5 | 5.8.1-1 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libmount1 | 2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libsmartcols1 | 2.41-5 | N/A |
| CVE-2026-40225 | 🟡 MEDIUM | libsystemd0 | 257.9-1~deb13u1 | N/A |
| CVE-2026-40226 | 🟡 MEDIUM | libsystemd0 | 257.9-1~deb13u1 | N/A |
| CVE-2026-4105 | 🟡 MEDIUM | libsystemd0 | 257.9-1~deb13u1 | N/A |
| CVE-2026-40225 | 🟡 MEDIUM | libudev1 | 257.9-1~deb13u1 | N/A |
| CVE-2026-40226 | 🟡 MEDIUM | libudev1 | 257.9-1~deb13u1 | N/A |
| CVE-2026-4105 | 🟡 MEDIUM | libudev1 | 257.9-1~deb13u1 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | libuuid1 | 2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | login | 1:4.16.0-2+really2.41-5 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | mount | 2.41-5 | N/A |
| CVE-2026-5704 | 🟡 MEDIUM | tar | 1.35+dfsg-3.1 | N/A |
| CVE-2026-27456 | 🟡 MEDIUM | util-linux | 2.41-5 | N/A |
| CVE-2026-27171 | 🟡 MEDIUM | zlib1g | 1:1.3.dfsg+really1.3.1-1+b1 | N/A |
| CVE-2026-3219 | 🟡 MEDIUM | pip | 26.0.1 | N/A |
| CVE-2011-3374 | 🔵 LOW | apt | 3.0.3 | N/A |
| TEMP-0841856-B18BAF | 🔵 LOW | bash | 5.2.37-2+b8 | N/A |
| CVE-2022-0563 | 🔵 LOW | bsdutils | 1:2.41-5 | N/A |
| CVE-2025-14104 | 🔵 LOW | bsdutils | 1:2.41-5 | N/A |
| CVE-2026-3184 | 🔵 LOW | bsdutils | 1:2.41-5 | N/A |
| CVE-2017-18018 | 🔵 LOW | coreutils | 9.7-3 | N/A |
| CVE-2025-5278 | 🔵 LOW | coreutils | 9.7-3 | N/A |
| CVE-2011-3374 | 🔵 LOW | libapt-pkg7.0 | 3.0.3 | N/A |
| CVE-2022-0563 | 🔵 LOW | libblkid1 | 2.41-5 | N/A |
| CVE-2025-14104 | 🔵 LOW | libblkid1 | 2.41-5 | N/A |
| CVE-2026-3184 | 🔵 LOW | libblkid1 | 2.41-5 | N/A |
| CVE-2010-4756 | 🔵 LOW | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2018-20796 | 🔵 LOW | libc-bin | 2.41-12+deb13u2 | N/A |
| CVE-2019-1010022 | 🔵 LOW | libc-bin | 2.41-12+deb13u2 | N/A |
...and 59 more
⚓ Grype Scanner (0 findings, 0 unique)
✅ No vulnerabilities detected by Grype
⚠️ scanner-supply-chain - 8 vulnerabilities (8 unique)
Image: ghcr.io/huntridge-labs/argus/scanner-supply-chain:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a
Combined (Deduplicated)
| 🚨 Critical | 🟡 Medium | 🔵 Low | Total | Unique | |
|---|---|---|---|---|---|
| 0 | 4 | 4 | 0 | 8 | 8 |
🔷 Trivy Scanner (8 findings, 8 unique)
| CVE | Severity | Package | Version | Fixed |
|---|---|---|---|---|
| CVE-2026-32280 | stdlib | v1.26.1 | 1.25.9, 1.26.2 | |
| CVE-2026-32281 | stdlib | v1.26.1 | 1.25.9, 1.26.2 | |
| CVE-2026-32283 | stdlib | v1.26.1 | 1.25.9, 1.26.2 | |
| CVE-2026-33810 | stdlib | v1.26.1 | 1.26.2 | |
| CVE-2026-3219 | 🟡 MEDIUM | pip | 26.0.1 | N/A |
| CVE-2026-32282 | 🟡 MEDIUM | stdlib | v1.26.1 | 1.25.9, 1.26.2 |
| CVE-2026-32288 | 🟡 MEDIUM | stdlib | v1.26.1 | 1.25.9, 1.26.2 |
| CVE-2026-32289 | 🟡 MEDIUM | stdlib | v1.26.1 | 1.25.9, 1.26.2 |
⚓ Grype Scanner (0 findings, 0 unique)
✅ No vulnerabilities detected by Grype
Generated by Argus
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%.
E2E Test Coverage Report
Summary
✅ All Actions Have E2E Coverage |
Description
Adds
argus serve— an SDK-bundled, read-only localhost web UI for viewingargus-results.json. Same data model as theargus browseTUI (merged in #96), sameargus.core.findings_viewshared renderer, different consumer: a browser tab for the non-terminal audience (product owners, managers, executives) who want one-click insight into their products' security posture without reading raw JSON or paging through CI logs.Deliberately not a replacement for the separate
argus-portalenterprise effort (see ADR-017) — no auth, no database, no CRUD, no multi-tenant hosting. Bound to127.0.0.1with no--bindflag by design: multi-user network exposure belongs toargus-portal, not here.Ships behind an optional
[serve]extra so CI/server installs that never need the web UI skip the FastAPI/uvicorn/Jinja closure entirely.Changes Made
argus serve [PATH] [--port N] [--open])argus/serve/— FastAPI app + Jinja templates + vanilla JS static assets)argus/core/findings_view.pypicks updiff_scans, sort-direction variants, extra sort keys)docs/serve.md, ADR-017,.ai/architecture.yaml,.ai/workflows.yaml, roadmap)httpx[socks]in the[ai]extra (unblocks local testing in SOCKS-proxy environments)Features shipped (in commit order, SA → SO)
Phase 1 — core (SA–SF):
SAPackage scaffold + CLI subcommand +/healthzSBDashboard landing page with scan resolution (?scan=,latest/symlink handling, path-of-runs error messaging)SC/findingsfilterable table — query-param filters shareViewState.matches()with the TUISD/pickerone-level file browser with scan-ready hints (finding-count peek on candidate dirs)SEProgressive-enhancement filter refresh via vanilla JS (~80 LoC, no HTMX dep)./findings?partial=1returns just the table fragment;auto-filter.jsswaps it in and keeps the URL in sync viahistory.replaceStateSFdocs/serve.mdquickstart, ADR-017, AICaC updatesPhase 2 — walk-through fixes + polish (13 bug/feature commits):
{% set current = request.url.path %}was shadowing the route's context var);latest/symlink fallback; favicon (reusesargus_favicon.png); mobile table overflow wrapper?scan=and/picker?path=constrained to descendants of the launch root (defense-in-depth — cross-origin readback is already SOP-blocked, but the existence-oracle is closed too)/findingswith the matching filter pinned<details>(zero-JS, keyboard-accessible, CSP-safe) — renders fromfinding_detail_rows(f)so TUI and web stay in locksteparia-sortstatefilter-hintbanner when?min_severity=doesn't parseReferrer-Policy: no-referrer, long-pathoverflow-wrap, mobile-friendly nav, auto-filter error logging with subtle loading statestyle=moved intoargus.css, dropped'unsafe-inline'fromstyle-src(regression test included so future template drift fails loudly)Phase 2 — features (drill-downs etc., 5 commits):
GET /export?format=csv|json|markdown|sarifreusingargus/browse/export.py. UI menu has Download + Copy (navigator.clipboard.writeTextwith textarea fallback)/diff?a=X&b=Ywith New / Fixed / Severity-changed / Still-open buckets keyed off(scanner, id, location)prefers-color-scheme+ a localStorage override. Brand palette stays anchored; deeper severity hues in light mode for legibilityBrand rebrand to match
argus.huntridgelabs.com— palette tokens lifted verbatim (--argus-deep-bg #0b0f0d,--argus-accent-lime #dbe64c, etc.), header uses the eye-mark + "Project Argus" wordmark, lime CTA buttons.Security Considerations
127.0.0.1only, no--bindflag, documented in ADR-017default-src 'self'; style-src 'self'; script-src 'self'— no external CDN, nounsafe-inlineX-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-Policy: no-referrer(scan paths travel through query params)?scan=and/picker?path=rejected if they resolve outside the launch root; relaunch with a broader--rootis the escape hatch<script>in the search box (escaped to safe text)/,/findings,/pickerscanned for anystyle="attribute (catches future template drift that would require loosening CSP)Security Details
Read-only by design: loads
argus-results.json, renders findings, optionally writes export artifacts to the user's clipboard via the browser's Clipboard API (no server-side file writes). Platform-native clipboard fallback uses a hidden textarea +document.execCommand('copy')for contexts wherenavigator.clipboardis unavailable (insecure contexts, restricted permissions). Noeval, no dynamic<script>injection — confirmed by the strict CSP.Testing
Test Results
~170 new tests across the serve surface:
argus/tests/serve/test_scaffold.py(9) — CLI subcommand parsing,ServeUnavailablefriendly error,/healthz, favicon route, link rel=icon tagargus/tests/serve/test_dashboard.py(16) —_resolve_scanrules (file / dir direct hit /latest/symlink /latest/dir / parent-of-runs picker nudge), CSP + Referrer-Policy headers, malformed-JSON error rendering, no-inline-styles regression guard, scan-scope rejectionargus/tests/serve/test_findings.py(32) — filter semantics (severity × product × scanner × query), sort headers (aria-sort state, flip-on-reactivate, filter preservation, location/scanner asc/desc ordering), auto-hide empty columns, severity hint for invalid?min_severity=, detail disclosure, dashboard drill-down hrefs,(no product)filter matchargus/tests/serve/test_picker.py(16) — directory listing rules, scan-ready hints, compare checkboxes, out-of-scope rejection, parent-link suppression at launch root, breadcrumb shows filesystem path not URL pathargus/tests/serve/test_auto_filter.py(5) —?partial=1fragment correctness, filter parity with full page, script +data-auto-filterwiringargus/tests/serve/test_diff.py(12) — every bucket populates end-to-end, identity rule (scanner × id × location), identical scans → 100% still-open, malformed inputs render error (not 500), scope enforcement, picker checkbox markupargus/tests/serve/test_export.py(13) — every format (CSV/JSON/Markdown/SARIF), filter pass-through, sort pass-through, inline vs attachment content-disposition, unknown format 400, missing scan 404, menu markup, JS wiringargus/tests/serve/test_recent_scans.py(18) — collector handles parent-of-runs + scan-as-root + single-scan + symlink-dedup shapes, limit cap, malformed-scan fallback, non-dict JSON payload guard (a real bug — the collector would crash on any JSON that wasn't{...}; guarded now), dropdown render rules, current-scan highlightingargus/tests/serve/test_scan_metadata.py(10) — per-scanner extraction (tool version / container digest / duration), aggregate duration, missing-field fallbacks, chip + mtime JS wiringargus/tests/serve/test_theme_toggle.py(7) — button + script on every page, both palettes present,--argus-on-accentwired so lime-on-lime never happensPlus extensions to
argus/core/findings_view.py(+24 tests):diff_scansbucketing rules, identity tuple correctness, severity-desc sort within buckets,(no product)filter symmetry withunique_products().AI Context Updates (.ai/)
.ai/architecture.yaml—serve/package registered.ai/workflows.yaml—local_web_dashboardentry.ai/decisions.yaml— ADR-017:argus serveas localhost-only SDK-bundled, distinct fromargus-portalChecklist
docs/serve.md, cli-reference regeneration, README feature bullet)argus/serve/app.py, 100% onargus/browse/export.py, 98% onargus/core/findings_view.py)For New Subcommands
argus servewired into CLI parser + dispatchargus completion zshdocs/cli-reference.mdregeneratedRoad-test commands
Bloat
argus/serve/code in the wheel: ~121 KB uncompressed (14 files)[serve]extra deps: ~15.5 MB on disk closure (fastapi + uvicorn[standard] + jinja2 + python-multipart + transitives). Opt-in only — users who don't install[serve]don't carry any of it.Relationship to argus-portal
See ADR-017 for the full design note. TL;DR:
argus serve(this PR)argus-portal(separate track)argus serveon a laptop or jumpboxIf
argus-portalmatures, it can consume the samefindings_viewshared module we now have for both TUI and web, keeping the per-finding display consistent across all three surfaces.Future work (tracked in roadmap)