#297·skills

static-analysis: run-scans.sh expands --include globs against the CWD, silently dropping an extension

Author: luiscampos-evinkaCreated Sep 7, 2026Updated Sep 7, 2026

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.