Skip to content

fix(github,policies): point GitHub findings at the line that fired - #574

Open
Exypte wants to merge 4 commits into
mainfrom
fix/572-findings-precise-line
Open

Exypte wants to merge 4 commits into
mainfrom
fix/572-findings-precise-line

Conversation

@Exypte

@Exypte Exypte commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What type of PR is this?

fix

What this PR does / why we need it:

When a rule emitted no line, the finding fell back to its job header (internal/engine/opa/engine.go). A template injection in a job's fifth step, an unpinned container image and an inherited write-all all landed on jobs.<id>:. This is the GitHub half of #572.

  • Collector. A new yaml.v3 pass, scanGitHubJobLocations (github/github_locations.go), records per job:
    • the line of every run: script line: a literal block maps line for line; a folded, plain or quoted scalar reports its start line;
    • the container image line;
    • the line of the env: entry that sets each variable (step over job over workflow, as mergedEnv merges them);
    • each job key's line. permissions falls back to the workflow-level key when the job inherits it.
  • IR. These land on new provider-neutral fields: Image.Line, Job.ScriptLines, Job.VariableLines, Job.KeyLines.
  • Rules. They read the fields through a shared lib.location helper:
Code Now points at
101, 102, 103 the container image:
203 the env: entry, or the $GITHUB_ENV write
207, 209, 309, 411 the script line that fires
302 secrets: inherit
410 continue-on-error
701, 713 (reusable call) the job's uses:
802 the checkout step that is the finding's subject
803 the permissions: the job runs with

Which issue(s) this PR is related to:

Fixes #572. The GitLab half, including the include-level findings that have no location at all, is #573.

Special notes for your reviewer:

  • Fingerprints don't move. line is not an identity input. The e2e below checks this: identical fingerprint sets on main and on this branch.
  • One location per finding. Several findings are one per job (207, 209 direct, …), so each rule takes the first line that fires, through location.first. Binding the line to the iteration variable would have split one finding into several that share a fingerprint.
  • Alignment guard. The decoder (yaml.v2) and the line scan (yaml.v3) read run: with different YAML versions: run: yes is a boolean to one and a string to the other. ScriptLines is set only when both find the same number of scripts.
  • Nested jobs. Jobs of a workflow called from another repository drop these lines (clearJobLocations in github/nested_workflows.go), since they belong to another file. Those findings keep landing where they did.
  • GitLab is unchanged. It records none of these lines yet, so its rules read 0 and keep the job-header fallback.
  • Out of scope:
    • 412/413 can't fire from services: on GitHub, because the collector doesn't read services: at all.
    • 801 (missing permissions:) correctly stays on the job header.
    • Rules that match a whole job or a whole script rather than one line still use the job header: 208, 212, 213, 214, 215, 303, 308, 420, plus 704 (container credentials). They can adopt lib.location later.
  • One line per identity. Rule branches that share an identity report the same line: 203 (env entry and $GITHUB_ENV write), and 209's env-bound branches (inline write and heredoc). Otherwise one fingerprint would show up as two results.
  • Other details. KeyLines records every job key, not just the ones used today. Matrix images take the container reference's line.

Does this PR introduce a user-facing change?

GitHub findings for ISSUE-101, 102, 103, 203, 207, 209, 302, 309, 410, 411, 701 and 713 (reusable calls), 802 and 803 now point at the line that triggered them (the run: line, image, env entry or job key) instead of the job header, in the text report, JSON, SARIF and every other output. Fingerprints, and so dismissals, are unchanged.

How was this tested?

  • Unit:
    • github/github_locations_test.go: script lines for literal, plain and folded run:; image lines in string and mapping form; env precedence; key lines including inherited permissions; the alignment guard; a nested callee that keeps no lines.
    • policies/finding_line_test.go: every code in the table through the real collector; shared-identity branches reporting one line; a GitLab job keeping the job-header fallback.
    • go test ./... passes.
  • Real GitHub e2e. I pushed the fixture below to a parentless branch of a private scratch repository. Its nested job calls a workflow on a branch of a second scratch repository. I analyzed it with plumber analyze github.com/<owner>/<repo>/tree/<branch>, once with a binary built from main and once with this branch:
    • Fingerprints: identical sets (no finding added or lost, no fingerprint changed).
    • Nested callee: ci/nested/inner findings keep the same location as on main.
Fixture workflow
name: loc
on: [pull_request_target, issue_comment]
permissions: write-all
jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: node:latest
    env:
      ACTIONS_STEP_DEBUG: true
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: safe
        run: echo safe
      - name: inject
        run: |
          echo start
          echo "${{ github.event.pull_request.title }}"
          echo "T=${{ github.event.pull_request.body }}" >> "$GITHUB_ENV"
          curl -sSL https://example.com/install.sh | bash
          echo '${{ toJSON(secrets) }}'
  codeql-scan:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - run: echo scan
  call:
    uses: evilcorp/shared/.github/workflows/x.yml@main
    secrets: inherit
  short:
    runs-on: ubuntu-latest
    container: alpine:latest
    permissions:
      contents: read
    steps:
      - run: echo "${{ github.event.comment.body }}"
  nested:
    uses: <owner>/<other-repo>/.github/workflows/callee.yml@<branch>

Callee (.github/workflows/callee.yml in the second repository):

on: workflow_call
jobs:
  inner:
    runs-on: ubuntu-latest
    container: node:latest
    steps:
      - run: echo "${{ github.event.pull_request.title }}"
Results: line on main → line on this branch
Code Job main this branch
ISSUE-102 ci/build 5 8
ISSUE-103 ci/build 5 8
ISSUE-102 ci/short 32 34
ISSUE-103 ci/short 32 34
ISSUE-203 ci/build 5 10
ISSUE-207 ci/build 5 20
ISSUE-207 ci/short 32 38
ISSUE-209 ci/build 5 21
ISSUE-411 ci/build 5 22
ISSUE-309 ci/build 5 23
ISSUE-802 ci/build 5 12
ISSUE-803 ci/build 5 3
ISSUE-803 ci/codeql-scan 24 3
ISSUE-803 ci/call 29 3
ISSUE-803 ci/nested 39 3
ISSUE-410 ci/codeql-scan 24 26
ISSUE-302 ci/call 29 31
ISSUE-701 ci/call 29 30
ISSUE-713 ci/call 29 30
ISSUE-701 ci/nested 39 40
ISSUE-307, ISSUE-804 ci/build 12 12 (already exact)
ISSUE-102, 103, 207 ci/nested/inner callee.yml:3 callee.yml:3 (unchanged)

AI usage disclosure:

YES. Claude Code (Claude Opus 5.5) did the investigation, implementation, tests and e2e run, under the author's direction. The author is responsible for the submitted changes (CONTRIBUTING.md, AI_POLICY.md).

🤖 Generated with Claude Code

A finding whose rule emitted no line fell back to its job header, so a
template injection in the fifth step of a job, an unpinned container
image or an inherited write-all permission all landed on `jobs.<id>:`.

The GitHub collector now reads, off the YAML node tree, the line of
every run: script line, of the container image, of the env: entry that
sets each variable and of each job key (permissions falling back to the
workflow-level key). The rules report them through a shared
lib.location helper: 102, 103, 203, 207, 209, 302, 309, 410, 411, 701
and 713 (reusable calls), 802 and 803. A line that is unknown stays 0
and keeps the job-header fallback, so GitLab is unchanged for now.

Line is not an identity input: every fingerprint is unchanged. Each
rule takes the first line that fires, so a finding keeps the one
location it reports. Jobs of a called workflow drop these lines, which
belong to another file, and keep landing on the calling job.

Refs #572. The GitLab half is #573.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Exypte Exypte self-assigned this Oct 23, 2026
Comment thread policies/github_env_injection.rego Outdated
Comment thread policies/overprovisioned_secrets.rego
Review of the #572 change: branches of one rule that share an identity
reported different lines, so one fingerprint showed as two results.
ISSUE-203 takes the first of its env: entry and its $GITHUB_ENV writes,
and ISSUE-209's env-bound branches the first write of the variable that
fires for its value. A $GITHUB_ENV write whose name sits on a heredoc
line points at the script's first line instead of the job header.

ISSUE-101 reports the image line, as 102 and 103 do, and the script
lines pair with the decoded scripts only when the values match, not
just the count.

Refs #572.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread policies/github_env_injection.rego
Comment thread policies/overprovisioned_secrets.rego
ISSUE-209's env-bound writes to $GITHUB_ENV and $GITHUB_PATH, inline
and through a heredoc, and ISSUE-309's secrets dump in env: and in a
step input each get a job of their own, so a helper falling back to
the job header fails the test.

Refs #572.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread github/github_locations.go
The images a called job's container resolves to through its matrix
literals carry the callee's container line once parsed, a line of
another file, and only the nested resolution drops it. Images resolved
from the call's inputs never carry one. Pin both.

Refs #572.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Exypte

Exypte commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

PR ready to review @thomasboni

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Findings point at the job header (or nowhere) instead of the offending line, on GitHub

1 participant