Share
## https://sploitus.com/exploit?id=D5615DD2-5DF9-5847-B2EE-6BC8236A0139
# gha-exploit-guard

Standalone GitHub Action that scans GitHub Actions workflow YAML for exploit patterns and emits SARIF.

Scans for over 100 exploits, from the common to the obscure.

## Usage

```yaml
name: Exploit Guard
on:
  pull_request:

permissions:
  contents: read

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@ # pin v4
      - name: Run exploit-guards
        id: exploit_guards
        uses: your-org/gha-exploit-guard@
        with:
          target-dir: .
          fail-on-findings: false

      - name: Print scanner exit code
        run: echo "exit_code=${{ steps.exploit_guards.outputs.exit_code }}"
```

Pin this action to an immutable commit SHA. Do not use mutable refs such as `@main` in production.

## Runner Requirements

- `bash` must be available on `PATH`.
- `python3` must be available on `PATH`.
- Recommended runner: GitHub-hosted Ubuntu (`ubuntu-latest` or `ubuntu-24.04`).

## Inputs

- `target-dir` (optional, default `.`): Directory to scan, relative to workspace root.
- `fail-on-findings` (optional, default `false`): If `true`, action fails when scanner reports findings (`exit_code = 1`).

## Outputs

- `exit_code`: Exit code from `gha-exploit-guard.sh`.

## Behavior Contract

- Produces `exploit-guards.txt` and `exploit-guards.sarif` in the workspace.
- Exit code semantics:
  - `0`: all checks passed
  - `1`: one or more checks failed (findings)
  - `>1`: scanner/runtime failure
- The action always fails the step for scanner/runtime failure (`exit_code > 1`).
- Findings failure (`exit_code = 1`) is controlled by `fail-on-findings`.

## Full Check Catalog

The action runs the following checks from `gha-exploit-guard.sh`.

| # | Check | What it checks for | Why it matters |
|---:|---|---|---|
| 1 | `check_no_forbidden_event_triggers` | workflows do not use pull_request_target issue_comment or repository_dispatch | pull_request_target, issue_comment, and repository_dispatch execute with secrets while processing attacker-influenceable data, enabling pwn-request attacks. |
| 2 | `check_no_workflow_run_triggers` | workflows do not use workflow_run | workflow_run enables artifact poisoning and unsafe trust handoffs between untrusted and privileged workflows despite being intended as a privilege split. |
| 3 | `check_no_workflow_run_artifact_bridge` | workflow_run artifact download bridges are absent | Downloading artifacts from an untrusted workflow into a privileged job creates a trust bridge -- attacker-controlled artifact contents can achieve code execution. |
| 4 | `check_no_untrusted_github_context_in_run_blocks` | run blocks do not interpolate expanded untrusted github context | Attacker-controlled GitHub context (PR titles, bodies, branch names, commit messages) interpolated into run: blocks enables shell injection. |
| 5 | `check_no_untrusted_github_context_in_github_script_blocks` | github-script blocks do not interpolate untrusted github context | actions/github-script evaluates interpolated expressions before JavaScript runs, making untrusted context injection equivalent to shell injection. |
| 6 | `check_no_untrusted_github_context_in_action_shell_blocks` | local action run blocks do not interpolate untrusted github context | Composite action run: steps are shell execution surfaces with the same injection risk as workflow-level run: blocks. |
| 7 | `check_no_inputs_context_in_shell_like_blocks` | shell-like blocks do not interpolate reusable workflow inputs | Reusable workflow inputs are caller-controlled strings -- interpolating inputs.* into shell blocks is the same injection risk as untrusted event context. |
| 8 | `check_no_untrusted_context_to_env_files` | untrusted github context is never written to env files | Writing attacker-controlled values to \$GITHUB_ENV/\$GITHUB_OUTPUT/\$GITHUB_PATH creates a durable injection primitive affecting all subsequent steps. |
| 9 | `check_no_untrusted_context_in_env_blocks` | env blocks do not expose untrusted github context | Attacker-controlled expressions in env: promote untrusted data into environment variables, enabling downstream command/argument injection. |
| 10 | `check_no_checkout_ref_from_pr_head` | actions checkout refs never use pull request head values | Checking out a PR-head-controlled ref executes attacker-controlled repository contents under the privileged token and permissions. |
| 11 | `check_no_write_scoped_checkout_and_execution` | write scoped jobs do not mix checkout with execution logic | A job combining write permissions + checkout + execution has all ingredients for full compromise if any trust assumption is wrong. |
| 12 | `check_no_secrets_inherit` | workflow files never use secrets inherit | secrets: inherit passes all repository secrets to called workflows, violating least-privilege by exposing secrets the callee does not need. |
| 13 | `check_no_branch_ref_context_in_shell_or_env` | branch and ref github context stays out of shell like and env blocks | Branch/ref names are attacker-controlled in PR contexts and can carry shell metacharacters or path fragments. |
| 14 | `check_no_insecure_commands_enabled` | ACTIONS_ALLOW_UNSECURE_COMMANDS is never enabled | ACTIONS_ALLOW_UNSECURE_COMMANDS re-enables ::set-env::/::add-path:: (CVE-2020-15228), letting any stdout-printing step hijack env vars and PATH. |
| 15 | `check_all_external_action_refs_pinned_to_sha` | all external action refs are pinned to 40-char commit SHA | Mutable tags (@main, @v1) let a compromised upstream repo execute arbitrary code without any change to your files (cf. CVE-2025-30066). |
| 16 | `check_no_untrusted_context_to_step_summary` | untrusted github context is never written to GITHUB_STEP_SUMMARY | Untrusted context in \$GITHUB_STEP_SUMMARY enables attacker-injected markdown that can exfiltrate runner data or mislead reviewers. |
| 17 | `check_no_tojson_sensitive_context_in_shell` | toJSON bulk-dumps of secrets github env and steps context are absent | toJSON(secrets/github/env/steps) dumps entire context objects -- GitHub log masking cannot reliably redact embedded JSON values. |
| 18 | `check_no_spoofable_bot_conditions` | spoofable contains github actor bot conditions are absent | contains(github.actor, '[bot]') is spoofable -- GitHub allows usernames like 'evil[bot]', and github.actor can be manipulated via re-runs. |
| 19 | `check_no_additional_untrusted_context_in_shell` | additional attacker-controlled context absent from shell and env blocks | Fork repo names, PR review bodies, discussion content, release bodies/tags, and commit arrays are attacker-controlled but missed by primary injection checks. |
| 20 | `check_no_obfuscated_expressions` | fromJSON toJSON obfuscation patterns that evade injection guards are absent | fromJSON(toJSON(...)) round-trips the value unchanged, evading string-matching injection guards while achieving the same injection. |
| 21 | `check_no_unsound_if_block_scalars` | if block scalar conditions with expressions are absent | if: \| (YAML block scalar) with \${{ }} always evaluates true because the trailing newline makes the string non-empty, permanently bypassing the gate. |
| 22 | `check_no_unpinned_container_images` | container and service images are pinned to SHA digest | Mutable container image tags allow supply chain injection via registry tag manipulation without any change to this repository. |
| 23 | `check_no_untrusted_context_in_concurrency_group` | concurrency group does not interpolate untrusted context | Attacker-controlled context in concurrency.group lets a PR cancel in-progress main-branch scans, bypassing security coverage. |
| 24 | `check_no_artipacked_broad_upload` | upload-artifact paths do not expose the git credential store | upload-artifact with path: . uploads .git/config containing GITHUB_TOKEN, exposing it to anyone who can download artifacts. |
| 25 | `check_workflow_permissions_explicit_and_minimal` | workflow permissions stay explicit and least-privilege | Omitting permissions uses repository defaults (potentially broad). write-all/read-all are broader than most workflows need. |
| 26 | `check_job_level_permissions` | elevated-permission workflows have explicit job-level permissions | Workflows with elevated top-level permissions (pull-requests: write, security-events: write, etc.) silently grant those scopes to every job that omits an explicit job-level permissions block. A new job added without its own block inherits write scopes it does not need. |
| 27 | `check_checkout_repository_and_credentials_are_safe` | actions checkout keeps credentials off disk and repository static | Persisted checkout credentials remain on disk for subsequent steps. Dynamic repository overrides can redirect checkout to a malicious repo. |
| 28 | `check_no_untrusted_context_in_cache_controls` | cache keys and restore-keys do not use untrusted context | Attacker-controlled expressions in cache keys enable cross-trust cache poisoning -- forcing a malicious cache to be restored in trusted runs. |
| 29 | `check_cache_paths_stay_out_of_workspace` | cache paths stay out of the workspace | Caching workspace paths lets untrusted PR code populate cache entries that trusted jobs later restore and execute. |
| 30 | `check_write_scoped_jobs_do_not_use_cache` | write scoped jobs do not use actions cache | actions/cache in write-scoped jobs combines mutable cross-run state with write tokens, widening blast radius of any poisoning or injection. |
| 31 | `check_no_known_compromised_actions` | known-compromised action families and SHAs are denied | SHA pinning does not help when the action itself is compromised (CVE-2025-30066, CVE-2025-30154). Deny-listed actions must be removed entirely. |
| 32 | `check_no_id_token_write_outside_allowlist` | OIDC id-token write is absent outside an explicit allowlist | id-token: write enables OIDC minting that can be exchanged for cloud credentials -- this must remain opt-in and allowlisted. |
| 33 | `check_no_old_inputs_syntax_in_shell_blocks` | legacy github.event.inputs context stays out of shell-like blocks | github.event.inputs.* is the deprecated form of inputs.* and equally injectable -- it bypasses the modern inputs context guard. |
| 34 | `check_no_elevated_token_in_checkout` | actions checkout does not use elevated PAT or custom token | A PAT in checkout has broader scope than GITHUB_TOKEN. Compromise of any later step captures an org-wide token (cf. CVE-2025-30066). |
| 35 | `check_all_jobs_have_timeout_minutes` | all jobs have explicit timeout-minutes | Default timeout is 360min (6h). Without an explicit timeout, compromised jobs can mine crypto, persist, or exhaust CI minutes. |
| 36 | `check_no_format_wrapping_untrusted_context_in_shell` | format() does not wrap untrusted context in shell or env blocks | format('{0}', untrusted_context) achieves the same shell injection as direct interpolation but evades string-matching guards. |
| 37 | `check_no_deprecated_workflow_commands_in_run` | deprecated workflow commands are absent from run blocks | ::set-output, ::save-state, ::set-env, ::add-path are deprecated (CVE-2020-15228) -- any stdout-printing step can hijack env vars or PATH. |
| 38 | `check_no_hardcoded_credentials_in_workflows` | no hardcoded credentials in workflow or action files | Credentials hardcoded in YAML are visible to anyone who reads the repository and persist in git history. |
| 39 | `check_no_untrusted_context_in_action_with_inputs` | untrusted context is absent from action with inputs | Some actions shell out internally, making untrusted context in with: inputs equivalent to shell injection. |
| 40 | `check_no_curl_pipe_bash_patterns` | run blocks do not pipe curl or wget to a shell interpreter | curl\|bash fetches and executes remote code without integrity verification -- the pattern exploited in the Codecov breach (2021, 2.5 months undetected). |
| 41 | `check_no_runner_persistence_techniques` | runner persistence and escape techniques are absent from run blocks | RUNNER_TRACKING_ID=0 bypasses cleanup, RUNNER_ALLOW_RUNASROOT=1 enables root escalation, nohup enables persistence -- used by the Shai-Hulud worm (2025). |
| 42 | `check_additional_untrusted_author_context_absent` | author committer and pages context absent from shell and env blocks | Git author/committer fields and wiki page names are attacker-controlled (git allows arbitrary values) but missed by standard context lists. |
| 43 | `check_no_implicit_caching_in_setup_actions` | write-scoped jobs do not use implicit setup-action caching | Setup actions with cache: enabled in write-scoped jobs create the same poisoning risk as explicit actions/cache. |
| 44 | `check_no_known_vulnerable_action_versions` | critical actions meet minimum safe version requirements | Specific action versions have known CVEs: checkout .permissions on external reusable workflow calls can fall back to broader default GITHUB_TOKEN permissions than intended. |
| 63 | `check_no_secrets_in_job_outputs` | secrets are not mapped to job outputs | Mapping secrets to job outputs exposes them to downstream jobs via the needs context, widening the leakage surface. |
| 64 | `check_no_permissions_in_composite_action` | composite actions do not define permissions (ignored) | Composite actions cannot define permissions. Presence implies the author expects sandboxing that does not exist. |
| 65 | `check_no_checkout_clean_false` | actions/checkout does not disable clean workspace | actions/checkout with clean: false persists the workspace on self-hosted runners, enabling cross-run contamination. |
| 66 | `check_no_global_env_token_exposure` | GITHUB_TOKEN is not defined in global env block | Defining GITHUB_TOKEN in the top-level env block exposes it to every job and step, violating least privilege. |
| 67 | `check_no_secrets_in_global_env` | top-level env block does not contain secret expressions | Top-level env is inherited by every job and step. Placing secrets there broadens exposure across the entire workflow. |
| 68 | `check_no_dynamic_uses` | uses: directives are not dynamic expressions | Dynamic uses: expressions allow executing arbitrary code/actions determined at runtime, bypassing static analysis and pinning checks. |
| 69 | `check_no_checkout_submodules` | actions/checkout does not enable submodules | Checking out submodules allows the PR author to point .gitmodules to malicious repositories, potentially triggering SSRF or fetching bad code. |
| 70 | `check_no_secrets_in_input_defaults` | input defaults do not contain secrets | Secrets in workflow_dispatch defaults are exposed in plain text in the GitHub UI to anyone with write access. |
| 71 | `check_no_untrusted_context_in_shell_param` | shell: parameter does not interpolate untrusted context | Untrusted context in shell: parameters enables command injection since the shell invocation string is attacker-controlled. |
| 72 | `check_no_untrusted_context_in_working_directory` | working-directory does not interpolate untrusted context | Untrusted context in working-directory enables path traversal โ€” an attacker-controlled branch name can escape the workspace. |
| 73 | `check_no_needs_output_in_shell_blocks` | needs.*.outputs are not interpolated in shell blocks | Job outputs passed via needs.*.outputs can carry attacker-controlled data (e.g. PR titles, issue bodies) across job boundaries into shell injection sinks. |
| 74 | `check_no_fromjson_untrusted_in_matrix` | fromJSON() does not use untrusted sources in matrix strategy | fromJSON() applied to needs.*.outputs or attacker-controlled context in strategy.matrix propagates tainted data into matrix.* variables used in run: blocks. |
| 75 | `check_no_untrusted_context_in_job_fields` | job control fields do not interpolate untrusted context | Untrusted context in timeout-minutes, continue-on-error, or max-parallel fields lets attackers manipulate job execution flow via expression injection. |
| 76 | `check_no_untrusted_context_in_container_image` | container and service images do not use untrusted context | Untrusted context in container or services image fields lets attackers control which Docker image a job runs in, enabling full code execution. |
| 77 | `check_no_symlinks_in_workflow_tree` | workflow tree does not contain symlinked YAML files | Symlinked YAML files can point to external decoy files at scan time, then be swapped to malicious files before runtime (TOCTOU). |
| 78 | `check_file_size_limits` | YAML files do not exceed size limits | Extremely large YAML files cause excessive CPU consumption during scanning, enabling denial-of-service attacks against the CI pipeline. |
| 79 | `check_no_yaml_anchors_or_aliases` | YAML anchors aliases and merge keys are absent | YAML anchors/aliases and merge keys (
```

## Maintainer Branch Protection Policy

Configure this in GitHub repository settings and keep it enabled:

- Protect the default branch (for example, `main`).
- Require pull requests before merge.
- Require at least one approving review.
- Require these status checks before merge:
  - `ci / regression`
  - `ci / action-smoke`
- Disable force pushes and branch deletion on the protected branch.