GCA · REFERENCE

    Rule Authoring (GVL)

    Author custom detection rules with the Gadriel Validation Language.

    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:

    LayerLocationRole
    Private policy repogithub.com/Gadriel-ai/gadriel-code-policies (the DEFAULT_POLICIES_URL)The authoritative, versioned rule corpus. Cloned/cached locally under ./gadriel-code-policies.
    Vendored fallbackBaked 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:

    # List every rule slated for removal in a future corpus release
    gadriel 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.

    # ── Identity ───────────────────────────────────────────────────────────
    id: CODE-W1-L2-001
    name: exec.Command with format-string argument (Go)
    version: "1.0"
    # ── Classification ─────────────────────────────────────────────────────
    scan_type: sast
    pillar: [security]
    severity: critical
    layers: [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

    BlockTypeAllowed values
    namestringHuman-readable rule title (non-empty).
    versionstringMAJOR.MINOR (e.g. "1.0"; patch is implied 0).
    scan_typestringsast · sca · secrets · config · container · api.
    pillararray≥ 1 of security compliance safety operational finops coherence teamwork bias.
    severitystringcritical · high · medium · low · info.
    layersarray≥ 1 of watch · commit · pr · pipeline (the four execution layers).
    languagesarray≥ 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)

    risk_score:
    base: 9.0 # 0.0–10.0
    methodology: ADR-059 # fixed schema constant — the only accepted value;
    # identifies the Gadriel Scoring Model methodology
    amplifiers:
    - condition: external_data_source
    multiplier: 1.1
    - condition: high_centrality
    multiplier: 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.

    compliance_mapping:
    owasp_web: A03:2021
    cwe: 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:

    what_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 Surface
    metric_id: command_injection_surface # snake_case: ^[a-z][a-z0-9_]*$
    - name: Sanitization State
    metric_id: sanitization_state
    remediation:
    adr_ref: "sec-remediation-020" # free-form remediation reference
    effort: low # low · medium · high
    steps:
    - Pass each argument as its own Command(name, arg1, ...) parameter.
    - Validate every argument via an allow-list before passing it.
    code_example: |
    // BAD
    cmd := exec.Command("sh", "-c", fmt.Sprintf("echo %s", user))
    // GOOD
    cmd := 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

    tests:
    fail: [tests/fixtures/go/cmd_inject.go] # ≥ 1 fixture that MUST fire
    pass: [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.

    FamilyTypesScan types
    AST structuralast_call, ast_assign, ast_import, ast_string, ast_missingSAST, Config, API, Secrets, Container
    AST taintast_flow (source → sink)SAST
    Secretsregex_pattern, entropy_check, git_historySecrets
    SCAmanifest_entry, osv_query, license_checkSCA
    Container / APIdockerfile_directive, openapi_path, graphql_schemaContainer, API
    IaCyaml_query, hcl_queryConfig (opt-in IaC)
    Graphgraph_scc, graph_path, graph_centrality, graph_supply_chain, graph_simulationSAST, SCA
    Mathmath_complexity, math_entropy, math_token_estimate, math_probabilitySAST
    Docsmarkdown_contentSAST (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:

    id: CODE-W1-L2-001
    name: exec.Command with format-string argument (Go)
    version: "1.0"
    scan_type: sast
    pillar: [security]
    severity: critical
    layers: [commit, pr, pipeline]
    languages: [go]
    pattern:
    type: ast_flow
    source: # user-controlled origins
    - r.URL.Query
    - r.FormValue
    - r.PostFormValue
    - r.Body
    - os.Getenv
    - os.Args
    sink: # dangerous operations
    - exec.Command
    - exec.CommandContext
    missing_sanitization: true # fire only if NO sanitizer intervenes
    sanitizer_functions: # calls that neutralise the taint
    - validatePath
    - escapeShell
    - shellEscape
    requires_source_provenance:
    fire: [remote] # only when the source is remote-bound
    risk_score:
    base: 9.0
    methodology: ADR-059
    amplifiers:
    - condition: external_data_source
    multiplier: 1.1
    - condition: high_centrality
    multiplier: 1.5
    compliance_mapping:
    owasp_web: A03:2021
    cwe: 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:

    id: CODE-W1-AI-900
    name: "LLM logger.info leak — gated by requires_import"
    version: "1.0"
    scan_type: sast
    pillar: [security]
    severity: high
    layers: [pr]
    languages: [python]
    pattern:
    type: ast_call
    function: # single string or list of callees
    - logger.info
    - log.info
    - logging.info
    requires_import: # fire only when the file imports one of these
    - openai
    - anthropic
    - langchain
    compliance_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:

    pattern:
    type: yaml_query
    selectors:
    - kind: Pod
    path: "spec.containers[*]" # JSONPath-ish, * wildcard
    predicate:
    not_present_or_eq: # fires if missing OR present-but-not-eq
    key: "securityContext.allowPrivilegeEscalation"
    value: false
    pattern:
    type: hcl_query
    resource: "aws_s3_bucket" # matches resource "aws_s3_bucket" "X" {}
    predicate:
    sibling_resource_missing: # a related resource isn't paired
    kind: "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.

    GateShapeEffect
    requires_source_provenance{ fire: [...], demote: [...] }Gate on the taint source's origin tier.
    sanitizer_functions + missing_sanitizationlist + boolSuppress 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_patternstringNarrow a file-level import gate to a per-call argument-shape check.
    argslist of shape predicatesTyped 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_pathsshell-glob listSkip evaluation on matching file paths.
    excludes_receiverlistSuppress when the call's receiver segment is denylisted.
    requires_import / requires_import_setlist / named setFire only when the file imports one of the named modules.
    requires_framework_importglob listFire only when a matching framework import (e.g. django.*) is present.
    dominating_guardstoken listSuppress a flow when a neutralising guard token appears in the sink's enclosing function body.
    requires_dest_not_sized_to_srcboolSuppress a C string-copy sink allocated malloc(strlen(src)+N) (sized-to-source idiom).

    Source provenance

    requires_source_provenance:
    fire: [remote] # MUST reach the sink for the rule to fire
    demote: [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:

    pattern:
    type: ast_call
    function: JSON.parse
    args:
    - position: 0
    shape:
    - call_chain_includes: "localStorage.getItem" # only Web-Storage DoS
    excludes_in_scope: [test]

    Validation & testing

    Rules are validated in three passes when a corpus is loaded (RuleLoader):

    1. YAML parse — malformed YAML is rejected.
    2. JSON-Schema (Draft-7) — the YAML is converted to a JSON value and checked against the embedded schema (schemas/gvl-rule.schema.json) via the jsonschema crate compiled with Draft::Draft7. Because every pattern variant is a strict shape (additionalProperties: false), an unknown or mistyped field name (e.g. pattern: where ast_string wants match:) fails here with a clear <json-pointer>: <reason> message.
    3. Cross-field invariants — e.g. ProvenanceGate::validate() (const never in fire/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.


    • 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.