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 uses bounded source traversal, logical-command parsing, literal propagation, and precompiled patterns. It does not execute workflow or action code.
Workflow, reusable-workflow, and composite steps honor their declared shell, including inherited workflow/job defaults. Python and Node steps use the corresponding source scanner. Unsupported shells leave coverage incomplete. Declared action entrypoints lead to literal local imports, Python modules resolved from the entry script’s directory, package initializers, and files executed through the action’s or a script’s own location: $GITHUB_ACTION_PATH and ${{ github.action_path }} in any spelling (including step env: values, $env:GITHUB_ACTION_PATH, and Join-Path), python -m from the action directory, shell $(dirname "$0") and BASH_SOURCE, PowerShell $PSScriptRoot, Node __dirname and import.meta.url, and Python __file__. Composite shell, Python, and Node steps are all followed this way.
Path joins (with each language’s rules for absolute parts), concatenation, template and f-string interpolation, conditional and logical expressions, variables holding these values, and working-directory changes (cd, pushd, process.chdir, os.chdir, and cwd options) are evaluated. Shell assignments and directory changes apply in command order; a change inside a branch, loop, function, or after && or || may not have happened, a subshell’s changes end with it, and a loop that changes its own directory or variables leaves coverage incomplete. A followed file starts in the directory it was run from. In a composite step, changing to any directory that is not a resolved action location leaves coverage incomplete, because the workspace or an input can lead back into the action. A file run by an interpreter is scanned as that interpreter’s language whatever its extension; a container action’s ENTRYPOINT, CMD, and RUN are traced back to the files COPY or ADD placed in the image.
Location provenance survives every expression: a value stored in an object or array, returned by an unrecognized function, spread into arguments, bound more than once, or too large or deeply aliased to evaluate is still derived from the location. An execution through a location that does not evaluate to exactly one existing file, and any other unresolved required source, leaves coverage incomplete; mentioning the location or reading a data file through it does not. In JavaScript execution calls, followed shell scripts, composite steps, and container RUN, ENTRYPOINT, and CMD commands, a program whose name is unknown before runtime follows a bare action-located operand by its script extension, ignores structured data, and otherwise leaves coverage incomplete, including for a directory or an extensionless file; a location embedded in a larger argument, such as PowerShell -Command text, is not followed. A composite step is stricter: every action file it names through the action path must be a scannable source, so a named data file still leaves coverage incomplete. Python argument lists whose first element names a dynamic program and that carry location-tainted arguments still leave coverage incomplete even when a located script operand is followed. An argument list such as ['env', dynamic_program, script] follows a located script and can remain complete, whereas the equivalent shell string passed to os.system still leaves coverage incomplete. Copying an action file (cp, mv, ln, install, rsync, or cat with an output redirection) scans a recognized source file, ignores structured data, and otherwise leaves coverage incomplete. Recognized JavaScript file copy, move, and link calls with an action-located source scan that source too, including calls through a variable that directly aliases a recognized copier, readFileSync into writeFileSync, and read streams piped into write streams. Generic copy and moveSync calls count as file copies only when their receiver or imported binding comes from a module; a method name alone would also match byte copies such as Buffer.copy. A copied source is scanned with its own runtime location unknown, so an execution or local import through that location leaves coverage incomplete; a copy without such a dependency can remain complete.
JavaScript and TypeScript are parsed and names resolve to their declarations, so a name reused in unrelated code does not inherit a location. Any mention of __dirname, __filename, GITHUB_ACTION_PATH, import.meta, or a step variable holding the action path draws on a location, whatever it is bound to. Provenance follows arguments into the parameters of functions and methods defined in the same file, callbacks, fields (per class, object, or variable where the holder is known, and by name where it is not), instances of classes whose fields hold a location, and objects a function changes through its parameters or this. Outside class and object-literal methods, this may be any object: fields read through it are matched by name, and a located receiver reaches this in the methods called on it, in getters defined on it, and in functions bound or applied to it or passed along with it, including methods passed as fields of an object whose methods are known. A located argument also reaches the parameters of a function in another of the action’s JavaScript files when the caller reaches it through a require or awaited dynamic import of a relative path: the module object or a field of it, an imported or flatly destructured function, or a variable only ever holding one. The call binds to the function the module exports under that name through exports.name, module.exports, Object.defineProperty with a plain value, an export declaration, or a re-export with module.exports = require(...) or export ... from. The value keeps the location of the file that produced it, and calls passing different located values leave it unresolved. A module passed to an interop helper defined in the same file counts as the module itself when running the helper on it shows that it hands back only the module, or an object holding the module’s own fields under their own names and the module as default, as TypeScript’s __importStar and __importDefault do, and calls, stores, and writes nothing of the module’s. Running it follows the file’s own functions it calls and takes Object’s reflection functions as JavaScript defines them, and TypeScript’s (this && this.name) || guard counts only in a top-level declaration preceded by nothing but declarations that run no code. Constructing an export binds its constructor like a call when the export is only ever a class whose instances hold nothing but what their constructor is given: it extends nothing, null, a built-in error, or another such class; its body holds only fields with literal values and a constructor that passes parameters and literals to super and stores them in fields of this; and its file only exports, constructs, tests, or extends it. When the export cannot be resolved, such as one built at runtime, defined through Object.assign, a computed key, a getter, a method or a class, or when the caller passes the module or one of its functions on, stores it, constructs any other export, spreads arguments into it, calls a function it returns, or loads it through any other interop helper, every parameter in that file that may have callers outside it is unresolved instead. Inline step code passing a location to a module leaves coverage incomplete; values another file hands back through callbacks or globals are outside the model. A getter, a property descriptor’s get, or an implicit method such as toString that returns a location makes its object located. A function declaration or expression that is neither async nor a generator, has no rest parameter, and is only called directly within its file resolves its return for each call when its only statement returns a parameter without a default value unchanged, so a safe call does not inherit a location from another call. A field written through an object nested in another is matched by name, since the nested object may be anything stored there, unless the outer object is a literal that only ever holds fresh values. Because code can reach the global object without naming it, a field stored under a global is also matched by name through objects of unknown origin, and the global object, or any property on the path to that field, used as a value carries it. Arithmetic, comparison, and unary results are not paths; everything else derived from a location, including file contents and modules loaded through a located specifier or loader, keeps its provenance. Node’s built-ins are modelled as behaving normally, a heuristic rather than a proof: path functions are evaluated as Node’s, and createRequire imported from node:module is taken as Node’s own, so the file it is given only sets where its loader resolves from. A built-in module such a loader returns carries no location, while any other module it loads may be one of the action’s files and is treated as located. A method called on a built-in module, loaded by a literal name directly or through an interop helper that only copies its properties, such as esbuild’s __toESM, is Node’s own, so its located arguments do not reach functions of the same name in the action’s files. That stops holding for a member any of those files may replace, by writing to it or with Object.assign or Object.defineProperty, whether on the module itself, a variable holding it, or a function the file passes it to, on the parameter that receives it or on what the function returns, when the call reaches that function through a variable that may name it, at once, as a method of an object literal a variable may hold, or through call; and a module with any such member is not trusted through an interop helper at all, since the helper may rename members. A replacement made through a function the analysis cannot identify is outside the model. An action whose files visibly replace or reach around the loader withdraws this for all of them: taking more than createRequire from node:module; loading module other than to call its createRequire, or loading vm, inspector, or repl; loading or importing a specifier it does not spell out, other than a path relative to itself; using a loader’s cache or passing a loader on; naming syncBuiltinESMExports or getBuiltinModule; writing through a constructor; or evaluating code other than the eval("require") and Function("return this") forms bundlers emit. Replacement concealed from these checks, such as reaching the Function constructor through a computed key, is outside the model. A file that reassigns __dirname or __filename (directly or in eval code), writes or passes on import.meta, or writes the variable holding the action path does not evaluate it to the location, so executions through it are unresolved. A located execution option leaves the execution unresolved unless the option cannot choose what runs, such as cwd, encoding, stdio, or a timeout; shell, execPath, execArgv, env, and input can each run a helper. Functions reached only through a computed key, or passed as a field of an object whose methods are unknown, are not followed as callbacks or receivers. A file that cannot be parsed, or whose analysis fails or runs too long, leaves coverage incomplete. An exec call is treated as a regular-expression match only when its receiver is a literal, or a variable holding one that is only read or passed to a string method, and the file neither evaluates code nor exposes RegExp or a regular expression’s prototype. Python syntax is parsed in a separate worker so a parser failure leaves coverage incomplete without ending the scan. Function and class scopes keep unrelated assignments and directory changes separate. When an action location reaches a function that may execute a file, a returned path, a located default or field, or an unresolved program after a change into the action directory, the scan follows an exact file where it can and otherwise leaves coverage incomplete. This is a bounded heuristic: reflective calls and arbitrary Python data flow are not proven complete.
Audit diagnostics can identify dynamic JavaScript evaluation as a contributing cause of incomplete coverage in the analyzed source. Removing it does not necessarily make the scan complete: missing, unreadable, or excluded dependencies can hide other causes. Attribution is conservative: no other loader replacement may be observed, and every located execution in at least one failing file must resolve when standard module loading is assumed. Mixed failures in the same file can suppress this detail; the generic coverage failure remains. This extra analysis runs only for audit, after dependency traversal finishes, and does not change findings, verdicts, cache eligibility, or scores.
- 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 suppression. A non-pipe finding followed within 3 lines by
sha256sum,shasum,openssl dgst,gpg --verify, orGet-FileHashis suppressed only when the command performs an actual comparison, does not mask or negate failure, and binds every downloaded target to independently trusted verification material. A downloaded sidecar such astool.sha256is attacker-controlled alongside the payload and does not qualify on its own. Inline manifests require a literal full SHA-256 or SHA-512 digest; signature checks must name the downloaded target. Runtime downloads, imported keys, curl or wget configuration changes, and static copy, move, or link aliases remain tracked across later ordered steps in the same job or composite action. LiteralcdandSet-Locationpaths are resolved within a shell block; conditional or dynamic directories, subshells, directory stacks, expanded or redirected verification operands, and a payload used as its own checksum manifest fail closed when the binding cannot be proven. Verified matches are recorded as allowed.
Verification applies to the downloaded file version that exists when the check runs; checking an earlier copy does not cover a later overwrite. The verifier must be the command that runs; verifier text an echo or printf prints does not count. Runtime key retrieval, URL-valued key operands, downloaded material copied through tee, cat, or dd (including by input redirection with or without a descriptor, such as 0<file), imported into GPG from standard input or a pipe, and extracted downloaded archives (in any tar option form, including abbreviated long options) cannot establish independent trust. Recognized runtime fetches in Python or Node steps, in any source file beside a composite action, or anything a nested action a composite step uses may write, also prevent later shell verification in its steps from assuming that its file inputs are independent, because their output paths cannot be bound by the shell scanner.
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 a supported shell or interpreter, including sh, bash, python/python3, and node. Recognized command wrappers include sudo, doas, env, command, exec, nice, nohup, time, and busybox; |& is also treated as a pipeline. A wrapper asked only for help, its version, or (for sudo and doas) a permission check runs nothing, so nothing after it counts as a verifier, checkout, or jq.
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 shell -c, Python -c, Node/Ruby/Perl -e, or eval wrapping a fetch in command substitution. Shell here-strings containing fetched command output, backtick substitution, and source or . of a /dev/stdin here-string are also checked.
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 wrapping iwr / Invoke-WebRequest / irm / Invoke-RestMethod / DownloadString or receiving its output through a pipeline, and on [scriptblock]::Create wrapping a fetch. Fetched content stored in a variable and executed later is not tracked.
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(S) URL whose path contains no version segment. Scheme matching is case-insensitive. Literal domain/path operands without a scheme are also checked when they belong to a fetch command.
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.
curl or wget from a non-literal URL to an executable target
Section titled “curl or wget from a non-literal URL to an executable target”Severity: Low
Triggers when curl or wget writes a $-sourced URL to a target that does not look like a data file. pinprick cannot inspect the URL path, so this is intentionally lower severity than a known unversioned URL.
An unresolved output target is treated as potentially executable. For example, --output "$2" and --output "${RUNNER_TEMP}/body.json" trigger even when the intended response is JSON; the scanner cannot establish the destination. A literal --output body.json can use the data-format exemption. A literal executable target with a variable URL also triggers. Curl’s --disable option does not resolve an expanded destination.
curl -fsSL "$RELEASE_URL" -o toolwget "$TOOL_URL" -O bin/toolcurl "$DOWNLOAD_URL" > install.shNot flagged:
curl -fsSL "$SCHEMA_URL" -o schema.jsoncurl -fsSL "$RELEASE_URL" -o /dev/nullcurl -fsSL "$RELEASE_URL" | cat > toolcurl -fsSL https://example.com/tool -o toolLiteral URLs stay on the normal versioned/unversioned URL path. Later pipeline stages are not treated as proof that the fetch itself wrote an executable file.
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.0deno run / install from an unversioned URL
Section titled “deno run / install from an unversioned URL”Severity: High
Triggers on deno run or deno install when the command executes code from an unversioned URL.
deno run --allow-net https://deno.land/x/install/mod.tsdeno install https://example.com/scripts/tool.tsNot flagged:
deno run https://deno.land/x/tool@v1.2.3/mod.tsdeno install https://example.com/releases/download/v1.2.3/tool.tsDeno URL imports commonly pin by embedding the version after the package name (tool@v1.2.3), so the versioned-URL heuristic accepts @ as a version boundary.
git 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. The finding stands unless every clone on the line is parsed as pinned; a clone inside sh -c or text that only mentions one counts as unpinned.
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.txtpipx install without a version pin
Section titled “pipx install without a version pin”Severity: Low
Triggers on pipx install <package> where the package has no Python version specifier.
pipx install poetrypipx install black --include-depsNot flagged:
pipx install poetry==1.8.3pipx install black>=24.0npm 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 tscuv tool install without a version pin
Section titled “uv tool install without a version pin”Severity: Low
Triggers on uv tool install <package> where the tool spec has no version pin.
uv tool install ruffuv tool install black --with clickNot flagged:
uv tool install ruff==0.8.0uv tool install ruff@0.8.0uvx without a version pin
Section titled “uvx without a version pin”Severity: Medium
Triggers on uvx <package> where no token on the line has a version specifier. Like npx, uvx fetches and runs a tool in one step, so an unpinned invocation can change between CI runs.
uvx ruff check .uvx --from black black --check .Not flagged:
uvx ruff@0.8.0 check .uvx --from black@24.10.0 black --check .pip 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 repository default branch. A version-like tag or full commit SHA suppresses the finding; branch refs such as main remain findings.
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.gitpip install git+https://github.com/owner/repo.git@mainNot flagged:
pip install git+https://github.com/owner/repo.git@v1.2.3pip 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"Start-BitsTransfer to a /latest/ or unversioned URL
Section titled “Start-BitsTransfer to a /latest/ or unversioned URL”Severity: High for /latest/, Medium for other unversioned URLs
Start-BitsTransfer -Source "https://example.com/releases/latest/tool.ps1" -Destination tool.ps1Start-BitsTransfer -Source "https://example.com/tool.ps1" -Destination tool.ps1Not flagged:
Start-BitsTransfer -Source "https://example.com/releases/download/v1.2.3/tool.ps1" -Destination tool.ps1WebClient.DownloadFile to a /latest/ or unversioned URL
Section titled “WebClient.DownloadFile to a /latest/ or unversioned URL”Severity: High for /latest/, Medium for other unversioned URLs
(New-Object Net.WebClient).DownloadFile("https://example.com/releases/latest/tool.exe", "tool.exe")(New-Object Net.WebClient).DownloadFile("https://example.com/tool.exe", "tool.exe")Not flagged:
(New-Object Net.WebClient).DownloadFile("https://example.com/releases/download/v1.2.3/tool.exe", "tool.exe")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 reachable .js and .ts action entrypoints and explicitly referenced helpers. Minified and generated bundle files are scanned for literal URL and process-spawn patterns. Conservative unresolved-variable sink reporting is limited to authored JavaScript and TypeScript source because generic networking internals in bundled dependencies are not actionable evidence of an unpinned runtime fetch. Generated bundles are recognized from conventional dist/ paths, .min.js names, and narrow bundler-runtime markers. Relative imports that the bounded scanner does not follow make coverage incomplete rather than producing a clean verdict.
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');In authored source, a recognized sink with an unresolved variable or interpolated template is also reported at medium severity because its version cannot be verified statically. Exact literal assignments and aliases are propagated; concatenation or other dynamic reassignment invalidates that binding.
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 reachable .py action helpers. A recognized sink with an unresolved variable or interpolated value is reported at medium severity. Imports that the bounded scanner cannot prove are covered make source coverage incomplete rather than producing a clean verdict.
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.
Docker CLI patterns
Section titled “Docker CLI patterns”Flagged in shell run: blocks and composite action.yml steps.
docker pull / run image:latest or untagged
Section titled “docker pull / run image:latest or untagged”Severity: High
Triggers on literal image names in docker pull and docker run commands when the image has no tag or uses :latest.
docker pull alpine:latestdocker run --rm alpine echo hidocker image pull ghcr.io/org/appdocker container run ghcr.io/org/app:latestNot flagged:
docker run alpine:3.20 echo hidocker pull ghcr.io/org/app@sha256:abc123docker run "$IMAGE" echo hidocker build .docker compose upVariable or GitHub-expression image names are not classified: the value might expand to a digest-pinned image, and pinprick cannot inspect that value statically.
Dockerfile patterns
Section titled “Dockerfile patterns”Flagged in the Dockerfile named by a reachable container action’s runs.image, or the exact action-root Dockerfile/dockerfile when no action metadata exists. Unreferenced Dockerfiles in examples, fixtures, or the action repository’s own CI are not scanned. Docker continuation parsing skips full comment lines. Wildcard or directory COPY sources require a complete repository tree to establish complete coverage.
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 fed an inline literal digest for the downloaded target, or by an independent signature verification naming the target. Downloading a checksum sidecar from the same trust domain does not suppress the finding.
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+)+ at a path boundary or in a versioned filename (for example tool-1.2.3.tar.gz). Hostnames, query strings, and fragments do not qualify:
| 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://deno.land/x/tool@v1.2.3/mod.ts |
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.
Literal and percent-encoded dot segments are normalized before the version check: /v1.2.3/../tool does not retain a version segment. A latest path component or query value remains high severity even when another component contains a version or the URL ends in a data-format extension.
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 extension is a heuristic for intended data use; it does not prove how a later step consumes the bytes. 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).
Piped-to-jq exemption
Section titled “Piped-to-jq exemption”A curl/wget whose output is piped directly into jq is recorded as an allowed match with reason piped to jq even when the URL has no data-format extension. Every occurrence of the exact URL must be an operand of a qualifying fetch. Saving the response separately, passing it through an intermediate command such as tee, or invoking jq with null input, raw input, or a separate input file does not qualify. This heuristic describes the observed pipeline; it cannot prove how later commands consume saved output.
This covers the case the data-format exemption misses: a REST API endpoint that returns JSON but carries no .json in its path. Resolving the latest release of a crate from the registry API is a real example:
curl -fsSL "https://crates.io/api/v1/crates/ripgrep" | jq -r '.crate.max_stable_version'Pipe-to-shell still takes precedence. A curl … | jq -r .url | bash line is flagged as pipe-to-shell (high severity) before this exemption is considered — routing a fetch through jq on its way to a shell does not make it safe. This exemption needs no configuration; it is always on.
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 three distinct outcomes to be aware of:
-
Accepted finding — a repository-local
accept-workflow-findingsentry binds an exact finding to the complete reviewed workflow’s SHA-256 and a reason. It remains visible in every output format, including ordinary human output. Source coverage is unchanged, and workflow changes invalidate the acceptance. -
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, the piped-to-jqexemption, 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. Empty entries match nothing. 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",]Matching is case-insensitive and respects path boundaries. "actions/checkout" matches that repository at any ref; "actions" or "actions/" matches the owner. 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.