diff --git a/README.md b/README.md index 5087219..dbd34e6 100644 --- a/README.md +++ b/README.md @@ -379,6 +379,44 @@ bawbel-gate posture --audit-log /home/you/.bawbel/gate.audit.jsonl link on stderr. `posture` and `clear` default to `bawbel-audit.jsonl`, not to the gate's `audit_log`; pass `--audit-log`. +#### Verifying a log you did not write + +`audit verify` needs the chain file and nothing else: no gate config, no manifests, and no +access to the host that wrote the log. Anyone with a copy can replay it. From the file alone +it detects an edited, deleted, inserted, or reordered record, and a record cut off mid-line. +It does not detect records removed from the end of the file, or a log rewritten from the +first record with every hash recomputed, because the chain uses no key. Both change the head, +which is the `hash` of the last record. Record the head somewhere the party who can write the +log cannot rewrite, and compare against it. [SECURITY.md](https://github.com/bawbel/gate/blob/main/SECURITY.md#limits-of-the-audit-chain) +lists what the chain does not show. + +Read the head from the log: + +```bash +bawbel-gate audit tail gate.audit.jsonl -n 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["hash"])' +``` + +The gate also prints the head on stderr when a session ends. Either of these keeps a copy +outside the log: + +- A git note in a repository the gate host cannot rewrite: + `git notes --ref=gate-audit add -m "gate.audit.jsonl " HEAD`, then + `git push origin refs/notes/gate-audit`. Protect that ref against force pushes. +- A daily commit of ` ` to a file in a separate repository, with force pushes + disabled on its branch. + +To check a log later, verify it, then confirm the anchored head is still one of its records. +The log may have grown since, so the anchored head need not be the last record: + +```bash +bawbel-gate audit verify gate.audit.jsonl +grep -Fc '"hash":""' gate.audit.jsonl # 1 if the anchored record is present +``` + +A count of 0 means the file was cut before that record or rewritten. +[`tests/test_audit_third_party_verify.py`](https://github.com/bawbel/gate/blob/main/tests/test_audit_third_party_verify.py) +runs each of these steps. + `bawbel-gate clear --session ID --confirm --audit-log FILE` appends a `SESSION_CLEAR` record. It does not signal a running gate. Taint lives in the gate process and ends with the session, which ends when the client disconnects. @@ -471,7 +509,10 @@ and current hashes; it does not produce a diff. Append-only JSONL. Every record carries `hash = sha256(canonical(record) || prev)`. The chain is verified on demand (`bawbel-gate audit verify`). Argument payloads are hashed, never stored (`args_sha256`). The forensic record is local and -tamper-evident; OTel decision spans (`ops` extra) are an operational feed on top of it. +tamper-evident against changes to single records. Catching removed final records or a full +rewrite needs a head recorded elsewhere: see +[Verifying a log you did not write](https://github.com/bawbel/gate#verifying-a-log-you-did-not-write). +OTel decision spans (`ops` extra) are an operational feed on top of it. ### Embedded console (M6) diff --git a/SECURITY.md b/SECURITY.md index 7f3bfee..2205542 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,11 +23,33 @@ integrity pinning, learning mode, and the Console API (once shipped). - Any way to disable, skip, or narrow the trifecta invariant (DESIGN.md ยง5.2 step 6) through manifest content, configuration, or timing. - Any way to falsify, reorder, or gap the audit chain without detection by - `bawbel-gate audit verify`. + `bawbel-gate audit verify`, other than the limits listed under + "Limits of the audit chain" below. - Any way to make `tools/list` expose a tool that should be denied and hidden. - Argument-guard bypasses that let a secret or over-length payload reach an upstream call undetected. +## Limits of the audit chain + +`bawbel-gate audit verify` shows that, in the file it is given, no record was edited, +deleted, inserted, or reordered without every hash after it being recomputed. It does not +show: + +- **Records removed from the end.** The remaining chain is still valid. A head recorded + outside the log catches this. +- **A log rewritten from the first record.** The chain uses no key, so whoever can write the + file can rebuild every hash and `audit verify` passes. A head recorded outside the log + before the rewrite catches this too. See + [docs/adr/0001-audit-chain-construction.md](docs/adr/0001-audit-chain-construction.md). +- **That the gate was running correctly when the records were written.** A record is what the + gate wrote. It does not show that the decision it records was right, or that the gate was + running between two records. +- **Non-repudiation.** A compromised gate can write any record, correctly chained. Tying + records to one gate needs asymmetric signing, which is not implemented. + +How to record a head, and how to check a log against it, is in the README under +"Verifying a log you did not write". + ## Reporting a vulnerability **Do not open a public issue.** Report privately using either: diff --git a/tests/test_audit_third_party_verify.py b/tests/test_audit_third_party_verify.py new file mode 100644 index 0000000..97def09 --- /dev/null +++ b/tests/test_audit_third_party_verify.py @@ -0,0 +1,236 @@ +"""A third party verifies a chain file they did not write. + +The verifier holds the chain file and nothing else: no gate config, no manifests, no home +directory state, no access to the host that wrote it. These tests pin what +`bawbel-gate audit verify` detects from that file alone, and the two changes it cannot +detect without a chain head recorded somewhere the log writer cannot rewrite +(DESIGN.md 8.6, docs/adr/0001-audit-chain-construction.md). + +README "Verifying a log you did not write" and SECURITY.md "Limits of the audit chain" +state these results. If one of the "not detected" tests starts failing, the verifier got +stronger: update those two documents in the same change. +""" + +import json +import os +import shutil +import subprocess # nosec B404 # noqa: S404 -- runs the CLI of this package in a clean directory +import sys +from pathlib import Path + +import pytest + +from bawbel_gate.audit.writer import AuditWriter + +EVENTS = [ + {"event": "SESSION_START", "session": "s1"}, + {"event": "CALL_DECIDED", "session": "s1", "tool": "get_issue", "effect": "allow"}, + {"event": "CALL_DECIDED", "session": "s1", "tool": "create_pull_request", "effect": "deny"}, + {"event": "CALL_DECIDED", "session": "s1", "tool": "get_file", "effect": "allow"}, + {"event": "SESSION_END", "session": "s1"}, +] + + +def _write_chain(path: Path, events: list[dict]) -> str: + """Write a chain with the real writer and return its head.""" + writer = AuditWriter(path) + for event in events: + writer.append(event) + return writer.head + + +def _cli(args: list[str], cwd: Path, home: Path) -> subprocess.CompletedProcess: + """Run bawbel-gate as a separate process in an empty directory with an empty home.""" + env = {k: v for k, v in os.environ.items() if not k.startswith("BAWBEL")} + env.update(HOME=str(home), USERPROFILE=str(home), XDG_CONFIG_HOME=str(home)) + return subprocess.run( # nosec B603 # noqa: S603 -- fixed argv, no shell + [sys.executable, "-c", "from bawbel_gate.cli import main; main()", *args], + cwd=cwd, env=env, capture_output=True, text=True, timeout=60, + ) + + +@pytest.fixture +def origin(tmp_path): + """The host that wrote the chain. Returns (chain path, head hash).""" + path = tmp_path / "origin" / "gate.audit.jsonl" + head = _write_chain(path, EVENTS) + return path, head + + +@pytest.fixture +def verifier(tmp_path): + """A clean machine: an empty working directory and an empty home directory.""" + cwd = tmp_path / "verifier" + home = tmp_path / "verifier-home" + cwd.mkdir() + home.mkdir() + return cwd, home + + +def _verify(chain: Path, verifier) -> subprocess.CompletedProcess: + cwd, home = verifier + return _cli(["audit", "verify", str(chain)], cwd=cwd, home=home) + + +def _lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() + + +def _handed_over(origin_chain: Path, lines: list[str], verifier) -> Path: + """Put a (possibly altered) copy of the chain on the verifier's machine.""" + copy = verifier[0] / origin_chain.name + copy.write_text("\n".join(lines) + "\n", encoding="utf-8") + return copy + + +def _documented_head(chain: Path, verifier) -> str: + """The README command: take the hash of the last record from `audit tail -n 1`.""" + cwd, home = verifier + tail = _cli(["audit", "tail", str(chain), "-n", "1"], cwd=cwd, home=home) + assert tail.returncode == 0, tail.stderr + return json.loads(tail.stdout)["hash"] + + +def _records_with_hash(chain: Path, head: str) -> int: + """The README comparison: how many records carry the anchored head as their hash.""" + found = subprocess.run( # nosec B603 B607 # noqa: S603 S607 -- fixed argv, no shell + ["grep", "-Fc", f'"hash":"{head}"', str(chain)], + capture_output=True, text=True, timeout=60, + ) + return int(found.stdout.strip()) + + +class TestVerifyFromTheFileAlone: + def test_intact_chain_verifies_on_a_clean_machine(self, origin, verifier): + chain, _ = origin + copy = Path(shutil.copy(chain, verifier[0])) + before = copy.read_bytes() + + result = _verify(copy, verifier) + + assert result.returncode == 0, result.stderr + assert "ok: 5 records, chain intact" in result.stdout + assert copy.read_bytes() == before, "verify must not modify the file" + assert sorted(p.name for p in verifier[0].iterdir()) == [copy.name] + assert list(verifier[1].iterdir()) == [], "verify must not write to the home directory" + + def test_the_writer_host_is_not_needed(self, origin, verifier): + chain, _ = origin + copy = Path(shutil.copy(chain, verifier[0])) + shutil.rmtree(chain.parent) + + assert _verify(copy, verifier).returncode == 0 + + +class TestDetectedFromTheFileAlone: + """Each change below leaves the file with a break that verify reports on its own.""" + + def test_edited_field(self, origin, verifier): + chain, _ = origin + lines = _lines(chain) + lines[2] = lines[2].replace('"effect":"deny"', '"effect":"allow"') + assert lines[2] != _lines(chain)[2] + + result = _verify(_handed_over(chain, lines, verifier), verifier) + + assert result.returncode == 1 + assert "seq 3: hash mismatch" in result.stderr + + def test_deleted_record(self, origin, verifier): + chain, _ = origin + lines = _lines(chain) + del lines[2] + + result = _verify(_handed_over(chain, lines, verifier), verifier) + + assert result.returncode == 1 + assert "seq 4: prev mismatch" in result.stderr + + def test_reordered_records(self, origin, verifier): + chain, _ = origin + lines = _lines(chain) + lines[1], lines[2] = lines[2], lines[1] + + result = _verify(_handed_over(chain, lines, verifier), verifier) + + assert result.returncode == 1 + assert "prev mismatch" in result.stderr + + def test_inserted_record(self, origin, verifier): + chain, _ = origin + lines = _lines(chain) + lines.insert(2, lines[1]) + + result = _verify(_handed_over(chain, lines, verifier), verifier) + + assert result.returncode == 1 + assert "prev mismatch" in result.stderr + + def test_record_cut_off_mid_line(self, origin, verifier): + chain, _ = origin + lines = _lines(chain) + lines[-1] = lines[-1][: len(lines[-1]) // 2] + + result = _verify(_handed_over(chain, lines, verifier), verifier) + + assert result.returncode == 1 + assert "line 5" in result.stderr + + +class TestNotDetectedWithoutAnAnchoredHead: + """The two changes verify passes. Only a head recorded elsewhere exposes them.""" + + def test_final_records_removed(self, origin, verifier): + chain, head = origin + truncated = _handed_over(chain, _lines(chain)[:3], verifier) + + result = _verify(truncated, verifier) + + assert result.returncode == 0, "if this fails, verify got stronger: update the docs" + assert "ok: 3 records, chain intact" in result.stdout + assert _documented_head(truncated, verifier) != head + + def test_log_rewritten_with_every_hash_recomputed(self, origin, verifier): + chain, head = origin + forged_events = [dict(e) for e in EVENTS] + forged_events[2]["effect"] = "allow" + forged = verifier[0] / "forged" / chain.name + forged_head = _write_chain(forged, forged_events) + + result = _verify(forged, verifier) + + assert result.returncode == 0, "if this fails, verify got stronger: update the docs" + assert forged_head != head + + +@pytest.mark.skipif(shutil.which("grep") is None, reason="the documented comparison uses grep") +class TestAnchoredHead: + def test_the_documented_command_yields_the_writer_head(self, origin, verifier): + chain, head = origin + copy = Path(shutil.copy(chain, verifier[0])) + + assert _documented_head(copy, verifier) == head + + def test_anchored_head_is_still_in_a_log_that_has_grown(self, origin, verifier): + chain, anchored = origin + writer = AuditWriter.from_existing(chain) + writer.append({"event": "CALL_DECIDED", "session": "s2", "tool": "get_issue"}) + copy = Path(shutil.copy(chain, verifier[0])) + + assert _verify(copy, verifier).returncode == 0 + assert _documented_head(copy, verifier) != anchored + assert _records_with_hash(copy, anchored) == 1 + + def test_anchor_comparison_exposes_both_undetected_changes(self, origin, verifier): + chain, anchored = origin + + truncated = _handed_over(chain, _lines(chain)[:3], verifier) + assert _documented_head(truncated, verifier) != anchored + assert _records_with_hash(truncated, anchored) == 0 + + forged_events = [dict(e) for e in EVENTS] + forged_events[2]["effect"] = "allow" + forged = verifier[0] / "forged" / chain.name + _write_chain(forged, forged_events) + assert _documented_head(forged, verifier) != anchored + assert _records_with_hash(forged, anchored) == 0