Free tool Tier a model against OSFI E-23 in about five minutes — seven risk dimensions, the controls your tier requires, and a report you can print. Open it →
osfi-gate

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:

  1. Chain: sequence numbers are contiguous, each prev_entry_hash links to the entry before it, and each entry_hash is correct.
  2. 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.
  3. Signature: cosign verify-blob succeeds on the statement.
  4. 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 on gate-input.json and requires an identical result and identical violations.
  5. Completeness: it re-normalizes raw/* and requires every scanner finding to be present in gate-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:

  1. 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.
  2. 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.
  3. 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

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.

boto3 is an optional extra: pip install 'osfi-gate[s3]'.

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.

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

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.