Skip to content

feat(scan): scan shebang scripts, and give shell a rule set - #87

Merged
ralyodio merged 1 commit into
fix/xxe-requires-xml-evidencefrom
feat/shell-rules-and-shebang
Aug 10, 2026
Merged

feat(scan): scan shebang scripts, and give shell a rule set#87
ralyodio merged 1 commit into
fix/xxe-requires-xml-evidencefrom
feat/shell-rules-and-shebang

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #86 — review #85 and #86 first; this PR's diff is the third commit.

The bug

Two gaps that compound into a scanner reporting success on a repository it never read.

1. shell had no rules. It is a language in ScanLanguage, mapped from .sh/.bash/.zsh, and zero of the 32 code rules targeted it. A bash codebase got secret detection and nothing else.

2. Language detection was extension-only. languageOf() reads extname() and nothing else, so a file with no extension was skipped before it was opened.

Executables are routinely named for the command they provide rather than the language they are written in — debtap, configure, gradlew. On ralyodio/debtap, whose entire source is 3,511 lines of bash in a file called debtap:

✔ Scanned 1 files          ← LICENSE and README.md; the script was never opened
✓ No security issues found!

Pointed straight at the file, it is unambiguous:

$ threatcrush scan debtap
✔ Scanned 0 files
✓ No security issues found!

A clean scan that scanned nothing is worse than no scan — it is a green check that says the opposite of the truth.

The fix

Shebang detection. For files with no extension, read a 128-byte prefix and take the language from the #! line. Files with an unrecognised extension are still skipped — .png is not a script, and sniffing every file would mean reading the whole tree. The prefix read matters: an extensionless blob costs one 128-byte read rather than a megabyte decoded as UTF-8 and thrown away.

Seven shell rules, drawn from the classes that actually appear in installers and packaging scripts:

rule CWE what it catches
sh-remote-script-execution CWE-494 curl … | bash
sh-eval-expansion CWE-78 eval "$cmd"
sh-unquoted-expansion-destructive CWE-78 rm -rf $DIR/…
sh-insecure-transport-flag CWE-295 curl -k, --no-check-certificate
sh-plaintext-download CWE-319 curl http://…
sh-world-writable-permissions CWE-732 chmod 777
sh-predictable-temp-path CWE-377 a literal /tmp/name, unless mktemp is in the window

Every rule is built against the corrected shape as well as the vulnerable one, per this file's existing contract. rm -rf "$DIR" does not match because [^"'\n]*? cannot cross the quote; curl … | jq does not match because the pipe target must be a shell; chmod 0755 does not match.

The eval rule is matched against eval's argument, not the line

The obvious spelling, \beval\b.*\$, is wrong. Bash's ordinary way to build a numeric range is

for r in $(eval echo {$(($k + 1))..$(($k + $n - 1))}); do

Every expansion there sits inside $((…)), which the shell parses as an arithmetic expression — a ; in it is a syntax error, not a second command. A line-wide search still finds $k inside the arithmetic and fires. That spelling produced 355 findings in debtap alone, all of them this one safe loop.

Anchoring to the argument (eval "$cmd", eval $cmd, eval "$(…)") keeps the shape that genuinely re-parses text as source. eval echo $x is not covered, and the comment says so: separating it from the range idiom needs to know which expansions are arithmetic, which is parsing rather than matching.

Also handled: the documented shell-init idiom eval "$(pyenv init -)" is guarded, and -k on a plain-HTTP URL no longer double-reports — there is no certificate to skip, so sh-plaintext-download is the finding that fits.

Verification

ralyodio/debtap: 0 → 8 findings, all genuine.

103  sh-insecure-transport-flag   curl -k -s https://packages.ubuntu.com …
104  sh-plaintext-download        curl … http://ftp.debian.org/… > /var/cache/debtap/…
106  sh-plaintext-download
108  sh-plaintext-download
111  sh-plaintext-download
113  sh-plaintext-download
120  sh-insecure-transport-flag   curl -k -L https://github.com/…/master.tar.gz
128  sh-insecure-transport-flag   curl -k -C - -f https://aur.archlinux.org/packages.gz

Lines 120 and 128 are the sharp ones: an archive and a package list fetched over HTTPS with verification disabled, written to /var/cache/debtap/, and used to build packages. Anyone able to intercept that connection chooses what gets packaged.

Intermediate tuning, all measured on debtap: 368 → 13 (eval anchored to its argument) → 8 (plain-HTTP double-report removed).

ionic-team/capacitor at 5e5bb3b: unchanged at 13. No shell rule fires on it and shebang detection adds no files, so this stacks cleanly on #85 and #86.

83 tests pass, up from 68. 15 added — each shell rule has a positive case and the corrected shape beside it, plus the brace-range idiom, the shell-init idiom, loopback HTTP, and a check that shell rules do not leak into a JS file that merely contains the same words. tsc --noEmit clean.

`shell` was a language the type system knew about and no rule targeted,
so a repository written entirely in bash got secret detection and nothing
else. Worse, language detection was extension-only: an executable named
for the command it provides rather than the language it is written in was
never opened at all.

Both together meant `ralyodio/debtap` — 3,511 lines of bash in a file
called `debtap` — scanned clean by scanning nothing, and reported success
while doing it.

- Read the interpreter from a `#!` line for extensionless files, sniffed
  from a 128-byte prefix so a checked-in blob costs one small read rather
  than a megabyte decoded and discarded. Unrecognised extensions are still
  skipped; `.png` is not a script.
- Seven shell rules: remote script execution, eval on an expansion,
  unquoted expansion in a recursive remove, disabled certificate
  verification, plain-HTTP download, world-writable permissions and
  predictable temp paths.

Each rule is built against the corrected shape as well as the vulnerable
one. The eval rule matches eval's argument rather than the whole line,
because `\beval\b.*\$` counts bash's dynamic-range idiom — where every
expansion is arithmetic and cannot carry a command — and reported it 355
times in debtap alone.

On debtap: 0 findings (nothing scanned) to 8, all genuine — `curl -k`
against HTTPS and plain-HTTP fetches whose payloads build packages.
Capacitor is unchanged at 13. 83 tests pass, up from 68.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* line of every installer.
*/
const UNTRUSTED_SH =
/\$\{?[1-9]\d*\b|\$[@*]|\$\{@\}|\bread\s+(?:-\S+\s+)*[A-Za-z_]\w*|\$\{?REPLY\b|\$\{?QUERY_STRING\b/;
languages: ['shell'],
// The pipe must be the *next* thing: `curl -o f url && sh f` is a different
// (and checkable) shape, and `curl url | jq` is not an execution at all.
pattern: /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?(?:\/bin\/|\/usr\/bin\/)?(?:ba|da|k|z|a)?sh\b/,
// actually re-parses untrusted text as source. `eval echo $x` is not
// covered; catching it without also catching the range idiom needs to know
// which expansions are arithmetic, which is parsing, not matching.
pattern: /\beval\s+(?:-\S+\s+)*(?:"\s*)?\$(?:\{?[A-Za-z_]\w*|\((?!\())/,
// never reaches the `$` and never matches. Only a genuinely bare expansion
// does. Restricted to recursive/forced removal: a bare `$f` in `rm $f` is
// sloppy, but it is not the shape that erases a filesystem.
pattern: /\brm\s+(?:-[a-zA-Z-]*[rRf][a-zA-Z-]*\s+)+[^"'\n]*?\$\{?[A-Za-z_]/,
cwe: 'CWE-732',
severity: 'medium',
languages: ['shell'],
pattern: /\bchmod\s+(?:-[a-zA-Z-]+\s+)*(?:0?777|a\+rwx|ugo\+rwx|a=rwx)\b/,
// Redirection or an explicit write into a literal `/tmp` path. A `$$` or
// `$RANDOM` suffix is still predictable, so it is not treated as a fix;
// `mktemp` is, and it is the guard below.
pattern: /(?:>{1,2}\s*|\b(?:tee|touch|cp|mv|install)\s+(?:-\S+\s+)*)\/tmp\/[\w.$-]+/,
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

150 finding(s)

HIGH/CRITICAL: 12 | MEDIUM: 102 | LOW: 36

Severity Rule Location
HIGH sql-template-interpolation apps/cli/src/scan/__tests__/code-rules.test.ts:30
HIGH secret-aws-access-key apps/cli/src/scan/secret-rules.ts:192
HIGH js-shell-exec-interpolation modules/code-scanner/src/__tests__/sast.test.ts:31
HIGH js-shell-exec-interpolation modules/code-scanner/src/__tests__/sast.test.ts:102
HIGH js-shell-exec-interpolation modules/code-scanner/src/__tests__/sast.test.ts:108
HIGH secret-aws-access-key modules/code-scanner/src/secrets/rules.ts:74
HIGH secret-aws-access-key prd/0003-detect-hardcoded-secrets-before-they-are-committed-or-served.md:126
HIGH js-unsafe-yaml-load apps/cli/src/scan/__tests__/code-rules.test.ts:215
HIGH manifest-typosquat apps/mobile/package.json:43
HIGH secret-generic-credential modules/spend-guard/config/example.conf.toml:13
HIGH secret-generic-credential modules/spend-guard/README.md:84
HIGH secret-generic-credential PRD.md:268
MEDIUM js-shell-exec-interpolation apps/cli/src/commands/init.ts:70
MEDIUM js-shell-exec-interpolation apps/cli/src/commands/init.ts:79
MEDIUM sql-template-interpolation apps/cli/src/commands/properties.ts:226
MEDIUM js-shell-exec-interpolation apps/cli/src/commands/service.ts:88
MEDIUM js-shell-exec-interpolation apps/cli/src/commands/service.ts:111
MEDIUM sql-template-interpolation apps/cli/src/core/state.ts:121
MEDIUM sql-template-interpolation apps/cli/src/core/state.ts:125
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:31
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:33
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:34
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:35
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:36
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:43
MEDIUM sql-template-interpolation apps/cli/src/daemon/firewall/adapters.ts:49
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:49
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:56
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:63
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:82
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:84
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:85
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:93
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:98
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:105
MEDIUM js-shell-exec-interpolation apps/cli/src/daemon/firewall/adapters.ts:112
MEDIUM sql-template-interpolation apps/cli/src/index.ts:105
MEDIUM sql-template-interpolation apps/cli/src/index.ts:110
MEDIUM sql-template-interpolation apps/cli/src/index.ts:120
MEDIUM js-shell-exec-interpolation apps/cli/src/index.ts:411
MEDIUM sql-string-concatenation apps/cli/src/scan/__tests__/code-rules.test.ts:20
MEDIUM sql-template-interpolation apps/cli/src/scan/__tests__/code-rules.test.ts:34
MEDIUM sql-string-concatenation apps/cli/src/scan/__tests__/code-rules.test.ts:39
MEDIUM sql-string-concatenation apps/cli/src/scan/__tests__/code-rules.test.ts:48
MEDIUM sql-string-concatenation apps/cli/src/scan/__tests__/code-rules.test.ts:52
MEDIUM js-shell-exec-interpolation apps/cli/src/scan/__tests__/code-rules.test.ts:64
MEDIUM js-unsafe-yaml-load apps/cli/src/scan/__tests__/code-rules.test.ts:211
MEDIUM redos-nested-quantifier apps/cli/src/scan/code-rules.ts:140
MEDIUM redos-nested-quantifier apps/cli/src/scan/code-rules.ts:742
MEDIUM redos-nested-quantifier apps/cli/src/scan/code-rules.ts:769

…and 100 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 7f0ee9e into fix/xxe-requires-xml-evidence Aug 10, 2026
4 checks passed
@ralyodio
ralyodio deleted the feat/shell-rules-and-shebang branch August 10, 2026 17:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants