Audit trail
Evidence bundle
Each gate run produces one bundle, archived as <seq>-<sha12>.tar.gz. The archive is deterministic (sorted members, zero mtimes and owners), so rebuilding it from the same files yields the same bytes.
| File | Contents |
|---|---|
context.json |
Subject (repo, SHA, ref, event, PR), CI run (URL, workflow, actor, runner, start/end), tool image digests, osfi-gate commit, policy repo URL, commit and content digest |
gate-input.json |
The exact document OPA evaluated, including now, config, waivers and metadata |
decision.json |
Result, violations, warnings, and per-control status |
summary.md |
Human-readable result |
raw/* |
Scanner output after sanitizing |
manifest.json |
SHA-256 of every file above, plus the chain position (seq, prev_entry_hash) |
statement.json |
in-toto Statement v1 whose subjects are the manifest digest and the git commit, with a predicate summary |
statement.sigstore.json |
cosign bundle (present when signed) |
Sanitizing: before anything is hashed or stored, secret values reported by Gitleaks are replaced everywhere, and every code snippet is replaced by its SHA-256. Evidence storage is meant to be immutable, so a leaked secret there could never be removed.
Ledger
ledger/<owner>__<repo>.jsonl holds one line per bundle:
{seq, bundle_id, repo, sha, ref, event, pr, result, manifest_sha256, statement_sha256,
signing, recorded_at, prev_entry_hash, entry_hash}
entry_hash is the SHA-256 of the canonical JSON of the entry, without entry_hash itself. prev_entry_hash links to the previous entry, and the first entry links to 64 zeros. ci/publish-evidence.sh appends bundles to the osfi-evidence branch and retries when a concurrent run moved the head. Four parallel publishers were tested and produced one contiguous chain.
Verification
git clone --branch osfi-evidence <repo> evidence
osfi-gate verify --evidence-dir evidence --policy-dir <policy checkout> \
--pubkey cosign.pub # key-signed bundles
# or: --certificate-identity-regexp '^https://github.com/bank/osfi-gate/' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com
For every ledger and bundle, verify checks the following:
- Chain: sequence numbers are contiguous, each
prev_entry_hashlinks to the entry before it, and eachentry_hashis correct. - Integrity: every file matches the manifest and there are no extra files. The manifest digest and statement digest match the ledger, and the statement covers the manifest.
- Signature:
cosign verify-blobsucceeds on the statement. - Replay:
- It finds the policy with the recorded content digest, either in the given directory or at the recorded commit via
git archive. - It re-runs OPA ongate-input.jsonand requires an identical result and identical violations. - Completeness: it re-normalizes
raw/*and requires every scanner finding to be present ingate-input.json. A gate that silently dropped a finding fails here.
What each kind of tampering triggers (each row is covered by a test):
| Tampering | Detected by |
|---|---|
| A raw file edited in a bundle | Integrity check |
| A ledger entry edited (for example fail changed to pass) | Chain check (entry_hash) |
| A ledger entry deleted | Chain check (sequence and link) |
| A bundle removed | Bundle not found |
| A decision flipped, with every hash recomputed | Replay |
| A finding dropped from the input, with the decision recomputed | Completeness check (and the signature, if signed) |
| A bundle signed with a different key | Signature check |
Limits
A hash chain proves internal consistency, not authenticity. If someone can rewrite the whole evidence branch and re-sign it, they can forge history — and a test in tests/test_gate_and_evidence.py demonstrates exactly that: a rebuilt chain passes every check on the table above. Defences, in order of strength:
- Signing with a key that engineering can't reach: a KMS key used only by the CI identity, or keyless signing bound to the workflow identity.
- Protecting the evidence branch: only the CI identity may push, with force-push and deletion disabled. On GitHub use a ruleset; on Gitea, a protected branch with a push whitelist.
- An external anchor (
osfi-gate anchor,verify --anchor):
Every other check on this page is one the evidence makes about itself, so a forger who rebuilds the branch satisfies all of them by construction. An anchor is a small record written elsewhere — this repository's ledger stood at sequence N with head H, observed at time T — and verification asks the current ledger to account for every anchor ever made about it.
sh
osfi-gate anchor --evidence-dir .evidence --repo owner/name \
--store git+https://git.internal/compliance/anchors.git#main
osfi-gate verify --evidence-dir .evidence --repo owner/name \
--policy-dir policy --anchor git+https://git.internal/compliance/anchors.git#main
- Every anchor is checked, not the latest one. Truncating the ledger back to sequence 40 and re-signing leaves a head that is a legitimate historical head, so an anchor for 40 still matches. What the forger cannot do is produce sequence 50 again.
- Without
--anchor, the anchor state is reported as not checked, never as passing. A verification that quietly omitted the only external check must not read as a clean one. - An anchor is worth exactly as much as the independence of where it is written. One written with the credentials that can rewrite the evidence is theatre — the forger rewrites both. The tool cannot check that, so it records where each anchor was written and by whom, and reports those instead of a reassuring tick. A
git+store in a repository with different write permissions is the case this is built for; a plain directory buys nothing until it is genuinely write-once (see 4). - The records are not signed: access control to the store is the whole mechanism. Rewrite access to it defeats anchoring outright, so the requirement is not "write-once" but "not yours to write".
- An anchor covers its prefix and nothing after it. A forgery confined to entries published since the last anchor satisfies every anchor there is — so the cadence of the anchor job is the size of the window a forgery can hide in. Run it on a schedule, not once. Signing each record would make an anchor something a forger could not manufacture even with write access; that is #62's subject.
- Anchors do not reach backwards. The record says when a head was observed, and an observation cannot be honestly backdated. Evidence published before anchoring began has the chain behind it and nothing more.
- Keyless signing on GitHub also writes each signature to the public Rekor transparency log, which is a strong anchor — but it publishes the repository and workflow name, so avoid it for sensitive private repos (use
sign: keywith KMS instead). 4. WORM storage (osfi-gate worm):
sh
osfi-gate worm --evidence-dir .evidence --bucket osfi-evidence \
--retention-days 2555 --region ca-central-1
bundles/ and ledger/ map one-to-one onto object keys. Object Lock in COMPLIANCE mode means a version cannot be deleted or overwritten before its retention date by anyone — including the account root, including AWS support. The git branch stays as the working copy CI pushes to; this is the periodic lift into storage, with its own credentials, for the same reason the anchor job is a separate script.
--retention-dayshas no default. How long evidence must be kept is a record-keeping decision, and the tool refuses rather than guessing it.- A bucket without Object Lock is refused, not written to. Object Lock cannot be enabled after creation, so a bucket that was not made for it cannot be made safe — and writing to it anyway would produce objects that merely look protected.
- Versioning preserves history; it does not enforce append-only. Each publish PUTs a new version of the ledger. The old versions are locked and survive, but nothing in S3 requires the new one to be the old one plus a line: someone with write access can put a truncated ledger, and the earlier versions sit there intact, unread by anyone who does not look. So this buys recoverable history, and the recovery has to be an actual check.
- Which is why the bucket's own versions are an anchor (3, above).
osfi_gate.worm.anchors_from_versionsreads the locked prior versions back and turns the head each one recorded into an observation, timestamped by the storage rather than by the reader's clock. A forger who replaces the current ledger cannot touch the versions behind it, and every one of them contradicts the replacement. Unlike a second git repository, the independence does not rest on anyone's access policy. - Object Lock is deletion-delayed, not deletion-proof. Retention expires, and a lapsed bundle is an ordinary object again. That is where the weakness lives, and it is where it belongs.
boto3 is an optional extra: pip install 'osfi-gate[s3]'.
Retention, legal hold, and what immutability forecloses
Retention is a policy decision, not a flag. retention.bundle_days lives in the policy repository beside the SLA and the waiver term, owned and reviewed by second-line risk. osfi-gate worm refuses to write without it, and --retention-days exists only to override it for a deliberate one-off. Because the policy directory is digested and its commit recorded in every bundle, the period that applied when a bundle was written is reachable by the same route that makes its decision replayable — not a second mechanism anyone has to trust separately.
A legal hold is the other axis. It stops deletion until someone releases it rather than until a date, and it binds objects whose retention has already lapsed — which makes it the right instrument for litigation or an examination and the wrong one for record-keeping.
osfi-gate legal-hold --bucket osfi-evidence --repo owner/name \
--reason "OSFI examination 2026-Q4" --matter EXAM-2026-14
osfi-gate legal-hold --bucket osfi-evidence --repo owner/name --list
osfi-gate legal-hold --bucket osfi-evidence --repo owner/name --release \
--reason "examination closed" --matter EXAM-2026-14
Both the hold and the release are written into the bucket under holds/, under retention like everything else: a justification someone could quietly delete afterwards is not a justification. The release is recorded as carefully as the hold, because "somebody decided the matter was over" is the event an auditor will ask about. Those records are operational, not evidence — nobody signed them — which is why they are not under bundles/.
What this forecloses
This is engineering, not legal advice, and the distinction matters: the lawful basis for holding personal information and the period it may be held are decisions for the institution and its counsel. What the tool can state is the consequence, plainly.
- Once a bundle is under COMPLIANCE retention, it cannot be deleted before its date. By anyone. Not the operator, not the account root, not the cloud provider. An erasure request cannot be satisfied for that copy, and no amount of care afterwards changes it.
- A legal hold extends that indefinitely, for as long as nobody releases it.
- Bundles carry author names, email addresses and code snippets (see the regulator-pack README, which says the same thing).
Put together: whatever personal data reaches a bundle is fixed there for the retention period, so the decision that matters is what goes in, not what comes out. That makes minimising it — #61 — a prerequisite rather than a follow-on, and it is the reason the console restricts bundle download to the risk and auditor roles and logs every one. GOVERNANCE mode is the escape hatch for an institution that needs a privileged override, and choosing it is choosing a convention over a control.
Signing keys, and verifying after a rotation
A signature is only worth the verifier's ability to find the right public key years later. Each bundle records key_id — the SHA-256 of the signing key's public half — rather than only key_ref, because a KMS alias resolves to whichever key it points at today: after a rotation, every bundle would claim the new key and none of them were signed by it.
osfi-gate verify --evidence-dir .evidence --repo owner/name --policy-dir policy --keyring ./keys
osfi-gate keyring --evidence-dir .evidence --dir ./keys # which key covers which entries
- Two bundles in one ledger signed by different keys verify in a single pass. That is what a ledger looks like the day after a rotation.
- A recorded key id is answered exactly. If the keyring does not hold it, verification says so instead of trying the other keys and reporting success.
- Rotating a key invalidates nothing, provided the old public key is kept. The private half can be destroyed the moment it stops signing; the public half has to outlive the retention period of the last bundle it signed. Deleting it destroys the institution's ability to prove its own evidence, and nothing undoes that later.
osfi-gate keyringexits non-zero when the ledger names a key nobody holds, so "can we still verify 2026?" is a command rather than an argument. - A keyring is a lookup, not a trust decision: holding a key says nothing about whether it was ever authorised to sign. Trust still comes from where the keyring was obtained.
- Bundles written before
key_idexisted are reported as not recorded rather than lost, and still verify with--pubkey.
The signature proves which pipeline identity produced a bundle. It doesn't prove the runner itself wasn't compromised. Use ephemeral, dedicated runners for the gate job.