Rule Authoring — GVL
GVL (Gadriel Validation Language) is the YAML rule format that powers every
Gadriel Code scan type. A GVL rule is a single .yaml file describing what to
look for (a pattern), how much it matters (risk score + pillar), why it
matters (compliance mappings and a plain-language explanation), and how to
prove it works (fail/pass fixtures). One schema, one file shape, governs SAST,
SCA, Secrets, Config, Container, and API rules alike — the scanner binary owns
pattern compilation, but the rule corpus is pure declarative data.
The canonical contract is the GVL schema (v1.0). This page is the authoring guide: the anatomy of a rule, the pattern types, the false-positive gates, and how rules are validated and tested.
Where rules live
GVL rules are loaded from a policy corpus, resolved in two layers:
| Layer | Location | Role |
|---|---|---|
| Private policy repo | github.com/Gadriel-ai/gadriel-code-policies (the DEFAULT_POLICIES_URL) | The authoritative, versioned rule corpus. Cloned/cached locally under ./gadriel-code-policies. |
| Vendored fallback | Baked into the binary at build time (gadriel-policies-loader) | An offline fallback that "always succeeds offline" — so a scan never depends on network access (privacy boundary). |
The corpus is loaded by gadriel_policies_loader::PoliciesLoader, then parsed
into a RuleRegistry by the gadriel-gvl crate. Because SAST/Config/etc. rules
ship as GVL policies, coverage grows with the corpus, not the binary — a new
rule is a new YAML file, not a code change. See
scan-coverage.md for how rule catalogue depth varies by
language.
Inspect the loaded corpus read-only, with no outbound calls, via:
bash# List every rule slated for removal in a future corpus releasegadriel code policies --list-deprecated# (OSV advisory-snapshot sync — the one networked policies verb)gadriel code policies --osv
Rule anatomy
Every rule carries the following mandatory blocks (the schema's required list;
often just called the "8 mandatory blocks"). A rule that omits any of
them, or mistypes a field, is rejected at load time — see
Validation.
yaml# ── Identity ───────────────────────────────────────────────────────────id: CODE-W1-L2-001name: exec.Command with format-string argument (Go)version: "1.0"# ── Classification ─────────────────────────────────────────────────────scan_type: sastpillar: [security]severity: criticallayers: [commit, pr, pipeline]languages: [go]# ── Pattern ────────────────────────────────────────────────────────────pattern:type: ast_flow# ... (see Pattern types) ...# ── Scoring, compliance, output, tests ─────────────────────────────────risk_score: { ... }compliance_mapping: { ... }what_was_tested: >...what_we_measured: [ ... ]remediation: { ... }tests: { fail: [...], pass: [...] }
id — the finding-ID grammar
The rule id is the finding ID emitted for every hit. It is validated against
the FindingId grammar (shared with gadriel-finding-types) at both rule-load
and emit time:
CODE-W[1-8]-[SCAN]-[NNN]
│ │ │
│ │ └── zero-padded sequence (≥ 3 digits) within (pillar × scan-type)
│ └────────── scan-type code
└────────────────── pillar number (W1–W8)
The regex is:
^CODE-W[1-8]-(L[1-9]|AI|ATLAS|GRAPH|PRED|SCA|SECRET|CONFIG|CONTAINER|API|
MARKDOWN|EUAI|MOBILE|K8S|COMPOSE|TF|CF|HELM|HIPAA|PCI|NISTAI)-\d{3,}$
So CODE-W1-L2-001 is a Security-pillar (W1), Go/language-code-L2, SAST
finding. See Reports & Outputs
for the full ID + OCSF wire-shape contract, and scan-coverage.md
for the pillar (W1–W8) and scan-type code meanings.
Classification blocks
| Block | Type | Allowed values |
|---|---|---|
name | string | Human-readable rule title (non-empty). |
version | string | MAJOR.MINOR (e.g. "1.0"; patch is implied 0). |
scan_type | string | sast · sca · secrets · config · container · api. |
pillar | array | ≥ 1 of security compliance safety operational finops coherence teamwork bias. |
severity | string | critical · high · medium · low · info. |
layers | array | ≥ 1 of watch · commit · pr · pipeline (the four execution layers). |
languages | array | ≥ 1 language token, or ["*"] for language-agnostic rules. |
See execution-layers.md for the layers semantics and
scoring-and-verdicts.md for the pillar weights.
risk_score — base + amplifiers (the Gadriel Scoring Model)
yamlrisk_score:base: 9.0 # 0.0–10.0methodology: ADR-059 # fixed schema constant — the only accepted value;# identifies the Gadriel Scoring Model methodologyamplifiers:- condition: external_data_sourcemultiplier: 1.1- condition: high_centralitymultiplier: 1.5
base is the intrinsic score; amplifiers multiply it when a runtime
condition holds. Amplifier condition is drawn from a fixed enum, including
external_data_source, no_human_oversight, agent_has_tool_access,
output_unvalidated, high_centrality, dependency_reachable,
dependency_abandoned, supply_chain_path, cycle_member,
proven_path_to_asset, and license_commercial_conflict. The scoring math (how
per-finding scores roll into pillar scores and the overall verdict) is
the Gadriel Scoring Model. See
scoring-and-verdicts.md.
compliance_mapping
At least one of owasp_llm, owasp_web, cwe, eu_ai_act, nist_ai_rmf, or
iso_42001 is required; soc2, pci_dss, and hipaa are optional additions.
yamlcompliance_mapping:owasp_web: A03:2021cwe: CWE-78
These drive the framework-control rollup in compliance.md.
The finding-output subsections
Three blocks make a finding self-explanatory in the report — what was tested, what we measured, and how to remediate:
yamlwhat_was_tested: >Tests whether user-controlled input reaches exec.Command /exec.CommandContext via fmt.Sprintf template construction. Threat model:an attacker injects shell metacharacters and spawns an unintended process.what_we_measured:- name: Command Injection Surfacemetric_id: command_injection_surface # snake_case: ^[a-z][a-z0-9_]*$- name: Sanitization Statemetric_id: sanitization_stateremediation:adr_ref: "sec-remediation-020" # free-form remediation referenceeffort: low # low · medium · highsteps:- Pass each argument as its own Command(name, arg1, ...) parameter.- Validate every argument via an allow-list before passing it.code_example: |// BADcmd := exec.Command("sh", "-c", fmt.Sprintf("echo %s", user))// GOODcmd := exec.Command("echo", user)
Each metric_id must be snake_case (^[a-z][a-z0-9_]*$); a camelCase typo is
rejected at load time rather than surfacing at scoring time.
tests — fail/pass fixtures
yamltests:fail: [tests/fixtures/go/cmd_inject.go] # ≥ 1 fixture that MUST firepass: [tests/fixtures/go/clean.go] # ≥ 1 fixture that MUST NOT fire
Every rule ships at least one positive (fail) and one negative (pass)
fixture. These anchor recall and precision so a later corpus edit cannot
silently break the rule.
Pattern types
The pattern.type field selects one of a closed set of pattern variants (24 in the v1.0 schema; the shipped schema currently enumerates 27 after the Wave-C IaC
and compliance additions). Serde deserialises into the typed variant, and every
variant is a strict shape (additionalProperties: false) so an unknown or
mistyped field is a hard error, not a silent drop.
| Family | Types | Scan types |
|---|---|---|
| AST structural | ast_call, ast_assign, ast_import, ast_string, ast_missing | SAST, Config, API, Secrets, Container |
| AST taint | ast_flow (source → sink) | SAST |
| Secrets | regex_pattern, entropy_check, git_history | Secrets |
| SCA | manifest_entry, osv_query, license_check | SCA |
| Container / API | dockerfile_directive, openapi_path, graphql_schema | Container, API |
| IaC | yaml_query, hcl_query | Config (opt-in IaC) |
| Graph | graph_scc, graph_path, graph_centrality, graph_supply_chain, graph_simulation | SAST, SCA |
| Math | math_complexity, math_entropy, math_token_estimate, math_probability | SAST |
| Docs | markdown_content | SAST (docs scope) |
Worked example — ast_flow (Go command injection)
ast_flow tracks taint from a source: (user-controlled origin) to a sink:
(dangerous operation), suppressed when a sanitizer intervenes. This is the
canonical Go os/exec command-injection rule:
yamlid: CODE-W1-L2-001name: exec.Command with format-string argument (Go)version: "1.0"scan_type: sastpillar: [security]severity: criticallayers: [commit, pr, pipeline]languages: [go]pattern:type: ast_flowsource: # user-controlled origins- r.URL.Query- r.FormValue- r.PostFormValue- r.Body- os.Getenv- os.Argssink: # dangerous operations- exec.Command- exec.CommandContextmissing_sanitization: true # fire only if NO sanitizer intervenessanitizer_functions: # calls that neutralise the taint- validatePath- escapeShell- shellEscaperequires_source_provenance:fire: [remote] # only when the source is remote-boundrisk_score:base: 9.0methodology: ADR-059amplifiers:- condition: external_data_sourcemultiplier: 1.1- condition: high_centralitymultiplier: 1.5compliance_mapping:owasp_web: A03:2021cwe: CWE-78# ... what_was_tested / what_we_measured / remediation / tests ...
The flow fires only when a tainted value reaches exec.Command/exec.CommandContext
and no sanitizer (validatePath/escapeShell/shellEscape) is present
and the source is remote-bound (see Source provenance).
Worked example — ast_call
ast_call matches a call site by callee name, optionally gated by preconditions.
This rule fires on logger.info-family calls only in files that import an LLM
SDK:
yamlid: CODE-W1-AI-900name: "LLM logger.info leak — gated by requires_import"version: "1.0"scan_type: sastpillar: [security]severity: highlayers: [pr]languages: [python]pattern:type: ast_callfunction: # single string or list of callees- logger.info- log.info- logging.inforequires_import: # fire only when the file imports one of these- openai- anthropic- langchaincompliance_mapping:cwe: CWE-532# ... risk_score / output blocks / tests ...
IaC — yaml_query / hcl_query
For Infrastructure-as-Code (opt-in via --scan-iac), path-selector +
value-predicate queries walk parsed YAML/HCL documents:
yamlpattern:type: yaml_queryselectors:- kind: Podpath: "spec.containers[*]" # JSONPath-ish, * wildcardpredicate:not_present_or_eq: # fires if missing OR present-but-not-eqkey: "securityContext.allowPrivilegeEscalation"value: false
yamlpattern:type: hcl_queryresource: "aws_s3_bucket" # matches resource "aws_s3_bucket" "X" {}predicate:sibling_resource_missing: # a related resource isn't pairedkind: "aws_s3_bucket_server_side_encryption_configuration"reference_path: "bucket"
Graph — graph_*
The graph_* variants query the 3D codebase graph: graph_scc
(strongly-connected-component / cycle detection), graph_path (directed
reachability from source to sink), graph_centrality, graph_supply_chain, and
graph_simulation. A rule may additionally set the top-level graph: flags
(check_centrality, check_cycle, check_reachability, …) to request those
signals for scoring amplifiers.
False-positive gates (the precision toolkit)
Precision in GVL comes from gate predicates that suppress or demote a match
that would otherwise be a false positive. Every gate is fail-open: a gate can only remove a finding on positive proof of safety — any
uncertainty (undeterminable enclosing function, missing call-site data, an
unpopulated field on a language whose engine hasn't wired the gate yet) leaves
the finding firing. This preserves recall by construction. The gates below are
real, wired predicates in the schema and gadriel-gvl::pattern.
| Gate | Shape | Effect |
|---|---|---|
requires_source_provenance | { fire: [...], demote: [...] } | Gate on the taint source's origin tier. |
sanitizer_functions + missing_sanitization | list + bool | Suppress when a sanitizer intervenes on the flow. |
requires_nonconstant_arg | { positions: [...], member_read_is_constant } | Suppress when the load-bearing arg(s) are compile-time constants. |
requires_argument_pattern | string | Narrow a file-level import gate to a per-call argument-shape check. |
args | list of shape predicates | Typed per-argument predicates (literal_kind, call_chain_includes, …). |
excludes_in_scope | [test], [cfg(test)], [display], … | Suppress when the match sits inside the named source scope. |
excludes_paths | shell-glob list | Skip evaluation on matching file paths. |
excludes_receiver | list | Suppress when the call's receiver segment is denylisted. |
requires_import / requires_import_set | list / named set | Fire only when the file imports one of the named modules. |
requires_framework_import | glob list | Fire only when a matching framework import (e.g. django.*) is present. |
dominating_guards | token list | Suppress a flow when a neutralising guard token appears in the sink's enclosing function body. |
requires_dest_not_sized_to_src | bool | Suppress a C string-copy sink allocated malloc(strlen(src)+N) (sized-to-source idiom). |
Source provenance
yamlrequires_source_provenance:fire: [remote] # MUST reach the sink for the rule to firedemote: [env, filesystem] # trigger one-band severity demotion instead
The six origin tiers are remote, local_cli, env, filesystem,
developer_config, and const. A fire: list restricts firing to those tiers
(a non-listed, non-demoted tier is suppressed entirely); a demote: list drops
severity one band instead of suppressing. const may never appear in either
list — a compile-time constant is never a taint source, and
ProvenanceGate::validate() rejects a rule that lists it, at load time.
Gadriel applies a
sink-aware refinement for C: path and memory sinks fed by a non-remote source
(e.g. the industry-standard getenv("SSLKEYLOGFILE") → fopen TLS-debug hook)
are demoted rather than gating CI at high severity, while a recv → fopen
remote flow still fires at full severity.
Sanitizers and guards
sanitizer_functions (with missing_sanitization: true) suppresses a flow when
a listed sanitizer appears among the sink's arguments. dominating_guards
catches a neutralising control on a different line than the sink — a token that
must appear in the sink's enclosing function body (e.g. MaxBytesReader before
io.Copy, or which::which before Command::new) — that argument-only
sanitizer checks cannot see. Both are fail-open: an undeterminable function body
leaves the finding firing.
Argument-shape predicates
args applies typed per-argument predicates, AND-ed across entries, OR-ed within
a shape: list. Predicates include literal_kind (arg is a literal of a given
kind), literal_value, call_chain_includes (arg is a call whose chain includes
a name), has_property, and is_empty_array:
yamlpattern:type: ast_callfunction: JSON.parseargs:- position: 0shape:- call_chain_includes: "localStorage.getItem" # only Web-Storage DoSexcludes_in_scope: [test]
Validation & testing
Rules are validated in three passes when a corpus is loaded (RuleLoader):
- YAML parse — malformed YAML is rejected.
- JSON-Schema (Draft-7) — the YAML is converted to a JSON value and checked
against the embedded schema (
schemas/gvl-rule.schema.json) via thejsonschemacrate compiled withDraft::Draft7. Because every pattern variant is a strict shape (additionalProperties: false), an unknown or mistyped field name (e.g.pattern:whereast_stringwantsmatch:) fails here with a clear<json-pointer>: <reason>message. - Cross-field invariants — e.g.
ProvenanceGate::validate()(constnever infire/demote), plus duplicate-ID detection when loading a whole directory.
The strict schema is also the re-vendor guard. Serde's deny_unknown_fields
does not propagate through internally-tagged enum newtype variants, so before the
schema was hardened a gate field with a typo (or a field the struct didn't yet
declare) was silently dropped — the gate simply vanished, weakening precision
without any error. additionalProperties: false on every variant makes that a
load-time failure instead: when the private policy repo re-vendors rules into the
binary, a dropped or mistyped gate is caught, not lost. Named-set tokens
(requires_import_set: ai_llm_sdks_v1) are likewise rejected on typo rather than
silently ignored.
Each rule's tests.fail / tests.pass fixtures are exercised by the scanner
test suites (recall + precision anchors), so a corpus edit that would drop a
confirmed true positive or introduce a false positive fails the build.
Related documentation
- scan-coverage.md — the six scan types, the finding-ID grammar, and the eight pillars.
- false-positives.md — the FP-prevention program, confidence tiers, and how to dismiss.
- scoring-and-verdicts.md — the Gadriel Scoring Model
math that turns
risk_score+ pillars into a verdict. - execution-layers.md — the
layers(Watch, Pre-Commit, Pull Request, CI/CD) semantics. - languages.md — per-language SAST/taint depth and rule catalogue coverage.
