Skip to content
Français
CI/CD & Automatisation medium

Scanning your CI/CD pipelines with poutine

40 min de lecture

Read this page in French

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:

Criterionpoutinezizmor
PlatformsGitHub Actions, GitLab CI, Azure DevOps, TektonGitHub Actions only
LanguageGo plus Rego (OPA)Rust
ScopeA whole organisationLocal files or a single repository
Custom rulesYes (Rego files)No
Auto-fixNoYes (--fix)
CVE databaseYes (OSV)No
Rules13 rules41 rules
Ideal useOrganisation audit, multi-CIDaily 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:

Fenêtre de terminal
brew install poutine

Check: confirm the installation and the version:

Fenêtre de terminal
poutine version

Expected result:

Version: 1.1.6
Commit: 8918c66db19ecfd12b2f8379e445c3da4589e599

Your first local scan

The simplest command analyses a local repository with no token at all:

Fenêtre de terminal
cd my-project
poutine analyze_local .

Reading the output

poutine prints results as tables grouped by rule. A real example:

Rule: Injection with Arbitrary External Contributor Input
Severity: warning
Description: The pipeline contains an injection into bash or JavaScript with
an 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:

ElementMeaning
RuleThe name of the rule that was violated
Severityerror (critical), warning (important), note (informational)
DescriptionWhat poutine detected and why it is risky
DocumentationLink to the rule page, with examples and remediation
RepositoryThe repository analysed
DetailsFile, 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:

Fenêtre de terminal
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:

Fenêtre de terminal
poutine analyze_org my-org --token "$GH_TOKEN"

Useful options for large organisations:

Fenêtre de terminal
# Skip forks
poutine analyze_org my-org --token "$GH_TOKEN" --ignore-forks
# Parallelise across 8 threads (default: 2)
poutine analyze_org my-org --token "$GH_TOKEN" --threads 8

A GitLab instance

poutine supports GitLab too, self-hosted or gitlab.com:

Fenêtre de terminal
export GL_TOKEN="your-gitlab-token"
poutine analyze_org my-group/my-project \
--token "$GL_TOKEN" \
--scm gitlab \
--scm-base-url https://gitlab.example.com

The 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.

.github/workflows/vulnerable.yml
on: pull_request_target
jobs:
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 build

Fix: 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.

.github/workflows/vulnerable.yml
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:

.github/workflows/safe.yml
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 true
if: |
${{
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 from
on: pull_request_target
jobs:
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 Dependabot
if: >-
!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 trigger
on: pull_request_target
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

Fix: always declare minimal permissions:

# ✅ Explicit, minimal permissions
on: pull_request_target
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Unverified 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.

Fenêtre de terminal
# ❌ No integrity check at all
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
bash <(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.1

Debugging 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 production
env:
ACTIONS_RUNNER_DEBUG: true

Self-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 forks
on: pull_request
jobs:
test:
runs-on: self-hosted # an external contributor can run code here

Configuring 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:

.poutine.yml
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-filter

Ignoring from the command line

To ignore rules one off, without touching the configuration file:

Fenêtre de terminal
# Ignore a single rule
poutine analyze_local . --skip debug_enabled
# Ignore several rules
poutine analyze_local . --skip debug_enabled --skip unverified_script_exec

Including custom rules

poutine supports custom Rego rules. Add a rules directory to your configuration:

.poutine.yml
include:
- path: ./custom_rules

Then create a Rego file in that directory:

custom_rules/no_latest_tag.rego
# 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.poutine
import 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:

FormatCommandUse
pretty-f prettyReadable tables in the terminal (default)
json-f jsonAutomated parsing by scripts
sarif-f sarifUpload to GitHub Advanced Security

Processing the JSON with jq:

Fenêtre de terminal
# List findings by rule, with their count
poutine 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:

.github/workflows/poutine.yml
name: Pipeline security audit
on:
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: poutine

A plain scan that fails the pipeline

If you do not need SARIF:

.github/workflows/poutine-simple.yml
name: Pipeline security
on:
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-violation

Without 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:

.github/workflows/poutine-org-audit.yml
name: Organisation security audit
on:
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-org

The rules at a glance

RuleSeverityWhat it detects
untrusted_checkout_execerrorFork code checked out, then executed (npm, make, pip and so on)
if_always_trueerrorAn if condition always true because of the YAML syntax
confused_deputy_auto_mergeerrorAuto-merge based on a spoofable github.actor
injectionwarningInjection through ${{ }} inside a run: block
job_all_secretswarningtoJSON(secrets) or dynamic access to secrets
default_permissions_on_risky_eventswarningNo permissions: on pull_request_target
pr_runs_on_self_hostedwarningSelf-hosted runner reachable by forks
known_vulnerability_in_build_componentwarningThird-party action with a known CVE (OSV database)
known_vulnerability_in_build_platformwarningCI platform with a known CVE
debug_enablednoteACTIONS_RUNNER_DEBUG or ACTIONS_STEP_DEBUG enabled
unpinnable_actionnoteAction whose internal dependencies are not pinned
unverified_script_execnotecurl | bash with no integrity check
github_action_from_unverified_creator_usednoteAction from an unverified Marketplace creator

Troubleshooting

SymptomLikely causeFix
not a git repositoryRepository not initialisedRun git init before analyze_local
No finding shownNo CI/CD workflowCheck that .github/workflows/ holds YAML files
token requiredToken missing for analyze_repo or analyze_orgExport GH_TOKEN or pass --token
known_vulnerability_in_build_component findingsActions with known CVEsUpdate the actions to non-vulnerable versions
rate limit exceededToo many GitHub API requestsLower --threads or use a token with more quota
A false positive on one ruleRule too strict for your contextAdd a skip entry in .poutine.yml
invalid configurationWrong .poutine.yml syntaxCheck the YAML indentation

Key points

  1. poutine scans CI/CD pipelines (GitHub Actions, GitLab CI, Azure DevOps, Tekton) for 13 kinds of supply chain vulnerability.

  2. analyze_org is the most powerful command: it audits a whole organisation in one go, for a global picture of pipeline security.

  3. untrusted_checkout_exec is the most critical finding: fork code checked out and then fed to npm install or make allows arbitrary code execution with the target repository secrets.

  4. if_always_true is a subtle trap: using ${{ }} inside a multi-line if condition makes that condition always true, even when the expression itself is false.

  5. The SARIF format surfaces findings in the GitHub Security tab, for tracking over time.

  6. .poutine.yml lets you ignore false positives precisely (by rule, path, level, job or purl) and add custom Rego rules.

  7. 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_exec poutine 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.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn