Detections
This is the canonical list of every rule pinprick audit checks. All rules emit findings under the pinprick/shell_fetch, pinprick/javascript_fetch, pinprick/python_fetch, or pinprick/docker_unpinned SARIF rule ids.
Severity levels
Section titled “Severity levels”- High — an attacker controlling the fetched resource gets arbitrary code execution in the job. Typical:
/latest/URLs, piped-to-shell, missing Docker tags. - Medium — an unversioned URL or unpinned download. Risk depends on what the URL points at.
- Low — unpinned package manager install (
pip install foo,npm install foo). Usually a hygiene issue rather than an immediate exploit.
How matches are scored
Section titled “How matches are scored”pinprick scans line by line. Each rule is an anchored regex, compiled once at startup.
- Pipe-to-shell pre-empts other shell rules. If a line matches a pipe-to-shell rule, no other shell or Docker rule fires on that line. So
curl ... | shproduces a single high-severity finding instead of one medium (unversioned URL) plus one high (pipe-to-shell). - Versioned-URL downgrade. Non-pipe shell, JavaScript, and Python fetch rules only fire if the URL is unversioned. A URL is versioned if any path segment matches
v?\d+(\.\d+)+— e.g./v1.2.3/,/0.55.8/. See Versioned URL heuristic. - Trusted hosts exemption. Unversioned-URL rules are downgraded to allowed matches when the URL host matches an entry in the user’s
trusted-hostslist. - Data-format exemption. If a fetch targets a URL whose path ends in a known data-format extension (
.json,.yaml,.toml, etc.), it is treated as a data fetch, not a code fetch, and downgraded to an allowed match instead of a finding. See Data-format exemption. - Checksum downgrade. A non-pipe finding followed within 3 lines by
sha256sum,shasum,openssl dgst,gpg --verify, orGet-FileHashis downgraded one severity level (high → medium → low). The fetch is still reported.
Pipe-to-shell
Section titled “Pipe-to-shell”Flagged in shell run: blocks, composite action.yml steps, and Dockerfile RUN lines. High severity regardless of URL versioning.
curl or wget piped to a shell interpreter
Section titled “curl or wget piped to a shell interpreter”Severity: High
Triggers on curl or wget piped into sh, bash, zsh, dash, ash, ksh, fish, or python/python3, optionally via sudo.
curl -sSL https://example.com/releases/download/v1.2.3/install.sh | shcurl -fsSL https://example.com/install.sh | sudo bashwget -qO- https://example.com/install.sh | sh -s -- --yescurl https://example.com/get.py | python3Not flagged:
curl https://example.com/file.sh | tee out.sh # not an interpretercurl https://api.example.com/data | jq . # not an interpreterThe versioned URL in the first example pins the path, not the bytes on the wire: release tags can be recreated, S3 buckets can be overwritten, in-flight bytes can be swapped. Writing the script to disk and checking a signature is always cheap; piping to sh forfeits that option.
Process substitution of a fetched script
Section titled “Process substitution of a fetched script”Severity: High
Triggers on Bash process substitution where the inner command is a fetch.
bash <(curl -L https://example.com/install.sh)sh <(wget -qO- https://example.com/install.sh)Equivalent to piping to shell: the script is executed without ever being written to disk.
Command substitution of fetched content
Section titled “Command substitution of fetched content”Severity: High
Triggers on bash -c "$(…)" or eval "$(…)" wrapping a fetch.
bash -c "$(curl -fsSL https://example.com/install.sh)"eval "$(wget -qO- https://example.com/install.sh)"Same risk: fetched bytes are handed straight to a shell.
PowerShell Invoke-Expression on fetched content
Section titled “PowerShell Invoke-Expression on fetched content”Severity: High
Triggers on iex / Invoke-Expression combined with iwr / Invoke-WebRequest / irm / Invoke-RestMethod / DownloadString.
iex (iwr https://example.com/install.ps1)iex (Invoke-RestMethod -Uri https://example.com/install.ps1)Invoke-Expression ((New-Object Net.WebClient).DownloadString("https://example.com/install.ps1"))The PowerShell equivalent of curl | sh. Same risk, same high severity.
Shell fetches
Section titled “Shell fetches”Flagged in shell run: blocks and composite action.yml steps.
curl or wget to a /latest/ URL
Section titled “curl or wget to a /latest/ URL”Severity: High
Triggers on curl or wget with a URL containing /latest or =latest.
curl -L "https://github.com/owner/repo/releases/latest/download/tool.tar.gz"wget "https://example.com/releases/latest/tool.tar.gz"Not flagged:
curl -L "https://github.com/owner/repo/releases/download/v1.2.3/tool.tar.gz"latest is a mutable alias — whatever it resolves to today may be different tomorrow.
curl or wget to an unversioned URL
Section titled “curl or wget to an unversioned URL”Severity: Medium
Triggers on curl or wget fetching an http:// or https:// URL whose path contains no version segment.
curl -L https://example.com/install.sh -o install.shwget https://example.com/bin/toolNot flagged:
- Any URL whose path contains a segment matching
v?\d+(\.\d+)+, e.g.https://example.com/releases/download/v1.2.3/tool. - Any URL whose host matches
trusted-hostsin.pinprick.toml. - Any URL whose path ends in a data-format extension (
.json,.yaml,.toml,.csv, etc.). See Data-format exemption.
gh release download without a pinned tag
Section titled “gh release download without a pinned tag”Severity: Medium
Triggers on gh release download without a version argument.
gh release download --pattern '*.tar.gz'Not flagged:
gh release download v1.2.3 --pattern '*.tar.gz'The gh CLI grabs the most recent release when no tag is given — same problem as a /latest/ URL.
go install @latest
Section titled “go install @latest”Severity: Medium
Triggers on go install …@latest.
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latestNot flagged:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.0git clone without a pinned ref
Section titled “git clone without a pinned ref”Severity: Medium
Triggers on git clone without --branch/-b or with a branch name that doesn’t look like a version tag.
git clone https://github.com/org/repogit clone --branch main https://github.com/org/repogit clone -b develop https://github.com/org/repoNot flagged:
git clone --branch v1.2.3 https://github.com/org/repogit clone -b 2.0.1 https://github.com/org/repogit clone --depth 1 --branch v1.2.3 https://github.com/org/repoA bare git clone defaults to HEAD of the default branch, which is mutable. Pinning to a version tag via --branch makes the clone deterministic (at least to the tag level).
Also flagged in Dockerfile RUN instructions under the pinprick/docker_unpinned rule.
pip install without a version pin
Section titled “pip install without a version pin”Severity: Low
Triggers on pip install <package> where <package> has no ==/>=/~= specifier and the line is not a -r requirements.txt install.
pip install requestspip3 install flaskpip install requests --quietpip install requests --userNot flagged:
pip install requests==2.31.0pip install requests>=2.0pip install -r requirements.txtnpm install without a version pin
Section titled “npm install without a version pin”Severity: Low
Triggers on npm install <package> where <package> has no @version specifier (a digit after @). Scoped packages like @babel/core are not version-pinned; @babel/core@1.0.0 is.
npm install typescriptnpm install @babel/corenpm install typescript --save-devNot flagged:
npm install typescript@5.6.0npm install @babel/core@1.0.0npm install # no package argument — uses package-lock.jsonnpx without a version pin
Section titled “npx without a version pin”Severity: Medium
Triggers on npx <package> (or npx -p <package>, npx --package=<package>) where no token on the line has an @<digit> version specifier. Rated higher than npm install because npx is fetch-and-execute with no lockfile: every CI run pulls whatever the registry currently resolves, and the resolved code runs immediately.
npx create-react-app my-appnpx typescriptnpx -y @angular/cli new my-appnpx --yes typescriptNot flagged:
npx typescript@5.6.0npx @angular/cli@17.0.0 new my-appnpx -p typescript@5.6.0 tscnpx --package=typescript@5.6.0 tscpip install git+URL without a ref
Section titled “pip install git+URL without a ref”Severity: Medium
Triggers on pip install git+https://… (or git+http://…) where the VCS URL has no @<ref> suffix. Without a ref, pip installs from the repo’s default branch at HEAD, which silently changes over time. Any ref — tag, branch name, or full SHA — suppresses the finding (mirrors git clone --branch handling: branch refs are accepted here rather than requiring a SHA).
pip install git+https://github.com/owner/repo.gitpip install --user git+https://github.com/owner/repo.gitpip3 install git+https://gitlab.example.com/team/tool.gitNot flagged:
pip install git+https://github.com/owner/repo.git@v1.2.3pip install git+https://github.com/owner/repo.git@mainpip install git+https://github.com/owner/repo.git@abc1234567890abcdef1234567890abcdef123456cargo install without a version pin
Section titled “cargo install without a version pin”Severity: Low
Triggers on cargo install <crate> where the line has neither @version on the crate name nor a --version flag. Note: --locked pins the crate’s dependencies via its lockfile, not the crate’s own version, so it does not suppress this finding.
cargo install ripgrepcargo install typos-cli --lockedcargo install cargo-deny --lockedNot flagged:
cargo install ripgrep@14.0.0cargo install ripgrep --version 14.0.0cargo install # no crate argument — uses Cargo.tomlgem install without a version pin
Section titled “gem install without a version pin”Severity: Low
Triggers on gem install <gem> where the line has neither -v <version> nor --version <version>.
gem install rubocopgem install rubocop --no-documentNot flagged:
gem install rubocop -v 1.0.0gem install rubocop --version 1.0.0gem install # no gem argumentbrew install —HEAD
Section titled “brew install —HEAD”Severity: Medium
Triggers on brew install <pkg> --HEAD (or --head). --HEAD ignores the formula’s bottle/version and builds from the upstream repository’s main branch, so the installed code silently changes between runs.
brew install ffmpeg --HEADbrew install imagemagick --headNot flagged:
brew install ffmpegbrew install ffmpeg --with-chromaprintPowerShell fetches
Section titled “PowerShell fetches”Flagged in shell run: blocks that happen to be PowerShell.
Invoke-WebRequest / iwr / Invoke-RestMethod / irm to a /latest/ URL
Section titled “Invoke-WebRequest / iwr / Invoke-RestMethod / irm to a /latest/ URL”Severity: High
Invoke-WebRequest "https://example.com/releases/latest/tool"irm "https://example.com/releases/latest/tool"Invoke-WebRequest / iwr / Invoke-RestMethod / irm to an unversioned URL
Section titled “Invoke-WebRequest / iwr / Invoke-RestMethod / irm to an unversioned URL”Severity: Medium
Invoke-WebRequest "https://example.com/tool"iwr https://example.com/tool -OutFile tool.exeNot flagged:
Invoke-WebRequest "https://example.com/releases/download/v1.2.3/tool"Install-Module / Install-Script without -RequiredVersion
Section titled “Install-Module / Install-Script without -RequiredVersion”Severity: Medium
Triggers on Install-Module or Install-Script without -RequiredVersion <version>. Only -RequiredVersion pins to a single release; -MinimumVersion and -MaximumVersion (alone or together) leave at least one end of the range unbounded and are not accepted as a pin.
Install-Module -Name Pester -ForceInstall-Script -Name Get-WindowsAutoPilotInfoInstall-Module -Name Pester -MinimumVersion 5.0.0Not flagged:
Install-Module -Name Pester -RequiredVersion 5.3.1 -ForceJavaScript / TypeScript fetches
Section titled “JavaScript / TypeScript fetches”Flagged in .js and .ts files inside an action’s source tree. Minified bundles (lines longer than 500 characters) are split on ; and each segment is scanned individually — this catches calls buried inside dist/index.js.
fetch() / axios / got / http.get to a /latest/ URL
Section titled “fetch() / axios / got / http.get to a /latest/ URL”Severity: High
fetch('https://api.github.com/repos/owner/repo/releases/latest');axios.get('https://example.com/releases/latest/tool');got('https://example.com/releases/latest/tool');https.get('https://example.com/releases/latest/tool', cb);exec / child_process shelling out to curl or wget
Section titled “exec / child_process shelling out to curl or wget”Severity: High
exec('curl -L https://example.com/install.sh | sh');child_process.execSync('wget https://example.com/tool');A JavaScript action reaching for curl is almost always doing something that should be a signed release download instead.
fetch() / axios to an unversioned URL
Section titled “fetch() / axios to an unversioned URL”Severity: Medium
const r = await fetch('https://example.com/api/data');const r = await axios.get('https://example.com/api/data');Not flagged:
- Versioned URL:
fetch('https://example.com/api/1.2.3/data') - Trusted host via
trusted-hosts - Data-format URL:
fetch('https://example.com/config.json')— see Data-format exemption.
Python fetches
Section titled “Python fetches”Flagged in .py files inside an action’s source tree.
urllib.request.urlopen / requests.get to a /latest/ URL
Section titled “urllib.request.urlopen / requests.get to a /latest/ URL”Severity: High
urllib.request.urlopen("https://example.com/releases/latest/tool")requests.get("https://example.com/releases/latest/tool")subprocess shelling out to curl or wget
Section titled “subprocess shelling out to curl or wget”Severity: High
subprocess.run(["curl", "-L", url])subprocess.check_output(["wget", url])urllib.request.urlopen / requests.get to an unversioned URL
Section titled “urllib.request.urlopen / requests.get to an unversioned URL”Severity: Medium
requests.get("https://example.com/api/data")urllib.request.urlopen("https://example.com/file")Not flagged:
- Versioned URL:
requests.get("https://example.com/releases/download/v1.2.3/tool") - Trusted host via
trusted-hosts - Data-format URL:
requests.get("https://example.com/data.json")— see Data-format exemption.
Dockerfile patterns
Section titled “Dockerfile patterns”Flagged in Dockerfile and *.dockerfile files inside an action’s source tree.
FROM image:latest
Section titled “FROM image:latest”Severity: High
FROM ubuntu:latestFROM node:latest AS builder:latest is a mutable tag. Pin to a specific version or, better, a digest.
FROM image without a tag
Section titled “FROM image without a tag”Severity: High
FROM ubuntuFROM node AS builderAn untagged FROM implicitly pulls :latest.
FROM image@sha256:…
Section titled “FROM image@sha256:…”Not flagged. Digest-pinned images are immutable.
FROM ubuntu@sha256:abc123def456...RUN curl or wget piped to a shell
Section titled “RUN curl or wget piped to a shell”Severity: High
Caught by the shared pipe-to-shell rules. Escalated from the medium-severity generic RUN curl rule below.
RUN curl -sSL https://example.com/install.sh | shRUN wget -qO- https://example.com/install.sh | shRUN curl or wget (no pipe)
Section titled “RUN curl or wget (no pipe)”Severity: Medium
RUN curl -L https://example.com/install.sh -o /usr/local/bin/installRUN wget https://example.com/toolNot flagged: a curl line followed within 3 lines by a checksum command, which is downgraded to low.
ADD with a URL source
Section titled “ADD with a URL source”Severity: Medium
Dockerfile’s ADD instruction accepts an http:// or https:// URL as its source, which is downloaded at build time. Unlike COPY, it can reach the network.
ADD https://example.com/install.tar.gz /tmp/ADD --chown=user:group https://example.com/tool.tgz /opt/Not flagged:
- Versioned URL:
ADD https://example.com/releases/download/v1.2.3/install.tar.gz /tmp/ - Trusted host via
trusted-hosts - Data-format URL:
ADD https://example.com/config.json /etc/— see Data-format exemption. - Local source:
ADD ./local.tar.gz /tmp/
Versioned URL heuristic
Section titled “Versioned URL heuristic”A URL is considered versioned if it contains a path segment matching v?\d+(\.\d+)+ between / or = boundaries:
| URL | Versioned? |
|---|---|
https://example.com/releases/download/v1.2.3/tool.tar.gz | yes |
https://example.com/releases/download/0.55.8/tool | yes |
https://example.com/releases/latest/download/tool.tar.gz | no |
https://api.example.com/data | no |
https://example.com/v4/resource | no (single numeric component only) |
This is intentionally strict — v4 alone is a sliding major-version alias, not a pinned release.
Data-format exemption
Section titled “Data-format exemption”Unversioned URL rules (curl/wget to an unversioned URL, fetch()/axios to an unversioned URL, urllib/requests to an unversioned URL) are not emitted as findings when the URL’s path ends in a known data-format extension. Instead, the match is recorded as an allowed match with reason data format URL and is only visible under --verbose.
Rationale: a workflow fetching JSON for jq or YAML for parsing is a different risk class from fetching an install script. The payload is consumed as data, never executed. Homebrew/core’s curl -s https://formulae.brew.sh/api/analytics/install/homebrew-core/30d.json is a real example — the JSON is assigned to a shell variable and parsed, never run.
Extensions considered data formats:
| Category | Extensions |
|---|---|
| JSON | .json, .jsonl, .ndjson |
| Config | .yaml, .yml, .toml |
| Tabular | .csv, .tsv, .xml |
| Text | .txt, .md, .rst |
Matching is case-insensitive. Query strings (?foo=bar) and fragments (#section) are stripped before the extension check.
The exemption applies only to the unversioned-URL rules. /latest/ URLs, pipe-to-shell, and gh release download without a tag still fire regardless of extension, because the risk there is about the path being mutable, not about what the bytes decode to.
The list can be extended via extra-data-formats in .pinprick.toml to add project-specific extensions (e.g., .proto, .graphql).
Trusted hosts exemption
Section titled “Trusted hosts exemption”Unversioned-URL rules are downgraded to allowed matches when the URL host matches an entry in the user’s trusted-hosts list. Configured via .pinprick.toml:
trusted-hosts = ["artifacts.example.com"]Matching is exact hostname, case-insensitive. example.com does not trust api.example.com — each subdomain must be listed separately. Port numbers and paths are stripped before comparison.
The exemption applies only to the unversioned-URL rules — the same scope as the data-format exemption. It does not cover:
/latest/URLs — the risk is the path being mutable, regardless of who’s serving it.- Pipe-to-shell — the piped payload is never written to disk, so host trust doesn’t change the safety profile.
gh release downloadwithout a pinned tag.- Package manager installs (
pip install foo,npm install foo) — those go through package registries, not the HTTP host.
Suppressing findings
Section titled “Suppressing findings”When a finding is intentional and you want pinprick audit to stop flagging it, reach for the tightest mechanism that covers the case. Each mechanism lives in .pinprick.toml, is visible in code review, and applies across the whole repo.
There are two distinct outcomes to be aware of:
- Allowed match — the rule still matched, but the finding is recorded as allowed instead of emitted. Visible under
--verbosewith a reason, so a reviewer auditing the audit can still see what fired. Used bytrusted-hosts,extra-data-formats, the versioned-URL heuristic, the data-format exemption, and the audited-actions list. - Removed finding — the finding is dropped from the report entirely and is not visible under
--verbose. Used byignore.patterns,ignore.actions, andseverity.
trusted-hosts
Section titled “trusted-hosts”Allowlist a URL host. Any curl/wget/fetch to that host becomes an allowed match instead of a finding.
trusted-hosts = ["artifacts.example.com"]Use this when you operate an internal artifact server and control what lives at https://artifacts.example.com/. Covers the unversioned-URL rules for shell, JavaScript, Python, and Docker ADD. See Trusted hosts exemption for what it does not cover (pipe-to-shell, /latest/ URLs, package-manager installs).
extra-data-formats
Section titled “extra-data-formats”Allowlist a file extension. Unversioned URL fetches ending in that extension become allowed matches.
extra-data-formats = ["proto", "graphql"]Use this when you regularly fetch a schema, config, or data file format that isn’t in pinprick’s built-in data-format list. The fetched bytes have to be consumed as data, not executed — the exemption is wrong if you’re fetching an install.proto that happens to be a shell script.
ignore.patterns
Section titled “ignore.patterns”Drop any finding whose description contains a given substring.
[ignore]patterns = [ "pip install without version pin",]Use this to silence a specific rule across all actions. Matches by substring against the rule’s description — so "pip install" silences the pip rule, "unversioned URL" silences every unversioned-URL rule. Findings matching a suppressed pattern are removed entirely, not visible under --verbose.
Prefer extra-data-formats or trusted-hosts when they fit — those keep the audit trail; this one doesn’t.
ignore.actions
Section titled “ignore.actions”Skip an action entirely. The action’s source code is never fetched, never scanned, and never counted in the “audited” total — it shows up on its own as ignored in the per-line output and the summary.
[ignore]actions = [ "actions/checkout",]Matches by prefix against owner/repo, so "actions/checkout" matches every actions/checkout@anything. Use this when you’ve manually reviewed an action and decided it’s out of scope — e.g. an action maintained by your own org that you already security-review separately. The blast radius is the entire action, so use sparingly.
severity threshold
Section titled “severity threshold”Raise the minimum severity that gets reported.
severity = "medium"Accepts "low", "medium", or "high". Findings below the threshold are removed from the report. Useful in CI when you want the audit to fail on real risks (high and medium) but not on hygiene issues (unpinned pip install, etc.). Not a targeted suppression — it silences every finding below the bar.
Why there’s no inline comment syntax
Section titled “Why there’s no inline comment syntax”pinprick deliberately does not read # pinprick: ignore-style inline comments. All suppression lives in .pinprick.toml so silencing is explicit, auditable in one place, and does not travel with copy-pasted code from another repo. If a specific line in a workflow needs to bypass a finding, your options are:
- Rewrite the line to avoid the pattern (pin the URL to a version, add a checksum check, etc.).
- Add a targeted allowlist entry in
.pinprick.tomlusing the mechanisms above. - Raise the
severitythreshold if the finding is structurally low-value.
The trade-off is intentional: a little more friction for the edge case, in exchange for no per-line escape hatch that a malicious or careless commit could hide in a workflow.