poutine scans your CI/CD pipelines (GitHub Actions, GitLab CI, Azure DevOps, Tekton) and detects 13 kinds of vulnerability: untrusted code execution, injections, over-exposed secrets, if conditions that are always true, deceptive auto-merge. This guide shows how to install it, scan a local repository or a whole organisation, understand the results and wire poutine into your CI. Prerequisites: Homebrew (or Docker) and a repository holding CI/CD workflows.
What poutine is
poutine is a security scanner built by BoostSecurity.io that detects misconfigurations and vulnerabilities in the build pipelines of a repository. It parses CI/CD workflow files and applies security rules written in Rego, the policy language of Open Policy Agent.
What sets poutine apart
Multi-platform
It analyses GitHub Actions, GitLab CI, Azure DevOps and Tekton Pipelines as Code. One tool for every CI/CD platform you run.
Organisation-wide scan
It can scan every repository of an organisation in a single command with analyze_org, to get a global picture.
OPA and Rego rules
Detection rules are written in Rego (Open Policy Agent), a declarative policy language. You can write your own rules.
Built-in CVE database
It detects actions and platforms with known vulnerabilities (CVEs) through the OSV database, on top of misconfigurations.
poutine against zizmor
The two tools complement each other. Their main differences:
| Criterion | poutine | zizmor |
|---|---|---|
| Platforms | GitHub Actions, GitLab CI, Azure DevOps, Tekton | GitHub Actions only |
| Language | Go plus Rego (OPA) | Rust |
| Scope | A whole organisation | Local files or a single repository |
| Custom rules | Yes (Rego files) | No |
| Auto-fix | No | Yes (--fix) |
| CVE database | Yes (OSV) | No |
| Rules | 13 rules | 41 rules |
| Ideal use | Organisation audit, multi-CI | Daily local scan, GitHub Actions |
Prerequisites
Before starting, make sure you have:
- Homebrew (the simplest installation) or Docker
- A Git repository holding CI/CD workflows
- A GitHub token (to scan remote repositories or organisations)
- A terminal on Linux, macOS or Windows (WSL recommended)
To scan a local repository, no token is needed. The GitHub token is only required for the analyze_repo and analyze_org commands.
Installing poutine
The simplest method on Linux and macOS:
brew install poutineWithout installing anything on your machine:
docker run -e GH_TOKEN ghcr.io/boostsecurityio/poutine:1.1.6@sha256:722a8e0999b583c1540fe2974e691032b2d9d21b9256a17965132b6bfd0081b0To scan a local repository with Docker, mount the volume:
docker run --rm -v $(pwd):/workspace \ ghcr.io/boostsecurityio/poutine:1.1.6@sha256:722a8e0999b583c1540fe2974e691032b2d9d21b9256a17965132b6bfd0081b0 \ analyze_local /workspaceDownload the binary from the releases page and add it to your $PATH:
# Example for Linux x86_64 (tar.gz archive since v1.1.5)curl -Lo poutine.tar.gz https://github.com/boostsecurityio/poutine/releases/download/v1.1.6/poutine_Linux_x86_64.tar.gztar xzf poutine.tar.gz poutinechmod +x poutinesudo mv poutine /usr/local/bin/Check: confirm the installation and the version:
poutine versionExpected result:
Version: 1.1.6Commit: 8918c66db19ecfd12b2f8379e445c3da4589e599Your first local scan
The simplest command analyses a local repository with no token at all:
cd my-projectpoutine analyze_local .Reading the output
poutine prints results as tables grouped by rule. A real example:
Rule: Injection with Arbitrary External Contributor InputSeverity: warningDescription: The pipeline contains an injection into bash or JavaScript withan expression that can contain user input.Documentation: https://boostsecurityio.github.io/poutine/rules/injection
┌─────────────────┬──────────────────────────────────────────────────┬──────────────────────────────────────┐│ REPOSITORY │ DETAILS │ URL │├─────────────────┼──────────────────────────────────────────────────┼──────────────────────────────────────┤│ localrepo/local │ .github/workflows/ci.yml │ /tree/HEAD/.github/workflows/ci.yml ││ │ Job: respond │ ││ │ Step: 0 │ ││ │ Sources: github.event.comment.body │ │└─────────────────┴──────────────────────────────────────────────────┴──────────────────────────────────────┘Every finding carries:
| Element | Meaning |
|---|---|
| Rule | The name of the rule that was violated |
| Severity | error (critical), warning (important), note (informational) |
| Description | What poutine detected and why it is risky |
| Documentation | Link to the rule page, with examples and remediation |
| Repository | The repository analysed |
| Details | File, job, step and sources involved |
At the end of the scan, a summary table lists every rule with its status:
Summary of findings:┌────────────────────────────────┬────────────────────────────────────────────┬──────────┬────────┐│ RULE ID │ RULE NAME │ FAILURES │ STATUS │├────────────────────────────────┼────────────────────────────────────────────┼──────────┼────────┤│ injection │ Injection with External Contributor Input │ 1 │ Failed ││ untrusted_checkout_exec │ Arbitrary Code Execution from Untrusted │ 2 │ Failed ││ default_permissions_on_risky… │ Default permissions used on risky events │ 3 │ Failed ││ known_vulnerability_in_build… │ Build Component with Known Vulnerability │ 0 │ Passed │└────────────────────────────────┴────────────────────────────────────────────┴──────────┴────────┘Scanning a remote repository or an organisation
A remote GitHub repository
To scan a repository without cloning it, supply a GitHub token with read access:
export GH_TOKEN=$(gh auth token)poutine analyze_repo my-org/my-repo --token "$GH_TOKEN"A whole organisation
The most powerful poutine command scans every repository of a GitHub organisation in parallel:
poutine analyze_org my-org --token "$GH_TOKEN"Useful options for large organisations:
# Skip forkspoutine analyze_org my-org --token "$GH_TOKEN" --ignore-forks
# Parallelise across 8 threads (default: 2)poutine analyze_org my-org --token "$GH_TOKEN" --threads 8A GitLab instance
poutine supports GitLab too, self-hosted or gitlab.com:
export GL_TOKEN="your-gitlab-token"poutine analyze_org my-group/my-project \ --token "$GL_TOKEN" \ --scm gitlab \ --scm-base-url https://gitlab.example.comThe main vulnerabilities it detects
poutine carries 13 rules covering the most critical CI/CD pipeline vulnerabilities. Here are the most important ones, by severity.
Untrusted code execution (untrusted_checkout_exec)
Severity: error. The most dangerous vulnerability poutine detects.
The workflow checks out code coming from a fork (through pull_request_target) then runs a tool that consumes files from disk: npm install, make, pip install, gradle build and so on. Those tools are known as LOTP (Living Off The Pipeline): they read configuration files (package.json, Makefile, setup.py) that can hold malicious code controlled by the attacker.
on: pull_request_targetjobs: test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} # ❌ DANGEROUS: runs fork code with the target repository secrets - run: npm install && npm test - run: make buildFix: never run build commands on untrusted code inside a pull_request_target context. Use the plain pull_request trigger instead, which grants no access to secrets, or split the workflow into two jobs with a label-based gate.
Injection through user input (injection)
Severity: warning. GitHub Actions expressions interpolated straight into a run: block allow code injection.
steps: - name: Process comment run: | # ❌ DANGEROUS: the attacker controls the comment body echo "Body: ${{ github.event.comment.body }}" echo "Issue: ${{ github.event.issue.title }}"Fix: pass the values through environment variables:
steps: - name: Process comment run: | # ✅ SAFE: the values arrive through environment variables echo "Body: ${COMMENT_BODY}" echo "Issue: ${ISSUE_TITLE}" env: COMMENT_BODY: ${{ github.event.comment.body }} ISSUE_TITLE: ${{ github.event.issue.title }}An if condition that is always true (if_always_true)
Severity: error. A subtle trap in GitHub Actions syntax.
When you use ${{ }} inside a multi-line if condition with |, GitHub Actions evaluates the expression into a string then checks whether it is truthy. The problem: the spaces and newlines around the expression make it always true.
# ❌ DANGEROUS: this condition is ALWAYS trueif: | ${{ github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]' }}The expression evaluates to a string holding whitespace ("\n true\n"), and any non-empty string is truthy in GitHub Actions.
# ✅ CORRECT: a single line, with no ${{ }}if: github.actor == 'dependabot[bot]' || github.actor == 'renovate[bot]'Deceptive auto-merge (confused_deputy_auto_merge)
Severity: error. The confused deputy attack.
The workflow auto-merges a pull request after checking only that github.actor is dependabot[bot]. An attacker can trigger a Dependabot action on a fork pull request holding malicious code, and the workflow merges it automatically.
# ❌ Checks the actor only, not where the code comes fromon: pull_request_targetjobs: automerge: if: ${{ github.actor == 'dependabot[bot]' }} steps: - run: gh pr merge --auto --squash "$PR_URL"Fix: check that the pull request does not come from a fork:
# ✅ Checks the PR is not from a fork AND that the author is Dependabotif: >- !github.event.pull_request.head.repo.fork && github.event.pull_request.user.login == 'dependabot[bot]'Over-exposed secrets (job_all_secrets)
Severity: warning. Injecting every secret into a job exposes sensitive information for no reason.
env: # ❌ Exposes EVERY repository secret ALL_SECRETS: ${{ toJSON(secrets) }}Fix: expose only the secrets the job needs:
env: # ✅ One secret, the one this task requires DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}Default permissions on risky events (default_permissions_on_risky_events)
Severity: warning. Without an explicit permissions: block on a workflow triggered by pull_request_target or issue_comment, the workflow inherits the default permissions. On older organisations those defaults are often read-write on everything.
# ❌ No permissions declared, plus a risky triggeron: pull_request_targetjobs: build: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0Fix: always declare minimal permissions:
# ✅ Explicit, minimal permissionson: pull_request_targetpermissions: {}jobs: build: runs-on: ubuntu-24.04 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1Unverified script execution (unverified_script_exec)
Severity: note. The curl | bash pattern downloads and runs a remote script without checking its integrity. In CI that pattern runs on every build, and the odds of pulling a compromised script grow over time.
# ❌ No integrity check at allcurl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bashbash <(curl -s https://codecov.io/bash)Fix: use a SHA-pinned action, or verify the script checksum:
steps: # ✅ An official, pinned GitHub action - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1Debugging enabled (debug_enabled)
Severity: note. Enabling ACTIONS_RUNNER_DEBUG or ACTIONS_STEP_DEBUG raises log verbosity and can expose extended debug logs holding sensitive information.
# ❌ Debug enabled in productionenv: ACTIONS_RUNNER_DEBUG: trueSelf-hosted runner on pull requests (pr_runs_on_self_hosted)
Severity: warning. A job using a self-hosted runner on a pull_request event lets external contributors run code on your own infrastructure. Even without access to secrets, an attacker can usually obtain sudo on most runners and exfiltrate data.
# ❌ Self-hosted runner reachable by forkson: pull_requestjobs: test: runs-on: self-hosted # an external contributor can run code hereConfiguring poutine with .poutine.yml
For a reproducible check, create a .poutine.yml file at the root of your repository. It lets you ignore findings and include custom rules.
Ignoring findings (skip)
Each skip entry can filter by rule, path, level, job or purl:
skip: # Ignore every note-level finding - level: note
# Ignore one rule for specific workflows - rule: unverified_script_exec path: - .github/workflows/setup.yml - .github/workflows/install.yml
# Ignore a rule globally - rule: unpinnable_action
# Ignore a specific action (by purl) - rule: github_action_from_unverified_creator_used purl: - pkg:githubactions/dorny/paths-filterIgnoring from the command line
To ignore rules one off, without touching the configuration file:
# Ignore a single rulepoutine analyze_local . --skip debug_enabled
# Ignore several rulespoutine analyze_local . --skip debug_enabled --skip unverified_script_execIncluding custom rules
poutine supports custom Rego rules. Add a rules directory to your configuration:
include: - path: ./custom_rulesThen create a Rego file in that directory:
# METADATA# title: Docker image using latest tag# description: Detects usage of :latest tag in container images# custom:# level: warning
package rules.no_latest_tag
import data.poutineimport rego.v1
rule := poutine.rule(rego.metadata.chain())
results contains poutine.finding(rule, pkg.purl, { "path": workflow.path, "job": job.id, "details": "Container uses :latest tag",}) if { pkg := input.packages[_] workflow := pkg.github_actions_workflows[_] job := workflow.jobs[_] job.container.image endswith(job.container.image, ":latest")}Output formats
poutine supports three output formats:
| Format | Command | Use |
|---|---|---|
pretty | -f pretty | Readable tables in the terminal (default) |
json | -f json | Automated parsing by scripts |
sarif | -f sarif | Upload to GitHub Advanced Security |
Processing the JSON with jq:
# List findings by rule, with their countpoutine analyze_local . -f json 2>/dev/null \ | jq '.findings | group_by(.rule_id) | map({rule: .[0].rule_id, count: length})'poutine also exposes an MCP server through the poutine mcp-server subcommand. An MCP-capable assistant can then launch a scan and query the findings without leaving your editor.
Wiring poutine into your CI
GitHub Actions with a SARIF upload
The recommended integration uses the SARIF format to surface findings in the GitHub Security tab:
name: Pipeline security auditon: push: branches: [main] paths: - '.github/workflows/**' pull_request: paths: - '.github/workflows/**'
permissions: {}
jobs: poutine: name: poutine scan runs-on: ubuntu-24.04 permissions: security-events: write contents: read steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false
- name: poutine scan uses: boostsecurityio/poutine-action@e240ebd3eff8b2db5a8e5f6b28f58739d7db2247 # v1.1.4 # The action generates results.sarif automatically
- name: Upload the SARIF results uses: github/codeql-action/upload-sarif@fc7e4a0fa01c3cca5fd6a1fddec5c0740c977aa2 # v3.28.14 with: sarif_file: results.sarif category: poutineA plain scan that fails the pipeline
If you do not need SARIF:
name: Pipeline securityon: pull_request: paths: - '.github/workflows/**'
permissions: {}
jobs: poutine: runs-on: ubuntu-24.04 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Install poutine run: | curl -Lo poutine.tar.gz https://github.com/boostsecurityio/poutine/releases/download/v1.1.6/poutine_Linux_x86_64.tar.gz tar xzf poutine.tar.gz poutine chmod +x poutine sudo mv poutine /usr/local/bin/
- name: Audit the workflows run: poutine analyze_local . --fail-on-violationWithout the --fail-on-violation flag, poutine always exits with status 0, even when findings exist, so the job would never block. With the flag, poutine returns status 10 as soon as a violation is detected, which fails the pipeline.
A scheduled organisation audit
For a regular audit of your whole organisation:
name: Organisation security auditon: schedule: - cron: '0 6 * * 1' # every Monday at 06:00
permissions: {}
jobs: audit: runs-on: ubuntu-24.04 permissions: security-events: write steps: - name: Install poutine run: | curl -Lo poutine.tar.gz https://github.com/boostsecurityio/poutine/releases/download/v1.1.6/poutine_Linux_x86_64.tar.gz tar xzf poutine.tar.gz poutine chmod +x poutine sudo mv poutine /usr/local/bin/
- name: Scan the organisation run: | poutine analyze_org ${{ github.repository_owner }} \ --token "$GH_TOKEN" \ --ignore-forks \ --threads 4 \ -f sarif > results.sarif env: GH_TOKEN: ${{ secrets.ORG_READ_TOKEN }}
- name: Upload the results uses: github/codeql-action/upload-sarif@fc7e4a0fa01c3cca5fd6a1fddec5c0740c977aa2 # v3.28.14 with: sarif_file: results.sarif category: poutine-orgThe rules at a glance
| Rule | Severity | What it detects |
|---|---|---|
untrusted_checkout_exec | error | Fork code checked out, then executed (npm, make, pip and so on) |
if_always_true | error | An if condition always true because of the YAML syntax |
confused_deputy_auto_merge | error | Auto-merge based on a spoofable github.actor |
injection | warning | Injection through ${{ }} inside a run: block |
job_all_secrets | warning | toJSON(secrets) or dynamic access to secrets |
default_permissions_on_risky_events | warning | No permissions: on pull_request_target |
pr_runs_on_self_hosted | warning | Self-hosted runner reachable by forks |
known_vulnerability_in_build_component | warning | Third-party action with a known CVE (OSV database) |
known_vulnerability_in_build_platform | warning | CI platform with a known CVE |
debug_enabled | note | ACTIONS_RUNNER_DEBUG or ACTIONS_STEP_DEBUG enabled |
unpinnable_action | note | Action whose internal dependencies are not pinned |
unverified_script_exec | note | curl | bash with no integrity check |
github_action_from_unverified_creator_used | note | Action from an unverified Marketplace creator |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
not a git repository | Repository not initialised | Run git init before analyze_local |
| No finding shown | No CI/CD workflow | Check that .github/workflows/ holds YAML files |
token required | Token missing for analyze_repo or analyze_org | Export GH_TOKEN or pass --token |
known_vulnerability_in_build_component findings | Actions with known CVEs | Update the actions to non-vulnerable versions |
rate limit exceeded | Too many GitHub API requests | Lower --threads or use a token with more quota |
| A false positive on one rule | Rule too strict for your context | Add a skip entry in .poutine.yml |
invalid configuration | Wrong .poutine.yml syntax | Check the YAML indentation |
Key points
-
poutine scans CI/CD pipelines (GitHub Actions, GitLab CI, Azure DevOps, Tekton) for 13 kinds of supply chain vulnerability.
-
analyze_orgis the most powerful command: it audits a whole organisation in one go, for a global picture of pipeline security. -
untrusted_checkout_execis the most critical finding: fork code checked out and then fed tonpm installormakeallows arbitrary code execution with the target repository secrets. -
if_always_trueis a subtle trap: using${{ }}inside a multi-lineifcondition makes that condition always true, even when the expression itself is false. -
The SARIF format surfaces findings in the GitHub Security tab, for tracking over time.
-
.poutine.ymllets you ignore false positives precisely (by rule, path, level, job or purl) and add custom Rego rules. -
Combine poutine and zizmor: poutine for the organisation audit and multi-CI setups, zizmor for the fast, fine-grained local scan with auto-fix.
Next steps
- The pull_request_target trap: the detail of the
untrusted_checkout_execpoutine flags as critical, and the correct way to handle a fork pull request. - Hardening checklist: what a scanner does not measure, to check by hand before calling a repository clean.
- zizmor: the complementary scanner, faster and with auto-fix, for GitHub Actions only.