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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ GITHUB_TOKEN= # repo-scoped PAT for opening PRs (or use `gh auth login`)
# VPCOPILOT_ACTOR=jane.doe # audit-log attribution — default: the OS user; set it in CI
# VPCOPILOT_SIM_THRESHOLD=0.01 # would-block rate that flags a policy in `vpcopilot simulate`
# VPCOPILOT_SIM_LOGS=traffic.jsonl # traffic sample the refiner also checks each candidate against
# VPCOPILOT_MINISIGN_KEY=~/.minisign/vpcopilot.key # sign exported bundles (unencrypted key: minisign -G -W)
# VPCOPILOT_MINISIGN_BIN= # path to minisign, if not on PATH
# --- Validation auth (auth-protected targets: let the probe log in so it can demonstrate the exploit) ---
# VPCOPILOT_PROBE_USER= # username the validation probe logs in with (cookie or token app)
# VPCOPILOT_PROBE_PASS= # its password
Expand Down
26 changes: 22 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,17 +415,35 @@ The export is internally consistent: `manifest.json` SHA-256s every member. Noth
`generated_on` (`export.py:165`), but as unauthenticated strings — and nothing outside the machine
sees the trail. J1–J4 are the open `BACKLOG.md` evidence entries, scheduled; **J5 is new**.

- [ ] **J1** Sign the evidence bundle. (S, P1)
- [x] **J1** Sign the evidence bundle. (S, P1) — **DONE:** `sign.py` shells out to `minisign` and
drops `manifest.json.minisig` beside the manifest in every bundle (each run's manifest in an
`--all` bundle). No new dependency, so `export.py` stays stdlib-only. Optional at every level: no
key, no binary, an unreadable key or a failing signer each log one line and export unsigned.
A detached signature beside the manifest, making a bundle attributable after it leaves the
machine. **DECIDED 2026-07-27: minisign.** One small dependency, one keypair, a detached
`manifest.json.minisig`, and verification is a single command a reviewer runs without this
toolchain. GPG drags in keyring assumptions that fail badly in CI; sigstore (OIDC flow, bundle
format) is the more defensible answer but is **not S** — raise it as its own item if wanted.
The signature attests **who exported this bundle**, not that the audit log it contains is
truthful; `docs/USAGE.md` has to say exactly that.
- Acceptance: signing is optional and its absence never fails an export; the signature
covers the manifest digest; `docs/USAGE.md` states plainly what the signature does and
does not attest.
- Acceptance: signing is optional and its absence never fails an export ✅; the signature
covers the manifest digest ✅ (it signs the exact manifest bytes that ship, and the manifest
SHA-256s every member, so coverage is transitive); `docs/USAGE.md` states plainly what the
signature does and does not attest ✅.
- **No passphrase handling, by design.** The key must be unencrypted (`minisign -G -W`). A signing
passphrase is a credential this tool has no business holding; requiring a key managed by
whatever already manages your secrets is the honest trade, and it is documented rather than
discovered at the prompt.
- **Round-trip verified against minisign 0.12**, not just a stub: keygen → export → verify
("Signature and comment signature verified"); a tampered manifest fails; a tampered *member*
is caught by the manifest's own SHA-256 while the signature stays valid — the chain is what
makes that digest trustworthy; a wrong public key fails on key-id mismatch.
- **The real run found a leak a stub could not.** The signed trusted comment embedded the
exporter's absolute filesystem path. It now carries the tool version and `run_id` — the join
key that already identifies the run — and a test pins that no local path appears in it.
- **The signature is not self-verifying from inside the bundle.** The reviewer needs the public
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.
Re-read a bundle, recompute every member digest against the manifest, check the signature
Expand Down
3 changes: 3 additions & 0 deletions docs/AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ zip can never imply more coverage than it has:

### Honest limits

- A bundle can be **signed** (`VPCOPILOT_MINISIGN_KEY`, see `docs/USAGE.md`), which binds it to
the holder of a key. That is a statement about *who exported it*, not about whether the log is
true — everything below still applies to a signed bundle.
- The log is written by the same process that makes the change, to a local file. It is **not
tamper-evident** — anyone who can write the out dir can edit `audit.log`. The manifest's SHA-256s
prove a bundle was not altered *after export*; they say nothing about the authenticity of the log
Expand Down
27 changes: 27 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ nothing here touches XC or GitHub.
Dry runs are not in it: nothing changed, so nothing is logged. The bundle is evidence for a human
reviewer, not a compliance certification. Full reference: **[AUDIT.md](AUDIT.md)**.

**Signing a bundle (optional).** Point `VPCOPILOT_MINISIGN_KEY` at an *unencrypted* minisign secret
key and every export gains `manifest.json.minisig` beside the manifest:

```sh
minisign -G -W -s ~/.minisign/vpcopilot.key # -W = no passphrase; this tool never holds one
export VPCOPILOT_MINISIGN_KEY=~/.minisign/vpcopilot.key
vpcopilot export --out out
```

A reviewer verifies with your **public** key:

```sh
unzip -o audit-bundle.zip manifest.json manifest.json.minisig
minisign -V -p vpcopilot.pub -m manifest.json
```

What that signature **does** attest: this manifest was signed by the holder of that key, and — since
the manifest SHA-256s every member — that no file in the bundle changed after it was signed.

What it **does not** attest: that the audit log inside is truthful. The log is written by the same
process that made the changes, to a local file; a signature proves who exported it, not that what it
says happened. And get the public key **out of band** — a key shipped inside a bundle proves nothing
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.

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
41 changes: 31 additions & 10 deletions src/vpcopilot/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@
import zipfile
from pathlib import Path

from typing import Callable

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


def _quiet(_msg: str) -> None:
"""Default log sink — the export is a library call first, a command second."""

# What each action DID, for a reviewer scanning a column rather than reading action names.
CATEGORY = {
Expand Down Expand Up @@ -175,6 +182,10 @@ def build_manifest(out_dir: str = "out", *, members: dict | None = None) -> dict
"apply; a CLI-driven run has none.",
"Entries written by older builds may lack finding_id / namespace / actor — those cells are "
"blank rather than inferred.",
"If manifest.json.minisig is present it signs THIS manifest, which SHA-256s every "
"member — so it attests who exported the bundle, not that the audit log inside is "
"truthful. Verify it with the signer's public key obtained OUT OF BAND; a key shipped "
"inside a bundle proves nothing about that bundle.",
"simulation.json, when present, describes ONE recorded sample replayed through a spare "
"load balancer — not production traffic in general. Its records are redacted at ingest "
"(see `redacted`); read the window and record count with the rate.",
Expand All @@ -183,23 +194,23 @@ def build_manifest(out_dir: str = "out", *, members: dict | None = None) -> dict
}


def write_bundle(out_dir: str = "out", output: str | None = None) -> str:
def write_bundle(out_dir: str = "out", output: str | None = None, *, log: Callable = _quiet) -> str:
"""Zip the run's evidence to `output` (default `<out>/audit-bundle.zip`) and return the path."""
path = Path(output) if output else Path(out_dir) / "audit-bundle.zip"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bundle_bytes(out_dir))
path.write_bytes(bundle_bytes(out_dir, log=log))
return str(path)


def bundle_bytes(out_dir: str = "out", *, prefix: str = "") -> bytes:
def bundle_bytes(out_dir: str = "out", *, prefix: str = "", log: Callable = _quiet) -> bytes:
"""The evidence bundle as bytes, so the console can stream it without touching disk."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
_add_run(z, out_dir, prefix)
_add_run(z, out_dir, prefix, log=log)
return buf.getvalue()


def _add_run(z: zipfile.ZipFile, out_dir: str, prefix: str = "") -> dict:
def _add_run(z: zipfile.ZipFile, out_dir: str, prefix: str = "", *, log: Callable = _quiet) -> dict:
"""Write one run into an open archive under `prefix`, returning its manifest (so a multi-run
bundle can index what it contains)."""
out = Path(out_dir)
Expand All @@ -223,7 +234,17 @@ def add(name: str, data: bytes) -> None:
add(f"{sub}/{p.name}", p.read_bytes())

manifest = build_manifest(out_dir, members=members)
z.writestr(f"{prefix}manifest.json", json.dumps(manifest, indent=2))
# Sign the EXACT bytes that ship: signing a different serialization would make every
# verification fail, or pass against something the reviewer never received.
blob = json.dumps(manifest, indent=2).encode()
z.writestr(f"{prefix}manifest.json", blob)
# The trusted comment is SIGNED and travels with the bundle, so it carries the run identity —
# not the exporter's filesystem path, which a real round-trip showed it was leaking.
run_id = (manifest.get("run") or {}).get("run_id") or "unknown-run"
sig = sign_bytes(blob, log=log,
comment=f"vpcopilot {__version__} evidence bundle · run {run_id}")
if sig:
z.writestr(f"{prefix}manifest.json.minisig", sig)
return manifest


Expand All @@ -238,7 +259,7 @@ def find_runs(root: str = ".") -> list[str]:
return [str(p) for p in cands if (p / "audit.log").exists() or (p / "findings.json").exists()]


def bundle_all_bytes(root: str = ".") -> bytes:
def bundle_all_bytes(root: str = ".", *, log: Callable = _quiet) -> bytes:
"""Every run on disk in one archive, each under its own folder, with a top-level index. For the
case an auditor asks for "everything", not one engagement."""
runs = find_runs(root)
Expand All @@ -251,7 +272,7 @@ def bundle_all_bytes(root: str = ".") -> bytes:
except ValueError:
rel = Path(r)
folder = str(rel).replace("/", "-").replace("\\", "-").strip("-") or "run"
m = _add_run(z, r, prefix=f"{folder}/")
m = _add_run(z, r, prefix=f"{folder}/", log=log)
index.append({"out_dir": str(r), "folder": folder, "events": m["events"],
"run_id": (m.get("run") or {}).get("run_id", "")})
z.writestr("index.json", json.dumps({
Expand All @@ -261,8 +282,8 @@ def bundle_all_bytes(root: str = ".") -> bytes:
return buf.getvalue()


def write_bundle_all(root: str = ".", output: str | None = None) -> str:
def write_bundle_all(root: str = ".", output: str | None = None, *, log: Callable = _quiet) -> str:
path = Path(output) if output else Path(root) / "audit-bundle-all.zip"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(bundle_all_bytes(root))
path.write_bytes(bundle_all_bytes(root, log=log))
return str(path)
79 changes: 79 additions & 0 deletions src/vpcopilot/sign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""J1 — a detached minisign signature over an evidence bundle's manifest.

`manifest.json` already SHA-256s every member, so tampering with a *member* is detectable from
inside the bundle. What it cannot do is bind the bundle to whoever produced it: `generated_by` and
`generated_on` are unauthenticated strings anyone can edit. A detached signature over the manifest
closes that, and covers every member transitively through their digests.

**Signing is optional at every level.** No key configured, no `minisign` on PATH, an unreadable key,
a signer that exits non-zero — each means no signature and a successful export. An evidence bundle
that refuses to be produced because a signing key is missing is worse than an unsigned one.

**No new dependency.** This shells out to the `minisign` binary rather than pulling in a crypto
library, which keeps `export.py` stdlib-only and means a reviewer verifies with the same one-line
command whether or not they have this toolchain installed.

**No passphrase handling.** The key must be an unencrypted minisign key (`minisign -G -W`). A
signing passphrase is a credential this tool has no business holding; requiring an unencrypted key
managed by whatever already manages your secrets is the honest trade, and it is documented rather
than discovered."""
from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Callable

KEY_ENV = "VPCOPILOT_MINISIGN_KEY"
BIN_ENV = "VPCOPILOT_MINISIGN_BIN"


def configured() -> bool:
"""Whether the operator asked for signing at all — distinct from whether it will succeed."""
return bool((os.environ.get(KEY_ENV) or "").strip())


def _binary() -> str | None:
explicit = (os.environ.get(BIN_ENV) or "").strip()
if explicit:
return explicit if Path(explicit).is_file() and os.access(explicit, os.X_OK) else None
return shutil.which("minisign")


def sign_bytes(data: bytes, *, log: Callable = print, comment: str = "") -> bytes | None:
"""Detached signature over `data`, or None when signing is unconfigured or unavailable.

Every failure path returns None and logs one line. Callers must treat None as "unsigned",
never as an error."""
if not configured():
return None
key = os.environ[KEY_ENV].strip()
if not Path(key).is_file():
log(f" ⚠ signing skipped — no minisign key at {key} (bundle exported unsigned)")
return None
binary = _binary()
if not binary:
log(" ⚠ signing skipped — `minisign` not found on PATH "
f"(set {BIN_ENV}, or install it; bundle exported unsigned)")
return None

with tempfile.TemporaryDirectory() as td:
src, sig = Path(td) / "manifest.json", Path(td) / "manifest.json.minisig"
src.write_bytes(data)
cmd = [binary, "-S", "-s", key, "-m", str(src), "-x", str(sig)]
if comment:
cmd += ["-t", comment]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except Exception as e: # noqa: BLE001 — a broken signer must never fail an export
log(f" ⚠ signing skipped — could not run minisign: {e} (bundle exported unsigned)")
return None
if r.returncode != 0 or not sig.exists():
detail = (r.stderr or r.stdout or "").strip().splitlines()
why = detail[-1] if detail else f"exit {r.returncode}"
log(f" ⚠ signing skipped — minisign failed: {why} (bundle exported unsigned)")
return None
log(" signed manifest.json (detached minisign signature)")
return sig.read_bytes()
Loading
Loading