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

Operations

Repositories

Repo Owner Notes
osfi-gate Platform security This code. App repos run it at a pinned commit.
osfi-policy Second-line risk The contents of policy/. main is protected with a required CODEOWNERS review from risk (templates/policy-repo/CODEOWNERS).
App repos Engineering Workflow, waivers/, model-inventory.yaml, CODEOWNERS (templates/app-repo/)

Onboarding a GitHub repository

  1. Copy ci/examples/github-caller.yml to .github/workflows/osfi.yml and pin uses: and gate-ref to one osfi-gate commit.
  2. Add a branch ruleset on main: - Require a PR with 2 approvals. - Dismiss stale approvals. - Require signed commits. - Block force pushes. - Require the osfi / gate status check.
  3. Add a ruleset on osfi-evidence: only GitHub Actions may push, and deletion and force-push are blocked.
  4. Choose signing: - sign: keyless uses the OIDC identity and is logged in Rekor (see the privacy note in audit-trail.md). - sign: key with cosign-key-ref: awskms://... also needs AWS credentials in the job. Add an OIDC role step before publishing.
  5. Add OSFI_POLICY_TOKEN, a read-only token for the policy repo.
  6. Code-scanning upload requires GitHub Advanced Security on private repos. Without it, the step is skipped and the summary still appears in the job.

Onboarding a Gitea repository

  1. Copy ci/gitea/osfi-gate.yml to .gitea/workflows/osfi-gate.yml.
  2. Set these variables: OSFI_GATE_REPO, OSFI_GATE_REF (a commit SHA), OSFI_POLICY_REPO and OSFI_POLICY_REF. Optionally set OSFI_SIGN and OSFI_COSIGN_KEY_REF.
  3. Set these secrets:
Secret Purpose
OSFI_POLICY_TOKEN Reads the gate and policy repos
OSFI_API_TOKEN Reads repos and writes issue comments. Repo admin rights let it read the full branch-protection rule; without them the gate reads the branch summary.
COSIGN_PRIVATE_KEY, COSIGN_PASSWORD Signing, or use a KMS URI in OSFI_COSIGN_KEY_REF
  1. Protect main: - Required approvals: 2. - Dismiss stale approvals, require signed commits, disable push. - Add the gate as a required status check.
  2. If signed commits are required, configure [repository.signing] on the instance. Otherwise Gitea refuses merges with "wont sign: nokey". - Use MERGES = pubkey, commitssigned unless you enforce 2FA. - Fast-forward merges keep the author's signed commits, but see the schedule caveat below: delete the source branch on merge.
  3. Protect osfi-evidence: allow push only for the Actions user, and disable force-push.
  4. Runner requirements: - The runner needs Docker and outbound access to the image registries, github.com (actions, conftest, cosign) and PyPI. - Gitea older than 1.25 rejects upload-artifact@v4, which is why the workflow pins v3.2.1-node20. - Gitea has no SARIF viewer. The gate summary is posted as a single PR comment and updated in place.

Waivers

See templates/app-repo/waivers/README.md. The workflow for a waiver:

  1. Engineer: copies the fp= value from the gate output and opens a PR adding waivers/<id>.yaml.
  2. Risk: a member reviews and approves the PR. CODEOWNERS enforces this on the platform, and WV-01 enforces it in the gate.
  3. Expiry: the waiver applies until 00:00 UTC on its expiry date. The gate warns 14 days before, and after that the finding blocks again.

Changing policy

Refreshing Semgrep registry rules

scripts/vendor-semgrep-rules.sh policy            # or: policy p/default p/owasp-top-ten
git -C policy diff --stat                          # review, then open a policy PR

Run the fixtures before merging. A rule that was added or changed upstream can produce new findings, or change an existing finding's fingerprint, which would invalidate its waiver.

Scheduled rescans

Both workflows rescan the default branch daily at 06:17 UTC. A scheduled run:

Gitea caveat: a scheduled run uses the workflow file from the commit that registered the schedule. Gitea 1.24.7 re-registers schedules on a push to the default branch, but only if git name-rev resolves the pushed commit to that branch (services/actions/notifier_helper.go, handleSchedules).

A failing scheduled run is the signal that something on main has fallen out of SLA. Route it to the owning team, for example with a notification on workflow failure.

Turning on AI usage controls

  1. Discover first. Run the gate as-is. Undeclared AI usage that already exists is reported as a warning and recorded in the evidence bundle, so the first run tells you what is out there without blocking anyone.
  2. Register what you keep. Add an ai_usage: entry per call site to each repository's model-inventory.yaml (see docs/controls.md), with the model-risk ID and validation record.
  3. Fix or waive the rest. A provider outside the approved hosting classes fails MR-03. Either move the call to an approved provider or have risk approve a waiver with an expiry, like any other finding.
  4. Tighten. Once a repository is clean, set gate.ai.enforce: all in the policy repo so pre-existing usage blocks too.
  5. Keep the lists current. gate.ai.providers, approved_hosting, floating_model_patterns and dependency_packages are risk-owned. Review them when a provider publishes new aliases or regions.

Upgrading scanner or action pins

  1. Pull the new image and read its digest: docker inspect --format '{{index .RepoDigests 0}}'.
  2. Update osfi_gate/tools.py and both workflow files. Then run osfi-gate check-pins .github/workflows/*.yml ci/gitea/osfi-gate.yml.
  3. Re-run the fixtures (osfi-gate run ...) and refresh tests/data/raw-* if the output shape changed. Watch for changed rule IDs, since they change fingerprints and therefore waivers.
  4. To change Python dependencies, regenerate ci/requirements.lock with pip-compile --generate-hashes under Python 3.14, the version CI uses.

When the gate fails

Enrolment across the estate

Every other view in this system starts from the enrolment list, so none of them can see a repository nobody enrolled. osfi-console enrolment asks the forge instead:

osfi-console --config console.yaml enrolment          # exits 1 when something is unenrolled
osfi-console --config console.yaml enrolment --deep   # also: does it even have the workflow?

Configure the organisations to enumerate:

organisations:
  - platform: github
    api_url: https://git.internal/api/v3
    name: northwind
    token: ${FORGE_READ_TOKEN}
    identity: svc-osfi@northwind     # whose visibility produced the answer

The count is bounded by what the token can see, and the boundary travels with it into coverage.not_enrolled, the health page and the regulator pack's README. A repository in an organisation nobody listed, or one this token cannot see, is exactly as invisible as it was before — and an organisation that could not be read is excluded from the count entirely rather than being treated as complete, because shrinking the denominator would make coverage look better than it is.

On GitHub, org rulesets or required workflows can enforce enrolment directly and this becomes a cross-check. Gitea has no equivalent, which is why a scheduled job is the answer there.

The policy a decision was made under

osfi-gate bundle refuses to write a bundle when the policy directory has uncommitted changes. Verification finds the policy for replay either in the working tree or at the commit the bundle recorded; an uncommitted policy is in neither once the runner is destroyed, so such a bundle is permanently unverifiable — and until now the only thing that said so was a line on stderr in a CI log.

--allow-dirty-policy exists for evidence that is genuinely not meant to be verified, and takes saying so.

This is also what makes splitting the policy into its own repository worth doing: application repositories pin a commit, that commit is recorded in every bundle, and a decision made under anything else does not produce evidence at all.

Running the gate from an image

CI checks out osfi-gate's source and pip-installs it from a hash-locked requirements file. That works, and it means every run resolves an artefact rather than fetching one that was built, reviewed and signed.

ci/build-image.sh registry.internal/osfi-gate --push --sign   # COSIGN_KEY=file-or-kms-uri

It prints the digest and refuses to build from an uncommitted tree — the same rule osfi-gate bundle enforces, for the same reason: an artefact stamped with a commit that does not describe it is worse than one stamped with nothing.

The tag is a convenience; the digest is the identity. A tag can be moved, and one whose contents change between builds is worse than no tag at all. Pass the digest as gate-image and every bundle the run produces records it, so an examiner can tell which build decided a run rather than inferring it from a version string. The image bakes its own source commit for the same reason: a container has no .git, and the provenance a checkout gives for free would otherwise become null the moment CI moved to the image.

Switching the job to execute from the image — rather than merely recording it — means running the gate steps in a container that also needs the Docker socket for the scanners. That is a change to your runner topology, so it is yours to make; the image, its identity and the pinning check are here.

osfi-gate check-pins now fails on any image a workflow references that is not pinned by digest, not only the scanners whose digests it knows. It cannot know the digest of an image you publish, but "whatever you reference is pinned" is enforceable without it.

Access review

Both halves are commands, not a checklist — see privacy.md:

FORGE_TOKEN=... osfi-gate access-review --platform github --api-url ... \
    --repo owner/name --expect osfi-ci     # exit 1 finding, 2 could-not-see
curl -s .../api/access-review              # who can read bundles, and who has

The onboarding step that says "protect osfi-evidence" is now checkable, which is the point: it was a sentence somebody followed or did not, with no artefact recording which.

Controlled egress and internal mirrors

osfi-gate egress          # or --json

The list is generated from the pins in osfi_gate/tools.py, so it cannot drift from them the way a hand-written allowlist does the first time somebody bumps a scanner.

Setting Redirects
OSFI_REGISTRY_MIRROR=reg.internal/proxy Every scanner image. The digest is preserved exactly — a mirror changes where the bytes come from, never which bytes they are, so the evidence records the same images and a mirror serving different content fails to pull rather than substituting quietly.
OSFI_TRIVY_DB_REPOSITORY Trivy's vulnerability database, which it fetches at run time and which is therefore not one of the pinned images. OSFI_TRIVY_JAVA_DB_REPOSITORY for the Java database.

Two things no setting here reaches, and they are the honest limits of this: