Controls and how they are checked
The gate evaluates policy/rego/*.rego against one document, gate-input.json, which has three kinds of input:
| Input | Source | Used by |
|---|---|---|
findings[] |
Scanner output (SARIF or Trivy JSON) and osfi-gate's own pinning and licence checks, normalized and routed by mapping.yaml |
SD, VM, SC, CB, TP, OR |
artifacts |
The SBOM (presence, digest, component count), changed model files, and AI call sites joined to model-inventory.yaml |
AM-01, MR-01 to MR-04 |
metadata |
Platform API: PR author, approvals on the head commit, commit authors and signatures, changed files, branch protection | CM-01 to CM-05, WV-01 |
config (from mapping.yaml → gate), waivers[] and now are also recorded in the input, so replaying a decision later gives the same answer. artifacts.trivy_db records which vulnerability DB (its update time) the findings came from.
Which controls apply to which event
| Event | Change controls (CM-01, CM-02, CM-04, CM-05) | Everything else |
|---|---|---|
pull_request (also re-run on reviews) |
Yes | Yes. The history scan covers the PR's commits (base..head). |
push to a protected branch |
Yes; the merged PR is looked up from the commit | Yes. The history scan covers the pushed commits (before..after). |
schedule (daily) |
No; it isn't a change. CM-03 still checks branch protection. | Yes. The history scan covers everything reachable from the default branch. |
manual |
Yes, if metadata is supplied | Yes. This is the only event on which a scanner may be skipped. |
Semgrep rules
- No live fetching: the gate never downloads rules at scan time. Registry rulesets are copied into
policy/semgrep/registry/byscripts/vendor-semgrep-rules.sh, and each copy's SHA-256 is written topolicy/semgrep-registry.lock.json. - Evidence: because the rules sit in the policy repo, they are covered by the policy content digest recorded in every bundle and used for replay.
- Updating: refreshing the rules is a policy change reviewed by risk. Rule IDs keep their registry names, so fingerprints and waivers survive a refresh unless the upstream rule itself changed.
- Live configs: anything listed in
mapping.yaml → scanners.semgrep.configsis fetched live and isn't reproducible, so keep that list empty.
Severity routing: the first matching rule in mapping.yaml → rules sets each finding's control and, optionally, overrides its severity. A finding that matches no rule fails EV-01, so an unmapped scanner rule can't slip through unseen.
OSFI anchors: these are suggestions at the section level. Second-line risk owns and confirms them.
| ID | Control | Suggested anchor | Fails when |
|---|---|---|---|
| CM-01 | Required approvals | B-13 §2.5 | Fewer than min_approvals independent approvals on the head commit. Stale approvals and approvals from the author or commit authors don't count. |
| CM-02 | Segregation of duties | B-13 §2.5 | The author approved the PR, or an approver also authored commits in it |
| CM-03 | Branch protection | B-13 §2.5 | Protection is missing or unreadable, or its required approvals are below min_approvals |
| CM-04 | Signed commits | B-13 §3.2 | Any PR commit isn't verified by the platform |
| CM-05 | Traceability | B-13 §2.5 | A push to a protected branch has no associated PR |
| SD-01 | SAST | B-13 §2.4 | A Semgrep finding at a blocking severity (critical or high) isn't waived |
| VM-01 | Vulnerability SLA | B-13 §2.6, §3.1 | A Trivy vulnerability is older than sla_days[severity] (measured from advisory publication), or its date is unknown at a blocking severity. The default branch is rescanned daily, so new advisories surface without a code change. |
| AM-01 | SBOM | B-13 §2.2 | The CycloneDX SBOM is missing, unparseable or empty |
| SC-01 | No secrets | B-13 §3.2 | Any Gitleaks finding in the current tree or in the change's commits (a secret added and then deleted still counts and must be rotated), any Checkov CKV_SECRET_* finding, or any Semgrep secret-rule finding, at any severity |
| SC-02 | Approved crypto | B-13 §3.2 | An osfi-sc02-* finding: MD5, SHA-1, DES, RC4, Blowfish or ECB in Python, JS/TS, Java, Go or C# |
| CB-01 | IaC baseline | B-13 §2.1, §3.2 | A baseline Checkov ID (encryption, public access, logging) or a Trivy/Semgrep IaC finding at a blocking severity. Other Checkov checks are medium and don't block. |
| TP-01 | Licences | B-10 | An SBOM component's licence matches denied_licenses |
| TP-02 | Pinning and provenance | B-10, B-13 §2.4 | Missing lockfile (npm, Python, Go, NuGet), unpinned requirement, floating Maven/NuGet version, base image without a digest, CI action without a SHA, or an unapproved registry |
| OR-01 | Resilience | E-21, B-13 §2.9 | A criticality=critical resource without a dr-tier tag, a critical RDS instance that isn't Multi-AZ, or Checkov CKV_AWS_133/144/157 |
| MR-01 | Model artifacts | E-23 | A changed model file (model_globs) has no inventory ID or validation record in model-inventory.yaml |
| MR-02 | AI usage registered | E-23, B-10 | Code calls an AI model (hosted or self-hosted) that isn't in model-inventory.yaml, or the entry has no validation record. Only usage the change introduces blocks; pre-existing usage warns. |
| MR-03 | Provider and residency | B-10, B-13 §1 | The provider's hosting class isn't in approved_hosting, or the declared region isn't one the provider is approved for, or a region-bound provider has no declared region |
| MR-04 | Model pinned to validated version | E-23 | The model is a floating alias (floating_model_patterns), or the model in the code differs from the one the inventory says was validated |
| MR-05 | Materiality assessment present and current | E-23 §3.2 | A model record misses a risk-dimension rating, has no assessor, was assessed longer ago than assessment_max_age_days, or uses a control key or value outside the vocabulary |
| MR-06 | Controls match materiality | E-23 §5; HGAI P.4 | A control the computed tier requires is not recorded as yes; generative model types additionally require the GenAI control set |
| MR-07 | Independent validation current | E-23 §5.3 | No validation record, no validation date, or the next review date has passed |
| WV-01 | Waiver governance | B-13 §1 | A waiver is invalid, has a term longer than max_waiver_days or a future created date, or a PR changes waivers/ without approval from risk_approvers |
| EV-01 | Evidence completeness | B-13 §1 | A required scanner didn't complete (a skip is accepted only on manual runs), platform metadata errored or lacks a required field, a finding is unmapped, or the Trivy vulnerability DB metadata is missing or older than max_vuln_db_age_days |
AI and model usage (MR-02 to MR-04)
What is detected. policy/semgrep/ai/ holds the osfi-mr02-<provider>-<language> rules: hosted APIs (OpenAI, Anthropic, Bedrock, Vertex, Azure OpenAI, Foundry, Gemini, Cohere, Mistral, Groq, Together, Replicate, Hugging Face), frameworks (LangChain, LlamaIndex), and self-hosted inference (transformers, ONNX Runtime, llama.cpp, vLLM, sentence-transformers, torch.load, joblib.load, Ollama). AI/ML packages in the SBOM are recorded alongside as artifacts.ai.dependencies.
The risk reading encoded in mapping.yaml. Detect every provider; approve Canadian-region cloud (Bedrock, Vertex, Azure OpenAI, Foundry) and self-hosted inference; treat a direct call to a provider outside that set as a risk acceptance that needs a waiver. Correct gate.ai if that is not the intent.
Provider resolution. A call goes to the provider its client implies: AnthropicBedrock(...).messages.create(...) is bedrock, not anthropic. The gate applies a cloud client found anywhere in the same file. A file mixing a cloud client and a direct call would be attributed to the cloud provider, so keep them in separate modules.
New versus pre-existing. enforce: new-usage blocks only what a change introduces. The same rules run against the change's base commit (exported with git archive, so fingerprints line up) and anything already present warns instead. Scheduled and manual runs have no base commit, so nothing counts as new. Set enforce: all once a repository is clean.
Waiving a call site. MR-02, MR-03 and MR-04 are raised about a call site rather than by a
scanner finding, but the call site carries the fingerprint of the rule that detected it, so they
are waived exactly like a finding: a waivers/*.yaml naming that fingerprint and that control,
owned, justified, inside the maximum term and approved by risk. A waiver covers the one control
it names — accepting a US-hosted provider under MR-03 does not excuse leaving the model out of
the inventory under MR-02. Because the fingerprint comes from the rule and the path, it survives
the code moving down the file.
Model versions. There is no positive "pinned" pattern: claude-opus-5 is a complete published ID, while gpt-4o is an alias for whatever OpenAI points it at. So MR-04 checks a risk-maintained deny list (floating_model_patterns, where a pattern may start or end with *) and requires the model in the code to equal the model the inventory says was validated. Where the model can't be read from the call site, the gate warns and takes the inventory entry on trust.
Registering usage (model-inventory.yaml in the application repo):
ai_usage:
- path: ai/summarize.py # exact path or glob
provider: bedrock
model: anthropic.claude-sonnet-4-5-20250929-v1:0
region: ca-central-1
inventory_id: MRM-0101
validation_record: https://grc.bank.example/model-validation/MRM-0101/2026-08
purpose: Summarises inbound customer letters for the service desk.
E-23 model records (MR-05 to MR-07)
A call site says where a model is used. A model record says what it is, how material it is, and
which controls are in place. Records live in model_records: in the same model-inventory.yaml,
and call sites reference them by inventory_id.
The structure follows the OSFI E-23 AI Model Risk Alignment Tool: model profile, seven risk dimensions, a materiality tier, a governance control inventory, and the GenAI supplement. The difference is that here it is enforced — an assessment that lives only in a PDF cannot fail a build.
The tier is recorded on every run. MR-05 writes the computed tier into the evidence bundle as
a warning carrying materiality, score and max_score, so "how material is this model" is
answered by the evidence rather than by anything downstream recomputing it. The console's
assessment report reads exactly those fields.
The tier is recomputed, never trusted. The gate scores the seven dimensions (low 1, medium 2,
high 3) and derives the tier from gate.ai.e23.tier: any of high_if_any rated high makes the
model high materiality outright, otherwise the total decides. A record that writes
materiality: low while rating automation high is enforced as high, and the gate says so in a
warning. The ratings are the claim; the tier is the consequence.
What each tier must hold is required_controls in the policy, plus genai_required_controls
for generative model types. Only yes satisfies a requirement: partial, no and
not-applicable do not. A record cannot exempt itself — an exemption is a waiver, which is
owned, justified and expiring. Both the key and the value are checked against a vocabulary
(control_vocabulary, control_states), so a typo fails rather than becoming a silent gap that
looks like a decision.
A note on yes. YAML reads bare yes/no as booleans, so the gate normalises true/false
and any casing to the same vocabulary. Both documentation: yes and documentation: "yes" work.
model_records:
- inventory_id: MRM-0101
name: Customer Letter Summariser v1
model_type: llm # llm, rag, multi-agent get the GenAI control set
business_domain: member-services
owner: Jane Doe, Head of Member Services Technology
lifecycle_stage: production
risk_dimensions: # each rated low / medium / high
financial: low
breadth: medium
complexity: high
automation: low
data_sensitivity: medium
third_party: high
criticality: low
materiality: medium # recorded, but the gate recomputes it
assessed_on: 2026-08-14
assessed_by: Priya Raman, Model Risk
governance_controls: {documentation: yes, monitoring: yes, ...}
genai_controls: {hallucination_testing: yes, prompt_change_control: yes, ...}
validation:
record: https://grc.bank.example/model-validation/MRM-0101/2026-08
validated_on: 2026-08-01
next_review: 2027-08-01
limitations: English only; performance on French letters has not been validated.
Fingerprints
Waivers match on a 16-hex-character fingerprint that is stable across unrelated edits:
| Tool | Fingerprint inputs |
|---|---|
| SARIF tools | tool, rule ID, path, and the hash of the whitespace-normalized snippet |
| Checkov | tool, rule ID, path, and the resource address |
| Trivy vulnerabilities | vulnerability ID, package, installed version, and target |
| Gitleaks | tool, rule ID, path, and line number (the snippet is the secret, so it's discarded). History findings also include the commit SHA. |
Semgrep rules from the policy repo are reduced to their bare osfi-* ID, so fingerprints are the same locally and in CI.
Adding a check
- Add the rule to a scanner (for example a Semgrep rule in
policy/semgrep/) or toosfi_gate/inspect.py. - Route it in
mapping.yaml → rules. - If the control is new, add it to
catalog/controls.yaml, runosfi-gate catalog, and add adenyrule plus a Rego unit test inrego/policy_test.rego. - Run
conftest verifyandpytest.