Skip to content

feat(serve): SDK-hosted localhost web UI — argus serve#97

Open
eFAILution wants to merge 21 commits intofeat/argus-portabilityfrom
feat/serve-webui
Open

feat(serve): SDK-hosted localhost web UI — argus serve#97
eFAILution wants to merge 21 commits intofeat/argus-portabilityfrom
feat/serve-webui

Conversation

@eFAILution
Copy link
Copy Markdown
Collaborator

Description

Adds argus serve — an SDK-bundled, read-only localhost web UI for viewing argus-results.json. Same data model as the argus browse TUI (merged in #96), same argus.core.findings_view shared 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-portal enterprise effort (see ADR-017) — no auth, no database, no CRUD, no multi-tenant hosting. Bound to 127.0.0.1 with no --bind flag by design: multi-user network exposure belongs to argus-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

  • Added new feature (argus serve [PATH] [--port N] [--open])
  • Added new package (argus/serve/ — FastAPI app + Jinja templates + vanilla JS static assets)
  • Extended shared renderer (argus/core/findings_view.py picks up diff_scans, sort-direction variants, extra sort keys)
  • Updated documentation (docs/serve.md, ADR-017, .ai/architecture.yaml, .ai/workflows.yaml, roadmap)
  • Added dep declaration for 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):

  • SA Package scaffold + CLI subcommand + /healthz
  • SB Dashboard landing page with scan resolution (?scan=, latest/ symlink handling, path-of-runs error messaging)
  • SC /findings filterable table — query-param filters share ViewState.matches() with the TUI
  • SD /picker one-level file browser with scan-ready hints (finding-count peek on candidate dirs)
  • SE Progressive-enhancement filter refresh via vanilla JS (~80 LoC, no HTMX dep). /findings?partial=1 returns just the table fragment; auto-filter.js swaps it in and keeps the URL in sync via history.replaceState
  • SF docs/serve.md quickstart, ADR-017, AICaC updates

Phase 2 — walk-through fixes + polish (13 bug/feature commits):

  • Picker breadcrumb and prefill now show the filesystem path, not the URL path (base-template {% set current = request.url.path %} was shadowing the route's context var); latest/ symlink fallback; favicon (reuses argus_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)
  • Dashboard cards + per-product/scanner rows drill into /findings with the matching filter pinned
  • Findings row disclosure using native <details> (zero-JS, keyboard-accessible, CSP-safe) — renders from finding_detail_rows(f) so TUI and web stay in lockstep
  • Sortable column headers on Severity / ID / Location / Scanner with aria-sort state
  • Auto-hidden Package / Fix / Source SBOM columns when every visible row is empty
  • Quiet filter-hint banner when ?min_severity= doesn't parse
  • Referrer-Policy: no-referrer, long-path overflow-wrap, mobile-friendly nav, auto-filter error logging with subtle loading state
  • CSP tightened: every inline style= moved into argus.css, dropped 'unsafe-inline' from style-src (regression test included so future template drift fails loudly)

Phase 2 — features (drill-downs etc., 5 commits):

  • Export routes: GET /export?format=csv|json|markdown|sarif reusing argus/browse/export.py. UI menu has Download + Copy (navigator.clipboard.writeText with textarea fallback)
  • Scan diff: picker checkboxes + /diff?a=X&b=Y with New / Fixed / Severity-changed / Still-open buckets keyed off (scanner, id, location)
  • Recent-scans dropdown in header, auto-populated from scan-ready siblings; symlink-deduplicated
  • Scan metadata panel on the dashboard (per-scanner tool version, container image digest, duration; aggregate duration + scan mtime humanized client-side)
  • Light/dark theme toggle using prefers-color-scheme + a localStorage override. Brand palette stays anchored; deeper severity hues in light mode for legibility

Brand 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

  • No privileged exposure — 127.0.0.1 only, no --bind flag, documented in ADR-017
  • CSP: default-src 'self'; style-src 'self'; script-src 'self' — no external CDN, no unsafe-inline
  • Click-jacking: X-Frame-Options: DENY
  • MIME sniffing: X-Content-Type-Options: nosniff
  • Referrer leakage: Referrer-Policy: no-referrer (scan paths travel through query params)
  • Path scoping: ?scan= and /picker?path= rejected if they resolve outside the launch root; relaunch with a broader --root is the escape hatch
  • Jinja2 autoescape on all templates — confirmed against a reflected <script> in the search box (escaped to safe text)
  • XSS regression test: full render of /, /findings, /picker scanned for any style=" 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 where navigator.clipboard is unavailable (insecure contexts, restricted permissions). No eval, no dynamic <script> injection — confirmed by the strict CSP.

Testing

  • Unit tests added
  • Manual end-to-end road-testing in Chrome via Playwright (screenshots captured)
  • Dark + light theme verification
  • Full responsive walkthrough (1400px desktop → 420px mobile)

Test Results

2356 passed, 11 skipped, 7 deselected in ~17s

~170 new tests across the serve surface:

  • argus/tests/serve/test_scaffold.py (9) — CLI subcommand parsing, ServeUnavailable friendly error, /healthz, favicon route, link rel=icon tag
  • argus/tests/serve/test_dashboard.py (16) — _resolve_scan rules (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 rejection
  • argus/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 match
  • argus/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 path
  • argus/tests/serve/test_auto_filter.py (5) — ?partial=1 fragment correctness, filter parity with full page, script + data-auto-filter wiring
  • argus/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 markup
  • argus/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 wiring
  • argus/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 highlighting
  • argus/tests/serve/test_scan_metadata.py (10) — per-scanner extraction (tool version / container digest / duration), aggregate duration, missing-field fallbacks, chip + mtime JS wiring
  • argus/tests/serve/test_theme_toggle.py (7) — button + script on every page, both palettes present, --argus-on-accent wired so lime-on-lime never happens

Plus extensions to argus/core/findings_view.py (+24 tests): diff_scans bucketing rules, identity tuple correctness, severity-desc sort within buckets, (no product) filter symmetry with unique_products().

AI Context Updates (.ai/)

  • .ai/architecture.yamlserve/ package registered
  • .ai/workflows.yamllocal_web_dashboard entry
  • .ai/decisions.yaml — ADR-017: argus serve as localhost-only SDK-bundled, distinct from argus-portal

Checklist

  • Code follows project style guidelines
  • Documentation updated (docs/serve.md, cli-reference regeneration, README feature bullet)
  • All tests pass (2356)
  • Codecov patch coverage expected ≥ 80% (87% local on argus/serve/app.py, 100% on argus/browse/export.py, 98% on argus/core/findings_view.py)
  • Reviewed CONTRIBUTING.md guidelines

For New Subcommands

  • argus serve wired into CLI parser + dispatch
  • Shell completion updates automatic via argus completion zsh
  • docs/cli-reference.md regenerated

Road-test commands

pip install -e '.[serve]'
argus scan --config argus.yml          # produce argus-results/
argus serve argus-results --open       # launches browser against the latest run

# Then try:
#   Click a severity card  → filtered findings
#   Click Medium column header twice → asc, then desc
#   Click a finding title → expand detail disclosure
#   Open the Export menu → copy CSV to clipboard
#   Hit the theme toggle (top-right) → light mode
#   Switch scan → pick two scan-ready dirs → Compare selected → /diff view

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)
Audience Single team / single product owner Enterprise / multi-team compliance org
Deploy argus serve on a laptop or jumpbox Kubernetes + Postgres + Traefik
Auth None GitHub OAuth + RBAC + FedRAMP MFA
State Ephemeral, single file Multi-scan history, CRUD (POAM, changes)
Goal Answer "is product X shipping log4shell?" FedRAMP continuous-authorization dashboard

If argus-portal matures, it can consume the same findings_view shared module we now have for both TUI and web, keeping the per-finding display consistent across all three surfaces.

Future work (tracked in roadmap)

  • Keyboard shortcuts — deliberately declined for v1 (bookmarkable URLs + mouse cover the common flows; revisit if users ask)
  • Triage annotations — declined (breaks the read-only model, duplicates argus-portal's vuln-management scope)
  • Self-hosted Exo 2 / Noto Sans font bundle for pixel-parity with the brand site (~80 KB) — optional follow-up if the system-font fallback isn't close enough

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
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

❌ Patch coverage is 97.34463% with 47 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
argus/serve/app.py 87.71% 36 Missing ⚠️
argus/serve/__init__.py 31.25% 11 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 24, 2026

🔒 Argus Container Security Scan

Branch: feat/serve-webui
Commit: bad2b1a

📊 Combined Findings Summary

🚨 Critical ⚠️ High 🟡 Medium 🔵 Low 📦 Total 🔢 Unique
1 21 50 73 145 145

Scanned: 4 containers | Build Failures: 0

📦 Container Breakdown

Container Image 🚨 Crit ⚠️ High 🟡 Med 🔵 Low Total Unique Status
cli ghcr.io/huntridge-labs/argus/cli:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a 1 11 15 1 28 28
scanner-bandit ghcr.io/huntridge-labs/argus/scanner-bandit:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a 0 0 1 0 1 1
scanner-opengrep ghcr.io/huntridge-labs/argus/scanner-opengrep:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a 0 6 30 72 108 108
scanner-supply-chain ghcr.io/huntridge-labs/argus/scanner-supply-chain:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a 0 4 4 0 8 8

🔍 Detailed Findings by Container

🚨 cli - 28 vulnerabilities (22 unique)

Image: ghcr.io/huntridge-labs/argus/cli:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a

Combined (Deduplicated)

🚨 Critical ⚠️ High 🟡 Medium 🔵 Low Total Unique
1 11 15 1 28 22
🔷 Trivy Scanner (28 findings, 22 unique)
CVE Severity Package Version Fixed
CVE-2025-68121 🚨 CRITICAL stdlib v1.24.11 1.24.13, 1.25.7, 1.26.0-rc.3
CVE-2026-32280 ⚠️ HIGH stdlib v1.26.1 1.25.9, 1.26.2
CVE-2026-32281 ⚠️ HIGH stdlib v1.26.1 1.25.9, 1.26.2
CVE-2026-32283 ⚠️ HIGH stdlib v1.26.1 1.25.9, 1.26.2
CVE-2026-33810 ⚠️ HIGH stdlib v1.26.1 1.26.2
CVE-2025-61726 ⚠️ HIGH stdlib v1.24.11 1.24.12, 1.25.6
CVE-2025-61728 ⚠️ HIGH stdlib v1.24.11 1.24.12, 1.25.6
CVE-2026-25679 ⚠️ HIGH stdlib v1.24.11 1.25.8, 1.26.1
CVE-2026-32280 ⚠️ HIGH stdlib v1.24.11 1.25.9, 1.26.2
CVE-2026-32281 ⚠️ HIGH stdlib v1.24.11 1.25.9, 1.26.2
CVE-2026-32283 ⚠️ HIGH stdlib v1.24.11 1.25.9, 1.26.2
CVE-2026-34040 ⚠️ HIGH github.com/docker/docker v28.5.2+incompatible 29.3.1
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
CVE-2025-11579 🟡 MEDIUM github.com/nwaples/rardecode/v2 v2.1.0 2.2.0
CVE-2025-58058 🟡 MEDIUM github.com/ulikunitz/xz v0.5.12 0.5.15
CVE-2025-47914 🟡 MEDIUM golang.org/x/crypto v0.35.0 0.45.0
CVE-2025-58181 🟡 MEDIUM golang.org/x/crypto v0.35.0 0.45.0
CVE-2025-61730 🟡 MEDIUM stdlib v1.24.11 1.24.12, 1.25.6
CVE-2026-27142 🟡 MEDIUM stdlib v1.24.11 1.25.8, 1.26.1
CVE-2026-32282 🟡 MEDIUM stdlib v1.24.11 1.25.9, 1.26.2
CVE-2026-32288 🟡 MEDIUM stdlib v1.24.11 1.25.9, 1.26.2
CVE-2026-32289 🟡 MEDIUM stdlib v1.24.11 1.25.9, 1.26.2
CVE-2026-33997 🟡 MEDIUM github.com/docker/docker v28.5.2+incompatible 29.3.1
GHSA-3xc5-wrhm-f963 🟡 MEDIUM github.com/go-git/go-git/v5 v5.17.2 5.18.0
CVE-2026-27139 🔵 LOW stdlib v1.24.11 1.25.8, 1.26.1
⚓ Grype Scanner (0 findings, 0 unique)

✅ No vulnerabilities detected by Grype

🟡 scanner-bandit - 1 vulnerabilities (1 unique)

Image: ghcr.io/huntridge-labs/argus/scanner-bandit:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a

Combined (Deduplicated)

🚨 Critical ⚠️ High 🟡 Medium 🔵 Low Total Unique
0 0 1 0 1 1
🔷 Trivy Scanner (1 findings, 1 unique)
CVE Severity Package Version Fixed
CVE-2026-3219 🟡 MEDIUM pip 26.0.1 N/A
⚓ Grype Scanner (0 findings, 0 unique)

✅ No vulnerabilities detected by Grype

⚠️ scanner-opengrep - 109 vulnerabilities (47 unique)

Image: ghcr.io/huntridge-labs/argus/scanner-opengrep:bad2b1a5f8d5240980d3503a7dcf6eab85e90c4a

Combined (Deduplicated)

🚨 Critical ⚠️ High 🟡 Medium 🔵 Low Total Unique
0 6 30 72 109 47
🔷 Trivy Scanner (109 findings, 46 unique)
CVE Severity Package Version Fixed
CVE-2025-69720 ⚠️ HIGH libncursesw6 6.5+20250216-2 N/A
CVE-2026-29111 ⚠️ HIGH libsystemd0 257.9-1~deb13u1 N/A
CVE-2025-69720 ⚠️ HIGH libtinfo6 6.5+20250216-2 N/A
CVE-2026-29111 ⚠️ HIGH libudev1 257.9-1~deb13u1 N/A
CVE-2025-69720 ⚠️ HIGH ncurses-base 6.5+20250216-2 N/A
CVE-2025-69720 ⚠️ HIGH 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 ⚠️ High 🟡 Medium 🔵 Low Total Unique
0 4 4 0 8 8
🔷 Trivy Scanner (8 findings, 8 unique)
CVE Severity Package Version Fixed
CVE-2026-32280 ⚠️ HIGH stdlib v1.26.1 1.25.9, 1.26.2
CVE-2026-32281 ⚠️ HIGH stdlib v1.26.1 1.25.9, 1.26.2
CVE-2026-32283 ⚠️ HIGH stdlib v1.26.1 1.25.9, 1.26.2
CVE-2026-33810 ⚠️ HIGH 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%.
@github-actions
Copy link
Copy Markdown
Contributor

E2E Test Coverage Report

Action E2E Test Status Notes
ai-summary ⚪ Exception Manual-only action - triggered via dedicated ai-summary.yml workflow with user-supplied PR number
comment-pr ⚪ Exception Utility action - tested indirectly by all scanner and linter actions
get-job-id ⚪ Exception Utility action - tested indirectly by other jobs
linter-dockerfile ✅ Tested
linter-javascript ✅ Tested
linter-json ✅ Tested
linter-python ✅ Tested
linter-terraform ✅ Tested
linter-yaml ✅ Tested
linting-summary ✅ Tested
parse-container-config ✅ Tested
parse-zap-config ✅ Tested
scanner-bandit ✅ Tested
scanner-checkov ✅ Tested
scanner-clamav ✅ Tested
scanner-codeql ✅ Tested
scanner-container ✅ Tested
scanner-container-summary ⚪ Exception Tested as part of scanner-container
scanner-dependency-review ✅ Tested
scanner-gitleaks ✅ Tested
scanner-opengrep ✅ Tested
scanner-osv ✅ Tested
scanner-supply-chain ✅ Tested
scanner-syft ✅ Tested
scanner-trivy-iac ✅ Tested
scanner-zap ✅ Tested
scanner-zap-summary ⚪ Exception Tested as part of scanner-zap
scn-detector ✅ Tested
security-summary ⚪ Exception Tested in test-summary job

Summary

  • Total Actions: 29
  • Covered: 29
  • Missing E2E Tests: 0
  • Coverage: 100%

✅ All Actions Have E2E Coverage

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant