From f002f26791b46c00f4a35bd4416f681f31466092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Proulx?= Date: Mon, 12 Jan 2026 15:35:56 -0500 Subject: [PATCH 1/3] Add structured metadata fields to findings for programmatic access Adds new fields to FindingMeta for library users who need machine-readable access to security-relevant data without parsing human-readable strings: - injection_sources: Array of specific expressions being injected - lotp_tool: Living Off The Pipeline build tool (npm, pip, make, etc.) - lotp_action: LOTP GitHub Action identifier - referenced_secrets: Secrets referenced in the job (excludes GITHUB_TOKEN) The referenced_secrets field is automatically extracted when rules pass the _job field, supporting dot notation (secrets.FOO) and bracket notation (secrets['FOO'] and secrets["FOO"]). Benchmark (Apple M4 Pro): | Version | ns/op | B/op | allocs/op | |---------|-----------|-----------|-----------| | Before | 10971339 | 7149084 | 132787 | | After | 12059356 | 7858242 | 148422 | | Delta | +9.9% | +9.9% | +11.8% | Co-Authored-By: Claude Opus 4.5 --- .../content/en/structured-finding-metadata.md | 159 +++++++++ opa/rego/poutine.rego | 16 + opa/rego/poutine/utils.rego | 40 ++- opa/rego/rules/injection.rego | 6 + opa/rego/rules/untrusted_checkout_exec.rego | 21 +- results/results.go | 6 + scanner/inventory_scanner_test.go | 1 + scanner/inventory_test.go | 318 ++++++++++++------ .../.github/workflows/test_new_fields.yml | 43 +++ 9 files changed, 510 insertions(+), 100 deletions(-) create mode 100644 docs/content/en/structured-finding-metadata.md create mode 100644 scanner/testdata/.github/workflows/test_new_fields.yml diff --git a/docs/content/en/structured-finding-metadata.md b/docs/content/en/structured-finding-metadata.md new file mode 100644 index 00000000..c09bd2cb --- /dev/null +++ b/docs/content/en/structured-finding-metadata.md @@ -0,0 +1,159 @@ +--- +title: "Structured Finding Metadata" +weight: 10 +--- + +# Structured Finding Metadata + +Poutine findings now include structured metadata fields that provide programmatic access to security-relevant information. These fields enable library users to build automated triage workflows, correlate findings with secrets exposure, and integrate with downstream security tooling without parsing human-readable text. + +## New Finding Fields + +### `injection_sources` + +**Type:** `[]string` + +A sorted array of the specific expression sources that are being injected into a sink (shell script, JavaScript, etc.). + +**Example:** +```json +{ + "rule_id": "injection", + "meta": { + "details": "Sources: github.event.issue.title github.head_ref", + "injection_sources": ["github.event.issue.title", "github.head_ref"] + } +} +``` + +**Use case:** Programmatically identify which untrusted inputs are exploitable without parsing the `details` string. + +--- + +### `lotp_tool` + +**Type:** `string` + +The "Living Off The Pipeline" build tool detected after an untrusted checkout. Common values include `npm`, `pip`, `make`, `bash`, `cargo`, `gradle`, etc. + +**Example:** +```json +{ + "rule_id": "untrusted_checkout_exec", + "meta": { + "details": "Detected usage of `npm`", + "lotp_tool": "npm" + } +} +``` + +**Use case:** Filter findings by tool type, prioritize based on tool risk, or build tool-specific remediation guidance. + +--- + +### `lotp_action` + +**Type:** `string` + +The GitHub Action identified as a "Living Off The Pipeline" vector (e.g., actions that execute code from the checked-out repository). + +**Example:** +```json +{ + "rule_id": "untrusted_checkout_exec", + "meta": { + "details": "Detected usage the GitHub Action `bridgecrewio/checkov-action`", + "lotp_action": "bridgecrewio/checkov-action" + } +} +``` + +**Use case:** Track which third-party actions introduce code execution risks, build action allowlists. + +--- + +### `referenced_secrets` + +**Type:** `[]string` + +A sorted array of secret names referenced in the job where the vulnerability was found. The `GITHUB_TOKEN` is excluded since it's always available. + +Supports both dot notation (`secrets.MY_SECRET`) and bracket notation (`secrets['MY_SECRET']`). + +**Example:** +```json +{ + "rule_id": "untrusted_checkout_exec", + "meta": { + "lotp_tool": "npm", + "referenced_secrets": ["API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN"] + } +} +``` + +**Use case:** Assess blast radius of a vulnerability - if a job with an injection vulnerability also references `PROD_DEPLOY_KEY`, the finding is more critical than one with no secrets. + +--- + +## Usage Examples + +### Prioritize by Secrets Exposure + +```python +def calculate_priority(finding): + secrets = finding.get("meta", {}).get("referenced_secrets", []) + high_value = ["DEPLOY", "PROD", "AWS", "GCP", "AZURE", "NPM_TOKEN"] + + if any(s for s in secrets if any(h in s for h in high_value)): + return "critical" + elif secrets: + return "high" + return "medium" +``` + +### Filter Injection Sources + +```python +def is_pr_body_injection(finding): + sources = finding.get("meta", {}).get("injection_sources", []) + pr_body_patterns = ["pull_request.body", "issue.body", "comment.body"] + return any(p in s for s in sources for p in pr_body_patterns) +``` + +### Group by LOTP Tool + +```python +from collections import defaultdict + +def group_by_tool(findings): + by_tool = defaultdict(list) + for f in findings: + if f["rule_id"] == "untrusted_checkout_exec": + tool = f["meta"].get("lotp_tool") or f["meta"].get("lotp_action", "unknown") + by_tool[tool].append(f) + return dict(by_tool) +``` + +--- + +## Backward Compatibility + +These fields are additive - the existing `details` field continues to provide human-readable descriptions. Tools parsing `details` will continue to work, but new integrations should prefer the structured fields for reliability. + +**JSON behavior:** +- `injection_sources`, `lotp_tool`, `lotp_action`: Omitted when not applicable +- `referenced_secrets`: Present as `[]` (empty array) for GitHub Actions findings even when no secrets are found; omitted for other CI systems + +--- + +## Supported Rules + +| Rule | `injection_sources` | `lotp_tool` | `lotp_action` | `referenced_secrets` | +|------|---------------------|-------------|---------------|----------------------| +| `injection` (GitHub Actions) | Yes | - | - | Yes | +| `injection` (GitLab CI) | Yes | - | - | - | +| `injection` (Azure Pipelines) | Yes | - | - | - | +| `injection` (Tekton) | Yes | - | - | - | +| `untrusted_checkout_exec` (GitHub Actions) | - | Yes | Yes | Yes | +| `untrusted_checkout_exec` (Azure DevOps) | - | Yes | - | - | +| `untrusted_checkout_exec` (Tekton) | - | Yes | - | - | diff --git a/opa/rego/poutine.rego b/opa/rego/poutine.rego index 19c62072..0eca0916 100644 --- a/opa/rego/poutine.rego +++ b/opa/rego/poutine.rego @@ -1,5 +1,6 @@ package poutine +import data.poutine.utils import rego.v1 rule(chain) = { @@ -24,10 +25,25 @@ rule(chain) = { ) } +# finding with _job field - extracts referenced_secrets automatically +finding(rule, pkg_purl, meta) = { + "rule_id": rule.id, + "purl": pkg_purl, + "meta": object.union( + object.remove(meta, ["_job"]), + {"referenced_secrets": utils.job_referenced_secrets(meta._job)}, + ), +} if { + meta._job +} + +# finding without _job field - no automatic secrets extraction finding(rule, pkg_purl, meta) = { "rule_id": rule.id, "purl": pkg_purl, "meta": meta, +} if { + not meta._job } _rule_config(rule_id, meta) = object.union(rule_config, config_values) if { diff --git a/opa/rego/poutine/utils.rego b/opa/rego/poutine/utils.rego index 8b2d4785..774556bc 100644 --- a/opa/rego/poutine/utils.rego +++ b/opa/rego/poutine/utils.rego @@ -103,4 +103,42 @@ find_first_uses_in_job(job, uses) := xs if { s := job.steps[i] startswith(s.uses, sprintf("%v@", [uses[_]])) } -} \ No newline at end of file +} + +######################################################################## +# extract_referenced_secrets +# Extracts all secrets.* references from GitHub Actions expressions (${{ }}) +# Excludes GITHUB_TOKEN. Handles dot and bracket notation. +######################################################################## + +# Dot notation: ${{ secrets.FOO }} or ${{ format(secrets.FOO) }} +_secrets_dot_notation(str) := {m[1] | + matches := regex.find_all_string_submatch_n("\\$\\{\\{[^}]*?secrets\\.([a-zA-Z_][a-zA-Z0-9_]*)", str, -1) + m := matches[_] + m[1] != "GITHUB_TOKEN" +} + +# Bracket notation with single quotes: ${{ secrets['FOO'] }} +_secrets_bracket_single(str) := {m[1] | + matches := regex.find_all_string_submatch_n("\\$\\{\\{[^}]*?secrets\\['([a-zA-Z_][a-zA-Z0-9_]*)'\\]", str, -1) + m := matches[_] + m[1] != "GITHUB_TOKEN" +} + +# Bracket notation with double quotes: ${{ secrets["FOO"] }} +# Also handles JSON-escaped quotes: secrets[\"FOO\"] (after json.marshal) +_secrets_bracket_double(str) := {m[1] | + matches := regex.find_all_string_submatch_n("\\$\\{\\{[^}]*?secrets\\[\\\\?\"([a-zA-Z_][a-zA-Z0-9_]*)\\\\?\"\\]", str, -1) + m := matches[_] + m[1] != "GITHUB_TOKEN" +} + +extract_referenced_secrets(str) := sort(secrets) if { + secrets := _secrets_dot_notation(str) | _secrets_bracket_single(str) | _secrets_bracket_double(str) +} + +# Extract secrets from a job by marshaling to JSON and searching +job_referenced_secrets(job) := secrets if { + job_json := json.marshal(job) + secrets := extract_referenced_secrets(job_json) +} diff --git a/opa/rego/rules/injection.rego b/opa/rego/rules/injection.rego index 57830033..1b944cd5 100644 --- a/opa/rego/rules/injection.rego +++ b/opa/rego/rules/injection.rego @@ -33,6 +33,8 @@ results contains poutine.finding(rule, pkg.purl, { "job": job.id, "step": i, "details": sprintf("Sources: %s", [concat(" ", exprs)]), + "injection_sources": sort(exprs), + "_job": job, "event_triggers": [event | event := workflow.events[j].name], }) if { pkg = input.packages[_] @@ -48,6 +50,7 @@ results contains poutine.finding(rule, pkg.purl, { "line": line, "step": i, "details": sprintf("Sources: %s", [concat(" ", exprs)]), + "injection_sources": sort(exprs), "event_triggers": [event | event := action.events[j].name], }) if { pkg = input.packages[_] @@ -69,6 +72,7 @@ results contains poutine.finding(rule, pkg.purl, { "path": config.path, "job": sprintf("%s.%s[%d]", [job.name, attr, i]), "details": sprintf("Sources: %s", [concat(" ", exprs)]), + "injection_sources": sort(exprs), "line": job[attr][i].line, }) if { pkg = input.packages[_] @@ -94,6 +98,7 @@ results contains poutine.finding(rule, pkg.purl, { "step": step_id, "line": step.lines[attr], "details": sprintf("Sources: %s", [concat(" ", exprs)]), + "injection_sources": sort(exprs), }) if { some attr in {"script", "powershell", "pwsh", "bash"} pkg := input.packages[_] @@ -117,6 +122,7 @@ results contains poutine.finding(rule, pkg.purl, { "step": step_idx, "line": step.lines.start, "details": sprintf("Sources: %s", [concat(" ", exprs)]), + "injection_sources": sort(exprs), }) if { pkg := input.packages[_] pipeline := pkg.pipeline_as_code_tekton[_] diff --git a/opa/rego/rules/untrusted_checkout_exec.rego b/opa/rego/rules/untrusted_checkout_exec.rego index 21c49538..42f63eb8 100644 --- a/opa/rego/rules/untrusted_checkout_exec.rego +++ b/opa/rego/rules/untrusted_checkout_exec.rego @@ -99,10 +99,13 @@ build_commands[cmd] = { results contains poutine.finding(rule, pkg_purl, { "path": workflow_path, "line": step.lines.run, + "job": job_id, + "lotp_tool": cmd, + "_job": job_obj, "details": sprintf("Detected usage of `%s`", [cmd]), "event_triggers": workflow_events, }) if { - [pkg_purl, workflow_path, workflow_events, step] := _steps_after_untrusted_checkout[_] + [pkg_purl, workflow_path, workflow_events, step, job_id, job_obj] := _steps_after_untrusted_checkout[_] regex.match( sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]), step.run, @@ -112,10 +115,13 @@ results contains poutine.finding(rule, pkg_purl, { results contains poutine.finding(rule, pkg_purl, { "path": workflow_path, "line": step.lines.uses, + "job": job_id, + "lotp_action": step.action, + "_job": job_obj, "details": sprintf("Detected usage the GitHub Action `%s`", [step.action]), "event_triggers": workflow_events, }) if { - [pkg_purl, workflow_path, workflow_events, step] := _steps_after_untrusted_checkout[_] + [pkg_purl, workflow_path, workflow_events, step, job_id, job_obj] := _steps_after_untrusted_checkout[_] regex.match( sprintf("([^a-z]|^)(%v)@", [concat("|", build_github_actions[_])]), step.uses, @@ -126,17 +132,20 @@ results contains poutine.finding(rule, pkg_purl, { results contains poutine.finding(rule, pkg_purl, { "path": workflow_path, "line": step.lines.uses, + "job": job_id, + "lotp_action": step.action, + "_job": job_obj, "details": sprintf("Detected usage of a Local GitHub Action at path: `%s`", [step.action]), "event_triggers": workflow_events, }) if { - [pkg_purl, workflow_path, workflow_events, step] := _steps_after_untrusted_checkout[_] + [pkg_purl, workflow_path, workflow_events, step, job_id, job_obj] := _steps_after_untrusted_checkout[_] regex.match( `^\./`, step.action, ) } -_steps_after_untrusted_checkout contains [pkg.purl, workflow.path, events, s.step] if { +_steps_after_untrusted_checkout contains [pkg.purl, workflow.path, events, s.step, workflow.jobs[s.job_idx].id, workflow.jobs[s.job_idx]] if { pkg := input.packages[_] workflow := pkg.github_actions_workflows[_] @@ -147,7 +156,7 @@ _steps_after_untrusted_checkout contains [pkg.purl, workflow.path, events, s.ste s := utils.workflow_steps_after(pr_checkout)[_] } -_steps_after_untrusted_checkout contains [pkg_purl, workflow.path, events, s.step] if { +_steps_after_untrusted_checkout contains [pkg_purl, workflow.path, events, s.step, workflow.jobs[s.job_idx].id, workflow.jobs[s.job_idx]] if { [pkg_purl, workflow] := _workflows_runs_from_pr[_] events := [event | event := workflow.events[i].name] @@ -170,6 +179,7 @@ results contains poutine.finding(rule, pkg_purl, { "job": job, "step": s.step_idx, "line": s.step.lines[attr], + "lotp_tool": cmd, "details": sprintf("Detected usage of `%s`", [cmd]), }) if { [pkg_purl, pipeline_path, s, job] := _steps_after_untrusted_checkout_ado[_] @@ -213,6 +223,7 @@ results contains poutine.finding(rule, pkg.purl, { "job": task.name, "step": step_idx, "line": step.lines.script, + "lotp_tool": cmd, "details": sprintf("Detected usage of `%s`", [cmd]), }) if { pkg := input.packages[_] diff --git a/results/results.go b/results/results.go index 6e5d348f..c10e9d4d 100644 --- a/results/results.go +++ b/results/results.go @@ -23,6 +23,12 @@ type FindingMeta struct { Details string `json:"details,omitempty"` EventTriggers []string `json:"event_triggers,omitempty"` BlobSHA string `json:"blobsha,omitempty"` + + // Structured fields for programmatic access + InjectionSources []string `json:"injection_sources,omitempty"` // Sources confirmed as injected into a sink + LOTPTool string `json:"lotp_tool,omitempty"` // Living Off The Pipeline tool (e.g., npm, pip) + LOTPAction string `json:"lotp_action,omitempty"` // Living Off The Pipeline GitHub Action + ReferencedSecrets []string `json:"referenced_secrets,omitempty"` // Secrets referenced in workflow (excludes GITHUB_TOKEN) } type Finding struct { diff --git a/scanner/inventory_scanner_test.go b/scanner/inventory_scanner_test.go index fd1aea8c..ff6a8217 100644 --- a/scanner/inventory_scanner_test.go +++ b/scanner/inventory_scanner_test.go @@ -35,6 +35,7 @@ func TestGithubWorkflows(t *testing.T) { ".github/workflows/anchors_job.yml", ".github/workflows/anchors_multiple.yml", ".github/workflows/anchors_with_vulnerability.yml", + ".github/workflows/test_new_fields.yml", }) } diff --git a/scanner/inventory_test.go b/scanner/inventory_test.go index af0d84d7..38937439 100644 --- a/scanner/inventory_test.go +++ b/scanner/inventory_test.go @@ -52,9 +52,10 @@ func TestPurls(t *testing.T) { "pkg:githubactions/org/owner@main#.github/workflows/ci.yml", "pkg:githubactions/actions/checkout@v5", "pkg:docker/node%3A18", + "pkg:githubactions/some/action@v1", } assert.ElementsMatch(t, i.Purls(*scannedPackage), purls) - assert.Len(t, scannedPackage.BuildDependencies, 22) + assert.Len(t, scannedPackage.BuildDependencies, 23) assert.Equal(t, 4, len(scannedPackage.PackageDependencies)) } @@ -134,24 +135,28 @@ func TestFindings(t *testing.T) { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Job: "build", - Path: ".github/workflows/valid.yml", - Step: "1", - Line: 20, - Details: "Sources: github.head_ref", - EventTriggers: []string{"push", "pull_request_target"}, + Job: "build", + Path: ".github/workflows/valid.yml", + Step: "1", + Line: 20, + Details: "Sources: github.head_ref", + InjectionSources: []string{"github.head_ref"}, + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Job: "build", - Path: ".github/workflows/valid.yml", - Step: "7", - Line: 46, - Details: "Sources: github.event.workflow_run.head_branch", - EventTriggers: []string{"push", "pull_request_target"}, + Job: "build", + Path: ".github/workflows/valid.yml", + Step: "7", + Line: 46, + Details: "Sources: github.event.workflow_run.head_branch", + InjectionSources: []string{"github.event.workflow_run.head_branch"}, + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { @@ -195,60 +200,78 @@ func TestFindings(t *testing.T) { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/valid.yml", - Line: 30, - Details: "Detected usage of `npm`", - EventTriggers: []string{"push", "pull_request_target"}, + Path: ".github/workflows/valid.yml", + Job: "build", + Line: 30, + Details: "Detected usage of `npm`", + LOTPTool: "npm", + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/valid.yml", - Line: 56, - Details: "Detected usage the GitHub Action `bridgecrewio/checkov-action`", - EventTriggers: []string{"push", "pull_request_target"}, + Path: ".github/workflows/valid.yml", + Job: "build", + Line: 56, + Details: "Detected usage the GitHub Action `bridgecrewio/checkov-action`", + LOTPAction: "bridgecrewio/checkov-action", + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/valid.yml", - Line: 60, - Details: "Detected usage of `pre-commit`", - EventTriggers: []string{"push", "pull_request_target"}, + Path: ".github/workflows/valid.yml", + Job: "build", + Line: 60, + Details: "Detected usage of `pre-commit`", + LOTPTool: "pre-commit", + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/valid.yml", - Line: 75, - Details: "Detected usage of `bash`", - EventTriggers: []string{"push", "pull_request_target"}, + Path: ".github/workflows/valid.yml", + Job: "build", + Line: 75, + Details: "Detected usage of `bash`", + LOTPTool: "bash", + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/valid.yml", - Line: 80, - Details: "Detected usage of `bash`", - EventTriggers: []string{"push", "pull_request_target"}, + Path: ".github/workflows/valid.yml", + Job: "build", + Line: 80, + Details: "Detected usage of `bash`", + LOTPTool: "bash", + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/workflow_run_valid.yml", - Line: 13, - Details: "Detected usage of `npm`", - EventTriggers: []string{"workflow_run"}, + Path: ".github/workflows/workflow_run_valid.yml", + Job: "pr", + Line: 13, + Details: "Detected usage of `npm`", + LOTPTool: "npm", + EventTriggers: []string{"workflow_run"}, + ReferencedSecrets: []string{}, }, }, { @@ -317,22 +340,25 @@ func TestFindings(t *testing.T) { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Job: "build", - Path: ".github/workflows/valid.yml", - Step: "8", - Line: 50, - Details: "Sources: github.event.client_payload.foo", - EventTriggers: []string{"push", "pull_request_target"}, + Job: "build", + Path: ".github/workflows/valid.yml", + Step: "8", + Line: 50, + Details: "Sources: github.event.client_payload.foo", + InjectionSources: []string{"github.event.client_payload.foo"}, + EventTriggers: []string{"push", "pull_request_target"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Path: ".gitlab-ci.yml", - Job: "default.before_script[0]", - Details: "Sources: inputs.gem_name", - Line: 48, + Path: ".gitlab-ci.yml", + Job: "default.before_script[0]", + Details: "Sources: inputs.gem_name", + InjectionSources: []string{"inputs.gem_name"}, + Line: 48, }, }, { @@ -410,11 +436,12 @@ func TestFindings(t *testing.T) { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Path: ".azure-pipelines.yml", - Line: 14, - Job: "build", - Step: "1", - Details: "Sources: Build.SourceBranch", + Path: ".azure-pipelines.yml", + Line: 14, + Job: "build", + Step: "1", + Details: "Sources: Build.SourceBranch", + InjectionSources: []string{"Build.SourceBranch"}, }, }, { @@ -432,90 +459,100 @@ func TestFindings(t *testing.T) { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-2.yml", - Line: 13, - Job: "", - Step: "1", - Details: "Detected usage of `bash`", + Path: "azure-pipelines-2.yml", + Line: 13, + Job: "", + Step: "1", + Details: "Detected usage of `bash`", + LOTPTool: "bash", }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-2.yml", - Line: 14, - Job: "", - Step: "2", - Details: "Detected usage of `npm`", + Path: "azure-pipelines-2.yml", + Line: 14, + Job: "", + Step: "2", + Details: "Detected usage of `npm`", + LOTPTool: "npm", }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-4.yml", - Line: 10, - Job: "", - Step: "1", - Details: "Detected usage of `bash`", + Path: "azure-pipelines-4.yml", + Line: 10, + Job: "", + Step: "1", + Details: "Detected usage of `bash`", + LOTPTool: "bash", }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-4.yml", - Line: 11, - Job: "", - Step: "2", - Details: "Detected usage of `npm`", + Path: "azure-pipelines-4.yml", + Line: 11, + Job: "", + Step: "2", + Details: "Detected usage of `npm`", + LOTPTool: "npm", }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".tekton/pipeline-as-code-tekton.yml", - Line: 43, - Job: "vale", - Step: "0", - Details: "Detected usage of `vale`", + Path: ".tekton/pipeline-as-code-tekton.yml", + Line: 43, + Job: "vale", + Step: "0", + Details: "Detected usage of `vale`", + LOTPTool: "vale", }, }, { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Path: ".tekton/pipeline-as-code-tekton.yml", - Line: 45, - Job: "vale", - Step: "1", - Details: "Sources: body.pull_request.body", + Path: ".tekton/pipeline-as-code-tekton.yml", + Line: 45, + Job: "vale", + Step: "1", + Details: "Sources: body.pull_request.body", + InjectionSources: []string{"body.pull_request.body"}, }, }, { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/anchors_with_vulnerability.yml", - Line: 15, - Job: "base_job", - Step: "1", - Details: "Sources: github.head_ref", - EventTriggers: []string{"pull_request_target", "push"}, + Path: ".github/workflows/anchors_with_vulnerability.yml", + Line: 15, + Job: "base_job", + Step: "1", + Details: "Sources: github.head_ref", + InjectionSources: []string{"github.head_ref"}, + EventTriggers: []string{"pull_request_target", "push"}, + ReferencedSecrets: []string{}, }, }, { RuleId: "injection", Purl: purl, Meta: results.FindingMeta{ - Path: ".github/workflows/anchors_with_vulnerability.yml", - Line: 15, - Job: "test_job", - Step: "1", - Details: "Sources: github.head_ref", - EventTriggers: []string{"pull_request_target", "push"}, + Path: ".github/workflows/anchors_with_vulnerability.yml", + Line: 15, + Job: "test_job", + Step: "1", + Details: "Sources: github.head_ref", + InjectionSources: []string{"github.head_ref"}, + EventTriggers: []string{"pull_request_target", "push"}, + ReferencedSecrets: []string{}, }, }, { @@ -526,6 +563,49 @@ func TestFindings(t *testing.T) { EventTriggers: []string{"pull_request_target", "push"}, }, }, + // test_new_fields.yml findings + { + RuleId: "injection", + Purl: purl, + Meta: results.FindingMeta{ + Path: ".github/workflows/test_new_fields.yml", + Line: 14, + Job: "vulnerable_injection", + Step: "1", + Details: "Sources: github.event.issue.title", + InjectionSources: []string{"github.event.issue.title"}, + EventTriggers: []string{"pull_request_target"}, + ReferencedSecrets: []string{}, + }, + }, + { + RuleId: "github_action_from_unverified_creator_used", + Purl: "pkg:githubactions/some/action", + Meta: results.FindingMeta{ + Details: "Used in 1 repo(s)", + }, + }, + { + RuleId: "untrusted_checkout_exec", + Purl: purl, + Meta: results.FindingMeta{ + Path: ".github/workflows/test_new_fields.yml", + Line: 29, + Job: "vulnerable_checkout", + Details: "Detected usage of `npm`", + LOTPTool: "npm", + EventTriggers: []string{"pull_request_target"}, + ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"}, + }, + }, + { + RuleId: "default_permissions_on_risky_events", + Purl: purl, + Meta: results.FindingMeta{ + Path: ".github/workflows/test_new_fields.yml", + EventTriggers: []string{"pull_request_target"}, + }, + }, } assert.Equal(t, len(findings), len(analysisResults.Findings)) @@ -628,3 +708,53 @@ func TestRulesConfig(t *testing.T) { } assert.Empty(t, labels) } + +func TestStructuredFindingFields(t *testing.T) { + o, _ := opa.NewOpa(context.TODO(), &models.Config{ + Include: []models.ConfigInclude{}, + }) + i := NewInventory(o, nil, "", "") + ctx := context.TODO() + purl := "pkg:github/org/owner" + pkg := &models.PackageInsights{ + Purl: purl, + SourceGitRepo: "org/owner", + SourceGitRef: "main", + } + _ = pkg.NormalizePurl() + + scannedPackage, err := i.ScanPackage(ctx, *pkg, "testdata") + assert.NoError(t, err) + + // Test injection_sources field - find the specific injection finding + var injectionFinding *results.Finding + for idx, f := range scannedPackage.FindingsResults.Findings { + if f.RuleId == "injection" && f.Meta.Path == ".github/workflows/test_new_fields.yml" { + injectionFinding = &scannedPackage.FindingsResults.Findings[idx] + break + } + } + assert.NotNil(t, injectionFinding, "Expected to find injection finding for test_new_fields.yml") + if injectionFinding != nil { + assert.NotEmpty(t, injectionFinding.Meta.InjectionSources, "InjectionSources should be populated") + assert.Contains(t, injectionFinding.Meta.InjectionSources, "github.event.issue.title") + } + + // Test lotp_tool and referenced_secrets fields + var lotpFinding *results.Finding + for idx, f := range scannedPackage.FindingsResults.Findings { + if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" { + lotpFinding = &scannedPackage.FindingsResults.Findings[idx] + break + } + } + assert.NotNil(t, lotpFinding, "Expected to find untrusted_checkout_exec finding for test_new_fields.yml") + if lotpFinding != nil { + assert.Equal(t, "npm", lotpFinding.Meta.LOTPTool, "LOTPTool should be 'npm'") + assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "API_KEY") + assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "DATABASE_PASSWORD") + assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "DEPLOY_TOKEN") + assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "ENABLE_BUILD") + assert.NotContains(t, lotpFinding.Meta.ReferencedSecrets, "GITHUB_TOKEN", "GITHUB_TOKEN should be excluded") + } +} diff --git a/scanner/testdata/.github/workflows/test_new_fields.yml b/scanner/testdata/.github/workflows/test_new_fields.yml new file mode 100644 index 00000000..de0bd9a2 --- /dev/null +++ b/scanner/testdata/.github/workflows/test_new_fields.yml @@ -0,0 +1,43 @@ +name: test_new_fields +on: + pull_request_target: + +jobs: + vulnerable_injection: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Injection vulnerability - should populate injection_sources + - name: Vulnerable echo + run: | + echo "${{ github.event.issue.title }}" + + vulnerable_checkout: + runs-on: ubuntu-latest + # Secret in if expression + if: ${{ secrets.ENABLE_BUILD != '' }} + steps: + - name: Checkout PR code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + # LOTP - should populate lotp_tool and referenced_secrets + - name: Install dependencies + run: | + # Secret directly in run script + curl -H "Authorization: ${{ secrets.API_KEY }}" https://api.example.com + npm install + env: + # Secrets in env block + DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy + uses: some/action@v1 + with: + # Secret in with block + token: ${{ secrets.DEPLOY_TOKEN }} + password: ${{ secrets.DATABASE_PASSWORD }} From 1cc2923a58ce2c5572df4b70882275534a9969df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Proulx?= Date: Mon, 12 Jan 2026 16:33:19 -0500 Subject: [PATCH 2/3] Use hex.EncodeToString for faster fingerprint encoding Fixes perfsprint linter warning by replacing fmt.Sprintf("%x", ...) with hex.EncodeToString which is more performant. Co-Authored-By: Claude Opus 4.5 --- results/results.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/results/results.go b/results/results.go index c10e9d4d..8cddfcdf 100644 --- a/results/results.go +++ b/results/results.go @@ -2,8 +2,8 @@ package results import ( "crypto/sha256" + "encoding/hex" "encoding/json" - "fmt" "strconv" "github.com/rs/zerolog/log" @@ -42,7 +42,7 @@ func (f *Finding) GenerateFindingFingerprint() string { h := sha256.New() h.Write([]byte(fingerprintString)) fingerprint := h.Sum(nil) - return fmt.Sprintf("%x", fingerprint) + return hex.EncodeToString(fingerprint) } func (m *FindingMeta) UnmarshalJSON(data []byte) error { From 49a4819ed58a698610f1b9ed0fa7df155782378c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Proulx?= Date: Mon, 12 Jan 2026 16:41:48 -0500 Subject: [PATCH 3/3] Use require.NoError for error assertions in test Fixes testifylint warning by using require.NoError instead of assert.NoError for error checking in TestStructuredFindingFields. Co-Authored-By: Claude Opus 4.5 --- scanner/inventory_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scanner/inventory_test.go b/scanner/inventory_test.go index 38937439..935ed377 100644 --- a/scanner/inventory_test.go +++ b/scanner/inventory_test.go @@ -9,6 +9,7 @@ import ( "github.com/boostsecurityio/poutine/models" "github.com/boostsecurityio/poutine/opa" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPurls(t *testing.T) { @@ -724,7 +725,7 @@ func TestStructuredFindingFields(t *testing.T) { _ = pkg.NormalizePurl() scannedPackage, err := i.ScanPackage(ctx, *pkg, "testdata") - assert.NoError(t, err) + require.NoError(t, err) // Test injection_sources field - find the specific injection finding var injectionFinding *results.Finding