static-analysis: run-scans.sh expands --include globs against the CWD, silently dropping an extension
What
build_argv in plugins/static-analysis/skills/semgrep/scripts/run-scans.sh interpolates the
include globs unquoted so they word-split. Unquoted expansion in bash is word splitting and
pathname expansion, so the globs are expanded against the current working directory before
they ever reach semgrep.
# Unquoted on purpose: includes is a space-separated glob list and must word-split here.
# shellcheck disable=SC2086
for g in $includes; do ARGV+=("--include=$g"); done
If the CWD happens to contain files matching one of the globs, that glob is replaced by the
literal filenames found there — filenames that generally do not exist in the scan target. The
patterns that match nothing survive, because the default nullglob/failglob behaviour leaves
an unmatched pattern as-is. That asymmetry is what makes it hard to notice: some --include
flags look right and one does not.
Reproduction
mkdir -p /tmp/repro/target && cd /tmp/repro
printf 'const x = 1;\n' > target/app.mjs
touch decoy-one.mjs decoy-two.mjs # .mjs in the CWD, NOT in the target
printf '{"baseline":[],"javascript":["p/javascript"],"third_party":[]}\n' > rs.json
bash /path/to/run-scans.sh --target /tmp/repro/target --output-dir /tmp/repro/out \
--mode run-all --rulesets rs.json --dry-run | grep -o '\-\-include=[^ ]*' | sort -u
Actual:
--include=*.cjs
--include=decoy-one.mjs <-- from the CWD
--include=decoy-two.mjs <-- from the CWD
--include=*.js
--include=*.jsx
--include=*.ts
--include=*.tsx
--include=*.mjs is gone. Expected: the six globs from includes_for, unexpanded.
Impact
Every language-scoped ruleset silently skips the extension whose glob got expanded. Running from
a directory that holds a few .mjs files means p/javascript, p/nodejs and friends never look
at any .mjs file in the target — and scans.json reports the run as successful, with a
filesScanned count that looks plausible because the other extensions were still scanned.
The cross-language scans (p/security-audit, p/secrets, third-party repos) take no --include
at all, so those still cover everything. That makes the gap easy to miss: the unscoped scans look
complete and only the language-scoped ones are short.
It is CWD-dependent, which is presumably why CI does not catch it: run from a directory with no matching files and the globs pass through untouched.
Suggested fix
Disable pathname expansion around the loop, keeping the word splitting the comment asks for:
set -f
# shellcheck disable=SC2086
for g in $includes; do ARGV+=("--include=$g"); done
set +f
Verified against semgrep 1.176.1.
Source: trailofbits/skills