Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0ae5f81
feat(serve): SA scaffold — package, CLI subcommand, healthz route
eFAILution Apr 24, 2026
057550e
feat(serve): SB dashboard landing page with scan resolution
eFAILution Apr 24, 2026
e3307f6
feat(serve): SC findings table route with query-param filters
eFAILution Apr 24, 2026
98e2f10
feat(serve): SD picker — one-level file browser with scan-ready hints
eFAILution Apr 24, 2026
b992369
feat(serve): SE progressive-enhancement filter refresh
eFAILution Apr 24, 2026
c820607
docs(serve): SF quickstart, ADR-017, roadmap and AICaC updates
eFAILution Apr 24, 2026
c00e9a0
fix(serve): follow latest/ symlink when pointed at argus-results parent
eFAILution Apr 24, 2026
dac1a69
fix(serve): picker breadcrumb, mobile table overflow, favicon
eFAILution Apr 24, 2026
7f08fa5
fix(serve): constrain ?scan= and /picker paths to launch root
eFAILution Apr 24, 2026
02b745b
feat(serve): make dashboard cards and rows drill into findings
eFAILution Apr 24, 2026
3a0734e
feat(serve): findings detail disclosure and sortable columns
eFAILution Apr 24, 2026
e64c5d1
feat(serve): UX polish — column auto-hide, hints, headers, loading
eFAILution Apr 24, 2026
b28390d
feat(serve): drop unsafe-inline styles, tighten CSP
eFAILution Apr 24, 2026
481d39e
style(serve): rebrand to match argus.huntridgelabs.com
eFAILution Apr 24, 2026
8b61fb0
feat(serve): export findings as CSV, JSON, Markdown, SARIF
eFAILution Apr 24, 2026
a763539
feat(serve): scan-to-scan diff view with picker multi-select
eFAILution Apr 24, 2026
16a9710
feat(serve): recent-scans dropdown, metadata panel, theme toggle
eFAILution Apr 24, 2026
d80a11b
docs(serve): record Phase 2 completions and scope deferrals in roadmap
eFAILution Apr 24, 2026
3cf7107
fix(serve): guard recent-scans peek against non-dict JSON payloads
eFAILution Apr 24, 2026
29ac674
build: pin httpx[socks] into the ai extra for SOCKS-proxy envs
eFAILution Apr 24, 2026
c0de940
ci: install argus-security[all] before running unit tests
eFAILution Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .ai/architecture.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ components:
"core/exclusions.py": "Path exclusion set (builtins + .gitignore + config + CLI), ``**``-glob matcher"
"core/tool_config.py": "Auto-discovery of per-scanner canonical config files (.bandit, .checkov.yaml, trivy.yaml, osv-scanner.toml, semgrep.yml)"
"core/sbom.py": "SBOM format detection (CycloneDX JSON/XML, SPDX JSON/tag-value, Syft JSON) for ``argus scan --sbom``"
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary. Consumed by argus browse (TUI) and future argus serve (web UI)."
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary. Consumed by argus browse (TUI) and argus serve (web UI)."
"browse/": "Interactive findings TUI (argus browse) — Textual-based, optional extra"
"serve/": "Local read-only web UI (argus serve) — FastAPI + Jinja2 + vanilla JS, 127.0.0.1 only, optional extra"
"scanners/": "Scanner modules implementing Scanner protocol (SCANNER_REGISTRY includes linters via auto-merge)"
"linters/": "Linter modules implementing Scanner protocol (LINTER_REGISTRY auto-merges into SCANNER_REGISTRY)"
"reporters/": "Output reporters (terminal, markdown, sarif, json)"
Expand Down Expand Up @@ -420,8 +421,9 @@ docsite:
"core/exclusions.py": "Path exclusion set (builtins + .gitignore + config + CLI), ``**``-glob matcher"
"core/tool_config.py": "Auto-discovery of per-scanner canonical config files (.bandit, .checkov.yaml, trivy.yaml, osv-scanner.toml, semgrep.yml)"
"core/sbom.py": "SBOM format detection (CycloneDX JSON/XML, SPDX JSON/tag-value, Syft JSON) for ``argus scan --sbom``"
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary. Consumed by argus browse (TUI) and future argus serve (web UI)."
"core/findings_view.py": "Shared UI-free logic for findings display — ViewState, SEVERITY_ORDER, finding_detail_rows, compute_summary. Consumed by argus browse (TUI) and argus serve (web UI)."
"browse/": "Interactive findings TUI (argus browse) — Textual-based, optional extra"
"serve/": "Local read-only web UI (argus serve) — FastAPI + Jinja2 + vanilla JS, 127.0.0.1 only, optional extra"
"scanners/": "Scanner modules implementing Scanner protocol (SCANNER_REGISTRY includes linters via auto-merge)"
"linters/": "Linter modules implementing Scanner protocol (LINTER_REGISTRY auto-merges into SCANNER_REGISTRY)"
"reporters/": "Output reporters (terminal, markdown, sarif, json)"
Expand Down
63 changes: 63 additions & 0 deletions .ai/decisions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -897,3 +897,66 @@ decisions:
with:
fail_on_scanner_failure: 'false'
scan_statuses: '{"...": "..."}'

- id: ADR-017
title: "argus serve — localhost-only SDK-bundled web UI, separate from argus-portal"
status: accepted
date: 2026-04-24
context: |
Owners, managers, and executives want at-a-glance insight into their
product's security posture without digging through CI logs, PR
comments, or learning a terminal UI. argus-portal (separate repo)
sketches a FedRAMP-oriented enterprise dashboard, but productionizing
it requires significant work: SARIF ingest endpoints, GitHub OAuth +
RBAC, MFA, audit trails, Postgres operational burden, Kubernetes
deployment. That is months of scope for the "I just want to look at
one argus-results.json" need.
decision: |
Ship argus serve as a lightweight read-only web UI bundled with the
argus SDK as the ``[serve]`` optional extra. Bound to 127.0.0.1
only, single-user, single-scan scope. Deliberately NOT a replacement
for argus-portal — the portal stays on its own track aimed at
enterprise multi-team compliance orgs.

Technical stakes:
- FastAPI + Jinja2 + vanilla JS (no React or Next.js toolchain,
no bundler, no CDN dependencies).
- Reuses argus.core.findings_view (ViewState, compute_summary,
etc.) — same filter, sort, and summary logic as the TUI. One
source of truth for "high severity" and "this CVE matches."
- Four routes: ``/`` executive dashboard, ``/findings``
filterable table, ``/picker`` one-level file browser,
``/healthz`` liveness.
- CSP + clickjacking headers on every response; Jinja2 autoescape;
no cookies, no sessions, no mutations, so no CSRF handling.
- Picker is explicitly one-level-at-a-time — no recursive walks.

Non-goals (tracked as future roadmap items):
- Live reload on results-file changes.
- Scan-over-scan diff.
- JSON API endpoints (external tools can read argus-results.json
directly today).
- ``--bind`` or ``--basic-auth`` — multi-user network exposure is
argus-portal's territory.
rationale: |
Reuses all the groundwork already shipped for argus browse — the
findings_view module was explicitly built to serve both front-ends.
Going localhost-only dodges the biggest sources of complexity
(auth, sessions, CSRF, multi-user state) while still delivering the
actual user need: non-engineer stakeholders can open a browser and
see their findings. If argus-portal matures separately, it can
consume the same findings_view module to keep per-finding display
consistent across CLI, local web, and enterprise web — no rewrite
needed.
consequences:
positive:
- "Executive-level stakeholders get a first-class surface without the argus-portal operational burden"
- "TUI and web view cannot drift — filter and summary logic is centralized in argus.core.findings_view"
- "Localhost-only default removes an entire class of auth, session, and transport-security footguns"
- "No CDN dependencies means argus serve works offline, on airgapped networks, and in restricted corporate environments"
negative:
- "Users who want a team-visible deployment must set up their own reverse proxy or run argus-portal — documented as an explicit scope choice, not a bug"
- "Secret redaction is not serve-specific; if a finding description contains credentials from a stack trace, they appear in the CLI, TUI, JSON export, and the web UI. Will be tackled globally when the need arises"
related:
- ADR-013 # Python SDK as primary interface
- ADR-016 # silent-failure gating (serve inherits the same resilience posture)
17 changes: 17 additions & 0 deletions .ai/workflows.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ user_workflows:
e to export CSV, d for executive dashboard, ? for help, Ctrl+P for
command palette. Full reference in docs/browse.md.

local_web_dashboard:
description: "Serve findings locally via a browser-based dashboard (argus serve)"
category: triage
when_to_use: "Non-engineer stakeholders (owners, managers, execs) who want at-a-glance insight without a terminal"
sdk_command: "argus serve ./argus-results"
one_liner: "argus serve --open"
requirements:
- "Python 3.11+"
- "argus-security[serve] extra (installs FastAPI + uvicorn + jinja2)"
notes: |
Three routes: `/` executive dashboard, `/findings` filterable
table (URL-shareable filters), `/picker` one-level file browser
to switch scans. Bound to 127.0.0.1 only — no auth, no
sessions, no mutations. For multi-user enterprise deployments
see argus-portal (separate track). Full reference in
docs/serve.md.

fedramp_scn_detection:
description: "FedRAMP Significant Change Notification detection and tracking"
category: compliance
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# Install the package itself with every optional extra so all
# test modules can actually run. Tests for optional features
# (argus browse / argus serve / MCP server / AI classifier)
# guard their imports with pytest.importorskip or module-level
# stubs — without the extras those tests either skip or run
# against fake stubs, and codecov's patch metric flags the
# unexercised test bodies as missing coverage. Installing
# [all] runs the real tests against the real code paths.
pip install -e '.[all]'
pip install pytest-deadfixtures

- name: Run all tests
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@ argus/ # Python SDK package
│ ├── app.py # Textual App, HelpScreen, DashboardScreen, PickerScreens
│ ├── loader.py # argus-results.json → ScanSummary
│ └── export.py # CSV / JSON / Markdown / SARIF writers
├── serve/ # Local read-only web UI (argus serve) — optional extra, 127.0.0.1 only
│ ├── app.py # FastAPI routes: /, /findings, /picker, /healthz
│ ├── templates/ # Jinja2: base, summary (dashboard), findings, picker
│ └── static/ # argus.css + auto-filter.js (vanilla, no framework)
└── tests/ # 20 test files, comprehensive coverage
```

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ For detailed scanner configuration, see [Scanner Reference](docs/scanners.md).
- **Private registry support** - Authenticate to container registries
- **Environment variable expansion** - Dynamic configuration values
- **[Optional AI summary](.github/actions/ai-summary/README.md)** - Generate executive security summaries from scan results using your own AI provider and API key (Copilot, Claude, or Gemini)
- **[Interactive findings TUI](docs/browse.md)** - `argus browse` — keyboard-driven triage browser (`pip install 'argus-security[browse]'`)
- **[Local web UI](docs/serve.md)** - `argus serve` — localhost dashboard for non-engineer stakeholders (`pip install 'argus-security[serve]'`)

## GitHub Enterprise Server (GHES)

Expand Down
103 changes: 75 additions & 28 deletions argus/browse/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from __future__ import annotations

import csv
import io
import json
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -55,39 +56,55 @@ def make_export_path(
# CSV
# ---------------------------------------------------------------------------

def render_csv(findings: Iterable[Finding]) -> str:
"""Return findings as a CSV string.

Pure-in-memory rendering so callers that don't want a file (e.g.
``argus serve`` returning a Response body) can skip the filesystem
entirely. The TUI's ``write_csv`` wraps this for the file path.
"""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(CSV_COLUMNS)
for f in findings:
writer.writerow([
f.severity.value, f.id, f.cve or "", f.scanner or "",
f.metadata.get("package", ""),
f.metadata.get("installed_version", ""),
f.metadata.get("fixed_version", ""),
f.location or "", f.title or "",
f.metadata.get("sbom_source", ""),
])
return buf.getvalue()


def write_csv(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as CSV to ``dest``. Returns the resolved path."""
dest = Path(dest).resolve()
with open(dest, "w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(CSV_COLUMNS)
for f in findings:
writer.writerow([
f.severity.value, f.id, f.cve or "", f.scanner or "",
f.metadata.get("package", ""),
f.metadata.get("installed_version", ""),
f.metadata.get("fixed_version", ""),
f.location or "", f.title or "",
f.metadata.get("sbom_source", ""),
])
dest.write_text(render_csv(findings), encoding="utf-8")
return dest


# ---------------------------------------------------------------------------
# JSON
# ---------------------------------------------------------------------------

def write_json(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as JSON (a list of Finding.to_dict() objects).
def render_json(findings: Iterable[Finding]) -> str:
"""Return findings as a pretty-printed JSON string.

Matches the shape used inside ``argus-results.json`` for per-finding
records, so downstream consumers can pipe the export back through
other argus tooling (e.g. a future ``argus report`` subcommand)
without a shape translation step.
"""
dest = Path(dest).resolve()
payload = [f.to_dict() for f in findings]
dest.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
return json.dumps(payload, indent=2, ensure_ascii=False)


def write_json(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as JSON to ``dest``."""
dest = Path(dest).resolve()
dest.write_text(render_json(findings), encoding="utf-8")
return dest


Expand All @@ -105,18 +122,18 @@ def write_json(findings: Iterable[Finding], dest: Path) -> Path:
}


def write_markdown(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as a Markdown table — paste-ready for tickets / PRs.
def render_markdown(findings: Iterable[Finding], *, now: datetime | None = None) -> str:
"""Return findings as a Markdown table — paste-ready for tickets / PRs.

Pipes in table cells are escaped so a CVE title containing ``|``
doesn't break the row. The column set mirrors the CSV writer's so
the two formats carry the same data.
the two formats carry the same data. ``now`` is injectable for
deterministic test fixtures; production callers pass ``None``.
"""
dest = Path(dest).resolve()
lines: list[str] = [
"# Argus Findings Export",
"",
f"Generated {datetime.now().isoformat(timespec='seconds')}",
f"Generated {(now or datetime.now()).isoformat(timespec='seconds')}",
"",
"| Sev | ID | Scanner | Package | Fix | Location | SBOM | Title |",
"|-----|----|---------|---------|-----|----------|------|-------|",
Expand All @@ -135,7 +152,13 @@ def write_markdown(findings: Iterable[Finding], dest: Path) -> Path:
f"| {f.scanner or '—'} | {pkg_cell} | {fixed} "
f"| `{location}` | {sbom} | {title} |"
)
dest.write_text("\n".join(lines) + "\n", encoding="utf-8")
return "\n".join(lines) + "\n"


def write_markdown(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as a Markdown table to ``dest``."""
dest = Path(dest).resolve()
dest.write_text(render_markdown(findings), encoding="utf-8")
return dest


Expand All @@ -153,9 +176,9 @@ def write_markdown(findings: Iterable[Finding], dest: Path) -> Path:
}


def write_sarif(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as SARIF 2.1.0 — the format GitHub Code Security
and most dashboards consume.
def render_sarif(findings: Iterable[Finding]) -> str:
"""Return findings as a SARIF 2.1.0 JSON string — the format
GitHub Code Security and most dashboards consume.

We emit one ``run`` per scanner so viewers that group-by-run see
the same grouping a user would expect. Rule metadata is minimal
Expand All @@ -164,7 +187,6 @@ def write_sarif(findings: Iterable[Finding], dest: Path) -> Path:
output, which users can still access via the SDK's native sarif
reporter.
"""
dest = Path(dest).resolve()
by_scanner: dict[str, list[Finding]] = {}
for f in findings:
by_scanner.setdefault(f.scanner or "unknown", []).append(f)
Expand Down Expand Up @@ -220,12 +242,20 @@ def write_sarif(findings: Iterable[Finding], dest: Path) -> Path:
"version": "2.1.0",
"runs": runs,
}
dest.write_text(json.dumps(sarif, indent=2), encoding="utf-8")
return json.dumps(sarif, indent=2)


def write_sarif(findings: Iterable[Finding], dest: Path) -> Path:
"""Write findings as SARIF 2.1.0 to ``dest``."""
dest = Path(dest).resolve()
dest.write_text(render_sarif(findings), encoding="utf-8")
return dest


# ---------------------------------------------------------------------------
# Dispatch table — the TUI looks up (fmt -> writer) to pick the right one.
# Dispatch tables — format → (callable, file extension, HTTP content type).
# TUI path uses WRITERS (writes to a Path). Web path uses RENDERERS (returns
# a string) and CONTENT_TYPES to build the HTTP Response.
# ---------------------------------------------------------------------------

WRITERS = {
Expand All @@ -235,6 +265,23 @@ def write_sarif(findings: Iterable[Finding], dest: Path) -> Path:
"sarif": (write_sarif, "sarif"),
}

RENDERERS = {
"csv": (render_csv, "csv"),
"json": (render_json, "json"),
"markdown": (render_markdown, "md"),
"sarif": (render_sarif, "sarif"),
}

# MIME types for the /export HTTP route. text/* when the content is
# safe to display as plain text in a browser; application/json for
# structured payloads where browsers' JSON viewers are useful.
CONTENT_TYPES = {
"csv": "text/csv; charset=utf-8",
"json": "application/json; charset=utf-8",
"markdown": "text/markdown; charset=utf-8",
"sarif": "application/sarif+json; charset=utf-8",
}


def available_formats() -> list[str]:
"""Return the list of supported format keys, sorted."""
Expand Down
Loading
Loading