Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 21 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,12 +445,29 @@ sees the trail. J1–J4 are the open `BACKLOG.md` evidence entries, scheduled; *
key out of band — a key shipped alongside the thing it signs proves nothing. Stated in the
manifest's own `caveats`, so it travels with the bundle.

- [ ] **J2** `vpcopilot export --verify`. (S, P1) Independent of J1.
- [x] **J2** `vpcopilot export --verify`. (S, P1) — **DONE:** `export.verify_bundle` re-reads a
bundle and checks it against its own manifest; `vpcopilot export --verify <zip> [--pubkey <key>]`
exits non-zero on any problem, so it drops into CI. Verified end to end against a real signed
bundle: clean → OK, tampered member → `MISMATCH audit.log`, smuggled file → `UNLISTED`, no key →
`present-unverified` and still OK.
Re-read a bundle, recompute every member digest against the manifest, check the signature
when present, print pass or fail per member.
- Acceptance: a tampered member reports by name; a bundle with no signature verifies its
digests and says the signature is absent rather than failing; stdlib only, matching the
existing export.
- Acceptance: a tampered member reports by name ✅; a bundle with no signature verifies its
digests and says the signature is absent rather than failing ✅; stdlib only ✅ (the digest half
is pure stdlib — the same check `docs/AUDIT.md` documents by hand; only the optional signature
check shells out to `minisign`, adding no dependency).
- **Four member verdicts, not two.** `ok` / `mismatch` / `missing` / `unlisted`. A file **added**
to a bundle is exactly as suspicious as one altered, and a check over only the listed members
would wave it through.
- **`present-unverified` is a third signature state and NOT a failure.** Without a public key the
signature genuinely cannot be checked, and one shipped inside the bundle proves nothing. A
reviewer without the key must still be able to check digests; reporting "I cannot check this"
the same way as "this is forged" would destroy the distinction that matters most.
- **Console surface is deliberately narrow.** `GET /api/audit-verify` checks
`<out>/audit-bundle.zip` only — a path derived from server state, never from the request. An
endpoint taking a caller-supplied path would be an arbitrary-file reader, and localhost is not
a reason to ship one; verifying a bundle that has already left the machine is the CLI's job,
because that is where the reviewer is.
- **Reconciled:** does **not** depend on J1. Digest verification works on any bundle today —
`manifest.json` already SHA-256s every member and `docs/AUDIT.md` ships a runnable
verification snippet. Signature checking is added when J1 lands.
Expand Down
9 changes: 8 additions & 1 deletion docs/AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,14 @@ multi-run bundle, member names are relative to that run's folder.

### Verifying a bundle after it leaves the machine

Recompute every hash from the zip alone — no vpcopilot install needed:
With vpcopilot to hand, one command does all of this and exits non-zero on any problem:

```sh
vpcopilot export --verify audit-bundle.zip [--pubkey vpcopilot.pub]
```

Without it — which is the case that matters, since the bundle is meant to leave the machine —
recompute every hash from the zip alone:

```sh
python3 - vpcopilot-audit-out-claude-20260726T190411Z.zip <<'PY'
Expand Down
21 changes: 21 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ about that bundle.
Signing is optional at every level. No key, no `minisign` on PATH, or a signer that fails all mean
an unsigned bundle and a successful export — never a failed one.

**Verifying a bundle.** `--verify` re-reads a bundle and checks it against its own manifest:

```sh
vpcopilot export --verify audit-bundle.zip # digests only
vpcopilot export --verify audit-bundle.zip --pubkey vpcopilot.pub # digests + signature
```

It exits non-zero on any problem, so it drops into CI. Four member verdicts, because a file *added*
to a bundle is as suspicious as one altered: `ok`, `mismatch`, `missing` (listed but absent), and
`unlisted` (present but in no manifest).

The signature is reported as one of `absent`, `verified`, `failed`, or **`present-unverified`** —
a signature exists but no public key was supplied, so it could not be checked. That is deliberately
**not** a failure: a reviewer without the key must still be able to check the digests, and reporting
"I cannot check this" the same way as "this is forged" would destroy the distinction that matters
most.

Note the two layers are independent. Tampering with a *member* while leaving the manifest alone
leaves the signature **verified** and fails the digest — it is the chain, not either half, that
catches it.

Every scan also drops a self-contained `out/report.html` (no server, no external assets). In the
console it's on **② Review** and **⚙ Setup** — **Open HTML report ↗** for a new tab, **Download**
for a timestamped copy. Both rebuild it from the current run dir on every open, so you always get
Expand Down
30 changes: 29 additions & 1 deletion src/vpcopilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -545,11 +545,39 @@ def export(
output: str = typer.Option(None, "--output", help="bundle path (default: <out>/audit-bundle.zip)"),
all_runs: bool = typer.Option(False, "--all", help="bundle every run dir on disk, each in its own folder"),
root: str = typer.Option(".", help="where to look for run dirs when --all is used"),
verify: str = typer.Option(None, "--verify", help="check an existing bundle instead of writing one"),
pubkey: str = typer.Option(None, "--pubkey", help="minisign public key, to check the signature too"),
):
"""Export the audit evidence bundle for a run: every change made to a load balancer, the finding
that justified it, the exact XC config pushed, and the pre-change snapshot — with a manifest that
SHA-256s every member. Same bundle the console's Retire step downloads."""
from .export import build_audit_events, write_bundle, write_bundle_all
from .export import build_audit_events, verify_bundle, write_bundle, write_bundle_all

if verify:
r = verify_bundle(verify, pubkey=pubkey, log=lambda m: rprint(f"[dim]{m}[/dim]"))
if r["error"]:
rprint(f"[red]{r['error']}[/red]")
raise typer.Exit(code=1)
t = Table(title=f"verify {verify}")
for c in ["run", "run id", "members", "signature"]:
t.add_column(c)
sig_style = {"verified": "green", "failed": "red", "absent": "dim",
"present-unverified": "yellow"}
for run in r["runs"]:
st = sig_style.get(run["signature"], "")
t.add_row(run["run"], run["run_id"] or "—", str(run["members"]),
f"[{st}]{run['signature']}[/{st}]" if st else run["signature"])
rprint(t)
for pb in r["problems"]:
rprint(f" [red]{pb['problem'].upper()}[/red] {pb['run']}{pb['member']} — {pb['detail']}")
if r["ok"]:
rprint(f"[green]OK[/green] — {r['members_checked']} member digest(s) match the manifest")
if any(run["signature"] == "present-unverified" for run in r["runs"]):
rprint("[yellow]note:[/yellow] a signature is present but was not checked — "
"pass --pubkey with the signer's key, obtained out of band")
return
rprint(f"[red]FAILED[/red] — {len(r['problems'])} problem(s)")
raise typer.Exit(code=1)

if all_runs:
path = write_bundle_all(root, output)
Expand Down
17 changes: 17 additions & 0 deletions src/vpcopilot/console/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,23 @@ def runs():
return {"current": str(OUT), "runs": find_runs(".")}


@app.get("/api/audit-verify")
def audit_verify():
"""J2: check the bundle this run last wrote (`<out>/audit-bundle.zip`) against its own manifest.

The path comes from server state, never from the request. A verify endpoint that took a
caller-supplied path would be an arbitrary-file reader, and localhost is not a reason to ship
one. Verifying a bundle that has already left the machine is the CLI's job — that is where the
reviewer is."""
from ..export import verify_bundle
p = OUT / "audit-bundle.zip"
if not p.exists():
return {"bundle": str(p), "exists": False,
"hint": "run `vpcopilot export` (or the Retire step's download) first"}
return {"bundle": str(p), "exists": True,
**verify_bundle(str(p), pubkey=os.environ.get("VPCOPILOT_MINISIGN_PUBKEY"))}


@app.get("/api/audit-export")
def audit_export(scope: str = "run"):
"""F3: download the evidence bundle — `scope=run` for the run the console is reading, `scope=all`
Expand Down
79 changes: 78 additions & 1 deletion src/vpcopilot/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from typing import Callable

from . import __version__, audit, ledger, runmeta
from .sign import sign_bytes
from .sign import sign_bytes, verify_bytes


def _quiet(_msg: str) -> None:
Expand Down Expand Up @@ -287,3 +287,80 @@ def write_bundle_all(root: str = ".", output: str | None = None, *, log: Callabl
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bundle_all_bytes(root, log=log))
return str(path)


# ---------------- J2: verify a bundle after it has left the machine ----------------
def verify_bundle(path: str, *, pubkey: str | None = None, log: Callable = _quiet) -> dict:
"""Re-read a bundle and check it against its own manifest.

Read-only and stdlib-only for the digest half — the same check `docs/AUDIT.md` documents by
hand, so a reviewer with neither this tool nor a key can still do it. Handles both layouts by
finding every `manifest.json` in the archive, so an `--all` bundle verifies run by run.

Four member verdicts, not two: a file **added** to a bundle is exactly as suspicious as one
altered, and a check over only the listed members would wave it through."""
problems: list[dict] = []
runs: list[dict] = []
checked = 0
try:
z = zipfile.ZipFile(path)
except Exception as e: # noqa: BLE001 — a corrupt or missing file is a finding, not a crash
return {"ok": False, "error": f"cannot read {path}: {e}", "problems": [], "runs": [],
"members_checked": 0}
with z:
bad_crc = z.testzip()
if bad_crc:
return {"ok": False, "error": f"corrupt member (CRC): {bad_crc}", "problems": [],
"runs": [], "members_checked": 0}
names = set(z.namelist())
manifests = sorted(n for n in names if n.endswith("manifest.json"))
if not manifests:
return {"ok": False, "error": "no manifest.json in this archive — not a vpcopilot bundle",
"problems": [], "runs": [], "members_checked": 0}

accounted = {"index.json"}
for mpath in manifests:
prefix = mpath[: -len("manifest.json")]
accounted.add(mpath)
try:
manifest = json.loads(z.read(mpath))
except json.JSONDecodeError as e:
problems.append({"run": prefix, "member": mpath, "problem": "unreadable-manifest",
"detail": str(e)})
continue
members = manifest.get("members") or {}
for name, meta in sorted(members.items()):
full = prefix + name
accounted.add(full)
if full not in names:
problems.append({"run": prefix, "member": name, "problem": "missing",
"detail": "listed in the manifest, absent from the archive"})
continue
blob = z.read(full)
checked += 1
got = hashlib.sha256(blob).hexdigest()
if got != meta.get("sha256") or len(blob) != meta.get("bytes"):
problems.append({"run": prefix, "member": name, "problem": "mismatch",
"detail": f"sha256 {got[:16]}… != {str(meta.get('sha256'))[:16]}…"})

sigpath = prefix + "manifest.json.minisig"
accounted.add(sigpath)
if sigpath in names:
state = verify_bytes(z.read(mpath), z.read(sigpath), pubkey=pubkey, log=log)
if state == "failed":
problems.append({"run": prefix, "member": "manifest.json.minisig",
"problem": "signature", "detail": "signature did not verify"})
else:
state = "absent"
runs.append({"run": prefix or "./", "signature": state,
"members": len(members),
"run_id": (manifest.get("run") or {}).get("run_id", "")})

for extra in sorted(names - accounted):
if extra.endswith("/"):
continue
problems.append({"run": "", "member": extra, "problem": "unlisted",
"detail": "present in the archive but not listed in any manifest"})

return {"ok": not problems, "error": None, "problems": problems, "runs": runs,
"members_checked": checked}
43 changes: 43 additions & 0 deletions src/vpcopilot/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,46 @@ def sign_bytes(data: bytes, *, log: Callable = print, comment: str = "") -> byte
return None
log(" signed manifest.json (detached minisign signature)")
return sig.read_bytes()


PUBKEY_ENV = "VPCOPILOT_MINISIGN_PUBKEY"


def verify_bytes(data: bytes, sig: bytes, *, pubkey: str | None = None,
log: Callable = print) -> str:
"""Check a detached signature over `data`. Returns one of:

`verified` the signature checks out against `pubkey`
`failed` it does not — treat the bundle as untrustworthy
`present-unverified` a signature exists but we have no public key, or no minisign to run

`present-unverified` is deliberately NOT a failure. A reviewer without the key must still be
able to check the member digests, and reporting "I cannot check this" the same way as "this is
forged" would destroy the distinction that matters most."""
key = (pubkey or os.environ.get(PUBKEY_ENV) or "").strip()
if not key:
log(" signature present, but no public key supplied — digests checked, signature not")
return "present-unverified"
if not Path(key).is_file():
log(f" signature present, but no public key at {key} — signature not checked")
return "present-unverified"
binary = _binary()
if not binary:
log(" signature present, but `minisign` is not on PATH — signature not checked")
return "present-unverified"

with tempfile.TemporaryDirectory() as td:
src, sigf = Path(td) / "manifest.json", Path(td) / "manifest.json.minisig"
src.write_bytes(data)
sigf.write_bytes(sig)
try:
r = subprocess.run([binary, "-V", "-p", key, "-m", str(src), "-x", str(sigf)],
capture_output=True, text=True, timeout=30)
except Exception as e: # noqa: BLE001
log(f" signature present, but minisign could not run: {e} — signature not checked")
return "present-unverified"
if r.returncode == 0:
return "verified"
detail = (r.stderr or r.stdout or "").strip().splitlines()
log(f" ⚠ signature FAILED: {detail[-1] if detail else 'exit ' + str(r.returncode)}")
return "failed"
44 changes: 44 additions & 0 deletions tests/test_console_audit_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,47 @@ def test_runs_endpoint_lists_what_can_be_exported(tmp_path, monkeypatch):
names = {Path(r).name for r in body["runs"]}
assert "out-a" in names and "out-empty" not in names
assert body["current"] == str(tmp_path / "out-a")


# ---- J2: verifying the bundle this run wrote ----
def test_verify_endpoint_checks_the_runs_own_bundle(tmp_path, monkeypatch):
from vpcopilot import export
_seed(tmp_path)
export.write_bundle(str(tmp_path))
monkeypatch.setattr(A, "OUT", tmp_path)
body = _client().get("/api/audit-verify").json()
assert body["exists"] is True and body["ok"] is True
assert body["members_checked"] > 0 and body["runs"][0]["signature"] == "absent"


def test_verify_endpoint_reports_a_tampered_member(tmp_path, monkeypatch):
import io
import zipfile
from vpcopilot import export
_seed(tmp_path)
p = export.write_bundle(str(tmp_path))
src = zipfile.ZipFile(p)
items = [(n, src.read(n)) for n in src.namelist()]
src.close()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for n, d in items:
z.writestr(n, d + b"x" if n == "audit.log" else d)
open(p, "wb").write(buf.getvalue())
monkeypatch.setattr(A, "OUT", tmp_path)
body = _client().get("/api/audit-verify").json()
assert body["ok"] is False
assert any(x["member"] == "audit.log" for x in body["problems"])


def test_verify_endpoint_is_a_hint_not_an_error_before_any_export(tmp_path, monkeypatch):
monkeypatch.setattr(A, "OUT", tmp_path / "never")
body = _client().get("/api/audit-verify").json()
assert body["exists"] is False and "hint" in body


def test_verify_endpoint_takes_no_path_from_the_caller():
"""A verify endpoint accepting a caller-supplied path would be an arbitrary-file reader;
localhost is not a reason to ship one."""
import inspect
assert not inspect.signature(A.audit_verify).parameters
Loading
Loading