GitHub Actions run forensics
Where the github provider reasons about what a workflow could do,
the runs provider audits what actually executed. It pulls recent
Actions runs via the REST API
(GET /repos/{owner}/{repo}/actions/runs) and flags runs that fired on
a privileged trigger (pull_request_target / workflow_run) and, in
particular, any whose head came from a fork: untrusted code that ran
with the base repository's secrets and a write-scoped GITHUB_TOKEN.
That is the live shape of the tj-actions/changed-files (CVE-2025-30066)
and GhostAction incidents, which were visible in run history before
anyone read the workflow file.
Findings carry the run's URL, actor, and trigger so an operator can open the run directly. A missing token, a 404, or a network error degrades to a warning (every rule then sees an empty run list and passes) rather than crashing the scan.
Producer workflow
# Token comes from --gh-token or $GITHUB_TOKEN (needs ``actions:read``).
pipeline_check --pipeline runs --scm-repo owner/name \
--gh-token "$GITHUB_TOKEN"
What it covers
7 checks · 0 have an autofix patch (--fix).
| Check | Title | Severity | Fix |
|---|---|---|---|
| RUN-001 | Fork PR executed on a privileged trigger | HIGH | |
| RUN-002 | Privileged trigger exercised in run history | MEDIUM | |
| RUN-003 | Secret leaked in workflow run logs | HIGH | |
| RUN-004 | Fork PR run minted a cloud OIDC token | HIGH | |
| RUN-005 | Fork PR run executed on a self-hosted runner | HIGH | |
| RUN-006 | Known-compromised action executed in run history | CRITICAL | |
| RUN-007 | Third-party action pinned by a mutable tag executed in a privileged run | MEDIUM |
RUN-001: Fork PR executed on a privileged trigger
Sourced from the GitHub Actions REST API (GET /repos/{owner}/{repo}/actions/runs). A run is flagged when its event is a privileged trigger (pull_request_target / workflow_run) and its head_repository is a fork (or differs from the base repository). Unlike the static GHA-002 check this is evidence the dangerous path actually ran, so it survives even when the workflow file has since been deleted or rewritten.
Recommended action
Treat each flagged run as untrusted-code execution in a privileged context. Confirm the workflow that ran does not check out and execute the PR head, and move any build-from-PR logic into a separate unprivileged pull_request workflow (the label-then-build pattern). Rotate any secret the run could read if the workflow is not demonstrably safe.
RUN-002: Privileged trigger exercised in run history
Sourced from the Actions REST API. Counts recent runs whose event is pull_request_target or workflow_run. This is forensic context (the surface is live in production), which the static config scan cannot confirm on its own.
Recommended action
Review the workflows that run on these triggers and confirm none check out or execute PR-controlled content while holding secrets. See RUN-001 for any of these runs that came from a fork (the high-severity subset).
RUN-003: Secret leaked in workflow run logs
Only evaluated with --audit-runs-logs. Downloads each privileged-trigger run's log archive (the Actions REST API .../logs endpoint) and scans the text with the shared secret-shape catalog (find_secret_values). GitHub masks registered secrets, so a match is a credential that leaked past masking. The token value is redacted in the finding.
Recommended action
Rotate the leaked credential immediately, then stop it reaching the log: register it as an Actions secret so GitHub masks it, avoid set -x / env dumps in steps that hold it, and pipe tool output that may echo credentials through a redactor.
RUN-004: Fork PR run minted a cloud OIDC token
Only evaluated with --audit-runs-logs. Reuses the privileged-trigger run logs RUN-003 downloads (the Actions REST API .../logs endpoint) and flags a run whose logs show OIDC token minting (token.actions.githubusercontent.com, the ACTIONS_ID_TOKEN_REQUEST_* env, AWS AssumeRoleWithWebIdentity, or GCP workloadIdentityPools). Scoped to fork-originated runs, so a trusted-branch deploy that uses OIDC normally does not fire. Detection is high-precision but best-effort on recall (log content varies; registered secrets are masked).
Recommended action
Treat this as untrusted code that reached cloud federation: rotate / review the federated role's recent activity and assume the run could act as that role. Restrict the role's trust policy so a fork / PR ref cannot assume it (pin the subject to your protected branches and environments), and move any OIDC-authenticated step out of the privileged pull_request_target / workflow_run path that handles PR content (the label-then-deploy pattern).
RUN-005: Fork PR run executed on a self-hosted runner
Only evaluated with --audit-runs-logs. Fetches job metadata (the Actions REST API .../jobs endpoint) for recent fork-originated runs and flags any whose jobs ran on a self-hosted runner (GitHub adds the self-hosted label to every such runner). Independent of the trigger, so it catches a plain fork pull_request run on your own infrastructure. The fork-run fetch is bounded to the most recent runs.
Recommended action
Do not run fork pull-request code on self-hosted runners. Set the repository / org policy to require approval for first-time (and ideally all) outside-contributor workflow runs, and run any fork-triggered job on GitHub-hosted ephemeral runners instead. If self-hosted runners are required, isolate them (ephemeral / single-use VMs, a locked-down network, no standing cloud credentials) and scope them to trusted workflows only.
RUN-006: Known-compromised action executed in run history
Only evaluated with --audit-runs-logs. Scans the privileged-trigger run logs RUN-003 / RUN-004 already download for GitHub's Download action repository 'owner/repo@ref' (SHA:...) lines and matches both the pinned ref and the resolved commit SHA against the curated GHA-040 known-compromised-action registry (tj-actions/changed-files, reviewdog/action-setup, the 2026 aquasecurity / checkmarx campaigns). Matching the resolved SHA is what catches a tag-repoint: a workflow pinned to @v44 whose tag was force-moved to a malicious commit. Covers both the privileged-trigger run logs and a bounded set of the most recent non-privileged (push / pull_request) runs, since the tj-actions / Trivy / Checkmarx campaigns ran on ordinary CI; recall is bounded to the fetched runs, the IOC match itself is exact.
Recommended action
Treat this as a confirmed supply-chain compromise that ran in your CI: rotate every secret and token that was in scope for the affected run(s), review what the run accessed or pushed, and pin the action to a known-good commit SHA (never a tag, which the attacker can force-move). Cross-check the cited advisory for the clean post-incident version. If the workflow still references the compromised action, fix it now (GHA-040).
RUN-007: Third-party action pinned by a mutable tag executed in a privileged run
Only evaluated with --audit-runs-logs. Reuses the privileged-trigger run logs RUN-003 / RUN-004 download and inspects GitHub's Download action repository 'owner/repo@ref' (SHA:...) lines: a third-party action (not actions / github and not the repo's own owner) whose @ref is a mutable tag or branch rather than a 40-hex commit SHA is flagged, with the resolved SHA carried as evidence. The preventive twin of RUN-006: RUN-006 confirms a known-compromised action ran, RUN-007 flags the repoint-able third-party actions that could be the next one. Scoped to the secret-bearing privileged runs (not the bounded non-privileged RUN-006 pass), so the signal stays high; recall is bounded to the fetched runs.
Recommended action
Pin every third-party action to a full commit SHA rather than a tag or branch, which the upstream (or an attacker who compromises it) can force-move (the tj-actions/changed-files lesson). Use the resolved SHA the run log records as the pin, after confirming it is a known-good release, and consider Dependabot to bump the pinned SHAs. Restricting which actions can run at all (an allow-list) shrinks the third-party surface further.
Adding a new GitHub Actions run forensics check
- Create a new module at
pipeline_check/core/checks/runs/rules/NNN_<name>.pyexporting a top-levelRULE = Rule(...)and acheck(path, doc) -> Findingfunction. The orchestrator auto-discoversRULEand callscheckwith the parsed YAML document. - Add a mapping for the new ID in
pipeline_check/core/standards/data/owasp_cicd_top_10.py(and any other standard that applies). - Drop unsafe/safe snippets at
tests/fixtures/per_check/runs/-NNN.{unsafe,safe}.ymland add aCheckCaseentry intests/test_per_check_real_examples.py::CASES. - Regenerate this doc: