Semgrep
Trail of BitsOfficialPublished by Trail of Bits
- Skill
- Workflow
- Security
- CC-BY-SA-4.0
- Low risk
- Has scripts
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill automates the execution of Semgrep security scans across a codebase. It manages the entire lifecycle of a scan: detecting languages, selecting appropriate rulesets, requiring explicit user approval for the scan plan, executing scans in parallel batches, and merging results into a unified SARIF file. It supports two modes: a full scan of all rulesets and an important-only mode that filters findings based on security category, confidence, and impact metadata.
What it does
- Detects programming languages and checks for Semgrep Pro availability.
- Selects relevant official and third-party rulesets based on detected languages.
- Presents a detailed scan plan for explicit user approval before execution.
- Executes scans using batched processes to generate raw JSON and SARIF outputs.
- Applies optional post-filtering to focus on high-confidence security vulnerabilities.
- Merges all scan results into a single, consolidated SARIF report.
When to use it
- Performing a security audit of a codebase.
- Identifying potential vulnerabilities prior to code review.
- Scanning for known bug patterns.
- Conducting first-pass static analysis.
Advantages
- Enforces security best practices like disabling telemetry during scans.
- Ensures consistent scan configuration through automated script execution.
- Provides a clear approval gate to confirm scan scope and rulesets.
- Supports cross-file taint analysis when Semgrep Pro is available.
- Consolidates findings from multiple rulesets into a single report.
Usage examples
- “Run a security scan on the current directory.”
- The agent detects the languages, proposes a ruleset plan, and waits for your approval before running the scan.
- “Perform an important-only security audit on /path/to/code.”
- The agent configures the scan to filter for medium-to-high confidence security findings and generates a merged SARIF report.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/trailofbits/semgrep/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip semgrep.zip -d .agents/skills
Documentation by Trail of Bits
Semgrep Security Scan
Run a Semgrep scan with automatic language detection, parallel execution, and merged SARIF output.
Essential Principles
Always use
--metrics=off— Semgrep sends telemetry by default;--config autoalso phones home. Everysemgrepcommand must include--metrics=offto prevent data leakage during security audits.User must approve the scan plan (Step 3 is a hard gate) — The original "scan this codebase" request is NOT approval. Present exact rulesets, target, engine, and mode; wait for explicit "yes"/"proceed" before spawning scanners.
Third-party rulesets are required, not optional — Trail of Bits, 0xdea, and Decurity rules catch vulnerabilities absent from the official registry. Include them whenever the detected language matches.
scripts/run-scans.shgenerates the commands; do not write them yourself — it builds everysemgrepline from the approved list. That is what makes--metrics=off, the--includescoping rule, and the parallel dispatch properties of the code rather than instructions. Give it the approved rulesets and let it run.Always check for Semgrep Pro before scanning — Pro enables cross-file taint tracking and catches ~250% more true positives. Skipping the check means silently missing critical inter-file vulnerabilities.
Report what did not run —
scans.jsoncarriesfailedandskippedalongsidescans. A ruleset whose repo would not clone, or whose scan exited non-zero, must appear in the report. A partial scan presented as a complete one is worse than no scan.
When to Use
Security audit of a codebase
Finding vulnerabilities before code review
Scanning for known bug patterns
First-pass static analysis
When NOT to Use
Binary analysis → Use binary analysis tools
Already have Semgrep CI configured → Use existing pipeline
Need cross-file analysis but no Pro license → Consider CodeQL as alternative
Creating custom Semgrep rules → Use
semgrep-rule-creatorskillPorting existing rules to other languages → Use
semgrep-rule-variant-creatorskill
Output Directory
All scan results, SARIF files, and temporary data are stored in a single output directory.
If the user specifies an output directory in their prompt, use it as
OUTPUT_DIR.If not specified, default to
./static_analysis_semgrep_1. If that already exists, increment to_2,_3, etc.
In both cases, always create the directory with mkdir -p before writing any files.
# Resolve output directory
if [ -n "$USER_SPECIFIED_DIR" ]; then
OUTPUT_DIR="$USER_SPECIFIED_DIR"
else
BASE="static_analysis_semgrep"
N=1
while [ -e "${BASE}_${N}" ]; do
N=$((N + 1))
done
OUTPUT_DIR="${BASE}_${N}"
fi
mkdir -p "$OUTPUT_DIR/raw" "$OUTPUT_DIR/results"The output directory is resolved once at the start of Step 1 and used throughout all subsequent steps.
$OUTPUT_DIR/
├── rulesets.json # The approved plan (Step 3), read by run-scans.sh (Step 4)
├── scans.json # What ran, failed, skipped, and covered nothing (Step 4)
├── raw/ # Per-scan raw output (unfiltered)
│ ├── python-python.json # <language>-<ruleset> for language-scoped rules
│ ├── python-python.sarif
│ ├── python-django.json
│ ├── python-django.sarif
│ ├── all-security-audit.json # all-<ruleset> for cross-language rules, run once
│ ├── all-security-audit.sarif
│ └── ...
└── results/ # Final merged output
└── results.sarifPrerequisites
Required: Semgrep CLI (semgrep --version). If not installed, see Semgrep installation docs.
Optional: Semgrep Pro — enables cross-file taint tracking, inter-procedural analysis, and additional languages (Apex, C#, Elixir). Check with:
# --metrics=off because Principle 1 has no exceptions, and this is the first semgrep command
# of a run. stderr is kept because "OSS only" has several causes (logged out, no subscription,
# registry blocked) and the run downgrades silently for all of them.
if PRO_ERR=$(semgrep --pro --validate --metrics=off --config p/default 2>&1); then
echo "Pro available"
else
echo "OSS only"
echo " reason: $(printf '%s' "$PRO_ERR" | tail -n 3)"
fiLimitations: OSS mode cannot track data flow across files. Pro mode uses -j 1 for cross-file analysis (slower per ruleset, but parallel rulesets compensate).
Scan Modes
Select mode in Step 2. Mode affects both the scan flags and post-processing.
| Mode | Coverage | Findings Reported |
|---|---|---|
| Run all | All rulesets, all severity levels | Everything |
| Important only | All rulesets, pre- and post-filtered | Security vulns only, medium-high confidence/impact |
Important only applies two filter layers:
Pre-filter:
--severity WARNING --severity ERROR(CLI flag)Post-filter: JSON metadata — keeps only
category=security,confidence∈{MEDIUM,HIGH},impact∈{MEDIUM,HIGH}
See scan-modes.md for metadata criteria and jq filter commands.
Orchestration Architecture
┌──────────────────────────────────────────────────────────────────┐
│ MAIN SESSION (this skill) │
│ Step 1: Detect languages + check Pro availability │
│ Step 2: Select scan mode + rulesets (ref: rulesets.md) │
│ Step 3: Present plan + rulesets, get approval [⛔ HARD GATE] │
│ Step 4: Run scripts/run-scans.sh with the approved rulesets │
│ Step 5: Post-filter, merge, report, delete repos/ │
└──────────────────────────────────────────────────────────────────┘
│ Step 4: Bash
▼
┌──────────────────────────────────────────────────────────────────┐
│ scripts/run-scans.sh │
│ clone each third-party repo once, into repos/ │
│ generate one semgrep command per ruleset │
│ ├── python p/python, p/django --include=*.py│
│ ├── javascript p/javascript --include=*.js│
│ ├── docker p/dockerfile │
│ └── cross-language p/security-audit, p/secrets, │
│ the cloned repos (no filter) │
│ run in batches of --jobs, exit code read per process │
│ write scans.json — scans, failed, skipped │
└──────────────────────────────────────────────────────────────────┘The approval gate stays in the session; the script is execution only and asks nothing. The approved list reaches it as a JSON file, so the scan cannot reach a ruleset the user declined.
Cross-language rulesets go in one shared unit rather than being repeated per language.
p/security-audit, p/secrets, and the third-party repos scan the whole target unscoped,
so running them once per language ran the identical command N times and left the SARIF
merge to dedup the copies.
Running it as a Workflow
This plugin ships /static-analysis:semgrep-scan, which runs the whole scan end to end:
detect languages and Pro, select rulesets from rulesets.md, run
scripts/run-scans.sh, merge and report. Pass it a JSON object, not prose:
/static-analysis:semgrep-scan {"target": "/abs/path", "mode": "run-all"}It does not stop for ruleset approval. Invoking it with a target is the opt-in, the same
way /variant-analysis:variants works. That is safe to do because the scan is read-only over
the target — no --autofix, every write inside the output directory — so the approval gate
below is a scope confirmation rather than a safety one. What ran is recorded in
rulesets.json and scans.json either way.
Use the workflow when you want the scan run; work the five steps below when the ruleset selection itself matters and you want to see and edit the list first.
Workflow
Follow the detailed workflow in scan-workflow.md. Summary:
| Step | Action | Gate | Key Reference |
|---|---|---|---|
| 1 | Resolve output dir, detect languages + Pro availability | — | Use Glob, not Bash |
| 2 | Select scan mode + rulesets | — | rulesets.md |
| 3 | Present plan, get explicit approval | ⛔ HARD | AskUserQuestion |
| 4 | Run the scans | — | scripts/run-scans.sh |
| 5 | Post-filter, merge, report, clean up | — | Merge script (below) |
Task enforcement: On invocation, create 5 tasks with blockedBy dependencies (each step blocks the previous). Step 3 is a HARD GATE — mark complete ONLY after user explicitly approves.
Merge command (Step 5):
# run-all
uv run --no-project {baseDir}/scripts/merge_sarif.py "$OUTPUT_DIR/raw" "$OUTPUT_DIR/results/results.sarif" \
--scans "$OUTPUT_DIR/scans.json"
# important-only, once the JSON post-filter has run over every file in raw/
uv run --no-project {baseDir}/scripts/merge_sarif.py "$OUTPUT_DIR/raw" "$OUTPUT_DIR/results/results.sarif" \
--important --scans "$OUTPUT_DIR/scans.json"--scans drops the output of scans listed under .failed. A scan that died part-way may still
have written a .sarif, and under --important that file has no post-filter beside it, which is
an error rather than an empty filter. Without the flag one dead scan denies every healthy scan a
merged result. The excluded files are named on stdout, so they can go in the report.
The post-filter reads metadata SARIF does not carry, so it cannot be re-run against the merged
file; --important instead keeps the findings the JSON filter kept, matched on
(rule, file, line). Without it results.sarif is unfiltered while the JSON side is not.
Workflow and agents
| Component | Purpose |
|---|---|
scripts/run-scans.sh | Builds every scan command from the approved rulesets, runs them in batches, and writes scans.json |
Step 4 is a Bash call. No subagent runs any part of the scan: exit codes and finding counts are read from the processes and the JSON they wrote.
Rationalizations to Reject
| Shortcut | Why It's Wrong |
|---|---|
| "User asked for scan, that's approval" | Original request ≠ plan approval. Present plan, use AskUserQuestion, await explicit "yes" |
| "Step 3 task is blocking, just mark complete" | Lying about task status defeats enforcement. Only mark complete after real approval |
| "I already know what they want" | Assumptions cause scanning wrong directories/rulesets. Present plan for verification |
| "Just use default rulesets" | User must see and approve exact rulesets before scan |
| "Add extra rulesets without asking" | Modifying approved list without consent breaks trust |
| "Third-party rulesets are optional" | Trail of Bits, 0xdea, Decurity catch vulnerabilities not in official registry — REQUIRED |
| "Use --config auto" | Sends metrics; less control over rulesets |
| "I'll just run the semgrep commands myself" | run-scans.sh is what enforces --metrics=off, the --include rule and the output-directory --exclude. Hand-written commands drop them silently |
| "The script failed, I'll run semgrep directly to get something" | A non-zero exit means no scan succeeded. Report that and stop; a hand-run subset reads as a full scan |
| "Some scans failed, the run still finished" | failed and skipped are part of scans.json. Report them or the user reads a partial scan as a clean one |
| "Pro is too slow, skip --pro" | Cross-file analysis catches 250% more true positives; worth the time |
| "Semgrep handles GitHub URLs natively" | URL handling fails on repos with non-standard YAML; always clone first |
| "Cleanup is optional" | Cloned repos pollute the user's workspace and accumulate across runs |
"Use . or relative path as target" | Subagents need absolute paths to avoid ambiguity |
| "Let the user pick an output dir later" | Output directory must be resolved at Step 1, before any files are created |
Reference Index
| File | Content |
|---|---|
| rulesets.md | Complete ruleset catalog and selection algorithm |
| scan-modes.md | Pre/post-filter criteria and jq commands |
| Workflow | Purpose |
|---|---|
| scan-workflow.md | Complete 5-step scan execution process |
scripts/run-scans.sh | The scan runner Step 4 calls |
Success Criteria
[ ] Output directory resolved (user-specified or auto-incremented default)
[ ] All generated files stored inside
$OUTPUT_DIR[ ] Languages detected with file counts; Pro status checked
[ ] Scan mode selected by user (run all / important only)
[ ] Rulesets include third-party rules for all detected languages
[ ] User explicitly approved the scan plan (Step 3 gate passed)
[ ]
run-scans.shexited 0 and wrote$OUTPUT_DIR/scans.json[ ]
failedandskippedfromscans.jsonare empty, or listed in the report[ ] Scans marked
partialinscans.jsonare none, or listed in the report — they ran with some of their rules failing to compile[ ] Every
semgrepcommand used--metrics=off[ ] Approved plan written to
$OUTPUT_DIR/rulesets.jsonat the Step 3 gate, and passed to the scanner unchanged[ ]
coveredNothingfromscans.jsonis empty, or listed in the report[ ]
oversizedfromscans.jsonis empty, or listed in the report — those files exceed--max-target-bytesand no ruleset opened them[ ] Raw per-scan outputs stored in
$OUTPUT_DIR/raw/[ ]
results.sarifexists in$OUTPUT_DIR/results/and is valid JSON[ ] Important-only mode: post-filter applied before merge, merge run with
--important, unfiltered results preserved inraw/[ ] Results summary reported with severity and category breakdown
[ ] Cloned repos (if any) cleaned up from
$OUTPUT_DIR/repos/
Files
- ATTRIBUTION.mdAdded by AutoGineer923 B
- LICENSELicence file added by AutoGineer19.7 KiB
- SKILL.md15.3 KiB
- agents/openai.yaml229 B
- assets/trail-of-bits-mark.svg3.0 KiB
- references/rulesets.md7.5 KiB
- references/scan-modes.md8.5 KiB
- scripts/merge_sarif.pyScript13.7 KiB
- scripts/run-scans.shScript33.6 KiB
- scripts/test_merge_sarif.pyScript22.4 KiB
- workflows/scan-workflow.md24.1 KiB
scripts/run-scans.sh
#!/usr/bin/env bash
# Step 4 of the semgrep skill: run the selected rulesets and report what each scan produced.
# Generating every command here is what keeps --metrics=off, --include and --exclude out of a
# model's hands. Exit codes and counts come from the processes and the JSON they wrote.
# No `declare -A` and no `wait -n`, so this runs on macOS's bash 3.2.
set -euo pipefail
readonly METRICS_OFF="--metrics=off"
# semgrep --severity accepts INFO, WARNING and ERROR only, and rejects anything else with exit 2
# before scanning. The metadata thresholds LOW/MEDIUM/HIGH are applied by the post-filter in
# references/scan-modes.md, not here.
SEVERITY_FLAGS=(--severity WARNING --severity ERROR)
readonly DEFAULT_JOBS=4
# semgrep drops any file larger than --max-target-bytes and reports NOTHING about it: no
# paths.skipped entry, no error, and the file is simply absent from .paths.scanned. A scan that
# never opened a 1 MB bundle therefore records `findings: 0` next to a plausible filesScanned
# count, which is indistinguishable from a file that was opened and found clean. semgrep's own
# default is 1,000,000 bytes, which generated sources, single-file bundles, vendored blobs and
# legacy monoliths pass routinely, so the limit is set explicitly here, recorded in scans.json,
# and the files it still drops are named — see the oversized report below.
readonly DEFAULT_MAX_TARGET_BYTES=20000000
# A semgrep join rule carries `mode: join` and the `join:` block that mode needs, and requiring
# both is what keeps the prune below off a rule that merely mentions the words. Each is anchored
# as a YAML key on its own line — optionally the first key of a list item, optionally quoted,
# with a trailing comment allowed — because one file holds many rules and deleting it on a
# `message:` that quotes the docs would take every sibling rule with it. A rule that names the
# mode without the block is not a valid join rule; semgrep rejects it per-rule, which the exit-2
# handling below now survives, so leaving it in place costs nothing.
readonly JOIN_MODE_RE="^[[:space:]]*(-[[:space:]]+)?mode:[[:space:]]*(join|\"join\"|'join')[[:space:]]*(#.*)?\$"
readonly JOIN_BLOCK_RE="^[[:space:]]*join:[[:space:]]*(#.*)?\$"
usage() {
cat <<'USAGE'
Usage: run-scans.sh --target DIR --output-dir DIR --mode MODE --rulesets FILE [options]
--target DIR absolute path to the tree to scan
--output-dir DIR absolute path for results; raw/ and repos/ are created under it
--mode MODE run-all | important-only
--rulesets FILE JSON: {"baseline":[...], "<language>":[...], "third_party":["https://..."]}
--pro add --pro to every command (Semgrep Pro engine)
--jobs N concurrent semgrep processes (default 4)
--max-target-bytes N
skip files larger than N bytes; 0 means no limit, as it does in
semgrep (default 20000000). semgrep's own default is 1000000 and
it drops the file with no trace in the JSON
--dry-run print the commands that would run, then exit; clones nothing
Writes OUTPUT_DIR/scans.json:
{scans:[{lang,ruleset,json,sarif,findings,filesScanned,partial,exitCode}],
failed:[...], skipped:[...], unscoped:[lang], alsoShared:["lang/ruleset"],
maxTargetBytes:N, oversized:[path]}
partial is a scan that wrote complete output while some of its rules failed to compile.
oversized names the files an --include glob selected that are too large for semgrep to open.
USAGE
}
die() {
echo "run-scans.sh: $*" >&2
exit 1
}
# --include globs per language. Checked against semgrep by scanning one file per extension;
# re-run that when bumping it, because a type omitted here is excluded with no signal and the
# ruleset reads clean rather than incomplete. Absent because semgrep does not parse them:
# .mts, .cts, .C, Containerfile, Dockerfile.prod. javascript carries the TypeScript globs
# because one detection category covers both.
includes_for() {
case "$1" in
python) echo '*.py *.pyi' ;;
javascript) echo '*.js *.jsx *.mjs *.cjs *.ts *.tsx' ;;
typescript) echo '*.ts *.tsx' ;;
go) echo '*.go' ;;
ruby) echo '*.rb' ;;
java) echo '*.java *.jsp' ;;
kotlin) echo '*.kt *.kts' ;;
php) echo '*.php *.phtml' ;;
c) echo '*.c *.h' ;;
cpp) echo '*.c *.cc *.cpp *.cxx *.h *.hh *.hpp *.hxx' ;;
csharp) echo '*.cs' ;;
rust) echo '*.rs' ;;
scala) echo '*.scala' ;;
swift) echo '*.swift' ;;
elixir) echo '*.ex *.exs' ;;
solidity) echo '*.sol' ;;
docker) echo 'Dockerfile *.dockerfile' ;;
terraform) echo '*.tf *.tfvars *.hcl' ;;
json) echo '*.json' ;;
apex) echo '*.cls *.trigger' ;;
cloudformation) echo '*.yaml *.yml *.json' ;;
github-actions) echo '*.yml *.yaml' ;;
kubernetes) echo '*.yaml *.yml' ;;
yaml) echo '*.yaml *.yml' ;;
*) echo '' ;;
esac
}
# Folds plan spellings onto the keys includes_for knows, so `js` and `javascript` do not
# become two units and scan the same ruleset twice.
canonical_lang() {
local k
k=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
case "$k" in
js | jsx | node | nodejs | 'js/ts' | 'javascript/typescript') echo javascript ;;
ts | tsx) echo typescript ;;
golang) echo go ;;
'c/c++' | 'c++' | cxx) echo cpp ;;
dockerfile) echo docker ;;
k8s) echo kubernetes ;;
'c#' | dotnet) echo csharp ;;
sol) echo solidity ;;
tf | hcl) echo terraform ;;
cfn) echo cloudformation ;;
'github actions' | githubactions | gha) echo github-actions ;;
salesforce) echo apex ;;
*) echo "$k" ;;
esac
}
# Filenames, not identifiers: a registry id and a clone path must both reduce to something safe
# to concatenate into a shell-quoted path.
slug() {
case "$1" in
*://*) repo_dir_name "$1" ;;
*) printf '%s' "${1##*/}" | sed 's/[^A-Za-z0-9._-]\{1,\}/-/g; s/^-\{1,\}//; s/-\{1,\}$//' ;;
esac
}
# The clone directory carries the owner: several orgs publish a repo named semgrep-rules, and
# keying on the basename alone would collide them into one directory.
repo_dir_name() {
local u=$1 repo owner
u=${u%.git}
u=${u%/}
repo=${u##*/}
u=${u%/*}
owner=${u##*/}
printf '%s' "${owner:-unknown}-${repo:-rules}" | sed 's/[^A-Za-z0-9._-]\{1,\}/-/g'
}
# Deletes every .yaml/.yml under $1 that the remaining arguments select, printing what it took.
# The status is returned rather than swallowed: the prunes that use this keep files semgrep
# cannot handle out of a --config directory, and one that quietly stops matching puts the bug it
# exists to prevent straight back, with nothing on stderr to say so.
prune_yaml() { # prune_yaml <dir> <find-predicate...>
local dir=$1
shift
find "$dir" \( -name '*.yaml' -o -name '*.yml' \) -type f "$@" -print -delete
}
# `wc -l` reads one line in the empty string, so the zero case comes from the string itself.
count_lines() {
if [ -z "$1" ]; then
echo 0
else
printf '%s\n' "$1" | wc -l | tr -d ' '
fi
}
TARGET="" OUTPUT_DIR="" MODE="" RULESETS_FILE="" PRO="" DRY_RUN=""
JOBS=$DEFAULT_JOBS
MAX_TARGET_BYTES=$DEFAULT_MAX_TARGET_BYTES
while [ $# -gt 0 ]; do
case "$1" in
--target)
TARGET=${2:-}
shift 2
;;
--output-dir)
OUTPUT_DIR=${2:-}
shift 2
;;
--mode)
MODE=${2:-}
shift 2
;;
--rulesets)
RULESETS_FILE=${2:-}
shift 2
;;
--jobs)
JOBS=${2:-}
shift 2
;;
--max-target-bytes)
MAX_TARGET_BYTES=${2:-}
shift 2
;;
--pro)
PRO=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-h | --help)
usage
exit 0
;;
*) die "unknown argument: $1" ;;
esac
done
command -v jq >/dev/null 2>&1 || die "jq is required"
[ -n "$DRY_RUN" ] || command -v semgrep >/dev/null 2>&1 || die "semgrep is required"
# Each check fails the run rather than degrading it: a scan with no rulesets, or against the
# wrong path, produces a clean-looking empty report.
case "$TARGET" in /*) ;; *) die "--target must be an absolute path, got '${TARGET}'" ;; esac
case "$OUTPUT_DIR" in /*) ;; *) die "--output-dir must be an absolute path, got '${OUTPUT_DIR}'" ;; esac
[ -d "$TARGET" ] || die "--target is not a directory: $TARGET"
case "$MODE" in run-all | important-only) ;; *) die "--mode must be run-all or important-only, got '${MODE}'" ;; esac
if [ -z "$RULESETS_FILE" ] || [ ! -r "$RULESETS_FILE" ]; then
die "--rulesets file is missing or unreadable: ${RULESETS_FILE}"
fi
jq -e . "$RULESETS_FILE" >/dev/null 2>&1 || die "--rulesets is not valid JSON: $RULESETS_FILE"
case "$JOBS" in '' | *[!0-9]*) die "--jobs must be a positive integer, got '${JOBS}'" ;; esac
[ "$JOBS" -ge 1 ] || die "--jobs must be at least 1"
# Bytes only. semgrep also takes '1.5MB', but the oversized report below has to compare the
# limit against a file size, so a suffixed value would have to be parsed twice and the two
# readings could disagree. 0 is accepted and means no limit, which is what semgrep does with it.
case "$MAX_TARGET_BYTES" in
'' | *[!0-9]*) die "--max-target-bytes must be a whole number of bytes, got '${MAX_TARGET_BYTES}'" ;;
esac
# Both paths resolve the same way before being compared. Resolving only one makes the equality
# and inside-the-target checks miss on any symlinked path (every /var path on macOS), and the
# run then scans its own cloned rules. The output dir may not exist yet, so the deepest
# existing ancestor is resolved and the rest appended.
resolve_path() {
local p=$1 tail=""
while [ ! -d "$p" ]; do
tail="/${p##*/}$tail"
p=${p%/*}
[ -n "$p" ] || p=/
done
printf '%s' "$(cd "$p" && pwd)$tail"
}
TARGET=$(resolve_path "$TARGET")
TARGET_ROOT=${TARGET%/}
OUTPUT_ROOT=$(resolve_path "${OUTPUT_DIR%/}")
[ "$OUTPUT_ROOT" != "$TARGET_ROOT" ] ||
die "--output-dir is the scan target; the run would scan its own output and its cloned rules"
# A denylist, not an allowlist: real directories contain spaces and parentheses, which are
# inert. These four stay live inside double quotes and a control character can smuggle in a
# newline. One pattern each, since one bracket expression cannot hold both quote styles.
has_unsafe_char() {
case "$1" in
*'"'*) return 0 ;;
*'$'*) return 0 ;;
*'`'*) return 0 ;;
*[\\]*) return 0 ;;
*[[:cntrl:]]*) return 0 ;;
esac
return 1
}
for p in "$TARGET" "$OUTPUT_ROOT"; do
! has_unsafe_char "$p" || die "path contains a character that is not safe to splice: $p"
done
# jq -e over the whole file, so a bad entry stops the run before anything is fetched.
jq -e '
to_entries
| all(.value | type == "array")
' "$RULESETS_FILE" >/dev/null 2>&1 || die "every value in --rulesets must be an array"
jq -e '
to_entries
| map(select(.key != "third_party") | .value[])
| all(type == "string" and test("^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$")
and (split("/") | index("..") | not))
' "$RULESETS_FILE" >/dev/null 2>&1 ||
die "ruleset entries must be registry identifiers like p/python; repository URLs go under third_party"
jq -e '
(.third_party // [])
| all(type == "string" and test("^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._-]+)+(\\.git)?/?$"))
' "$RULESETS_FILE" >/dev/null 2>&1 || die "third_party entries must be https git URLs"
RAW_DIR="$OUTPUT_ROOT/raw"
REPOS_DIR="$OUTPUT_ROOT/repos"
# The default output dir lands inside the target, so without --exclude every scan also reads
# the cloned rule repos (full of literal example secrets) and the raw JSON siblings are still
# writing. --exclude takes a pattern, not a rooted path: `out` also excludes `vendor/out`, and
# there is no anchored form, so it is announced rather than applied quietly.
EXCLUDE_ARG=""
EXCLUDE_PATTERN=""
case "$OUTPUT_ROOT/" in
"$TARGET_ROOT"/*)
rel=${OUTPUT_ROOT#"$TARGET_ROOT"/}
EXCLUDE_ARG="--exclude=$rel"
# Recorded in scans.json, not just announced here. This drops files from every scan, and an
# unanchored pattern drops more than the output directory: --exclude=out also skips
# src/out/. On stderr alone the report has nothing to show, so the gap looks like clean
# coverage — the same silent truncation `coveredNothing` and the unparseable list exist to
# prevent.
EXCLUDE_PATTERN="$rel"
echo "note: output directory is inside the target; excluding '$rel' from every scan." >&2
echo " semgrep matches that pattern anywhere in the tree." >&2
;;
esac
WORK=$(mktemp -d "${TMPDIR:-/tmp}/run-scans.XXXXXX")
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
SCAN_LIST="$WORK/scans.tsv"
SKIPPED="$WORK/skipped.tsv"
ALSO_SHARED="$WORK/also-shared.txt"
UNSCOPED="$WORK/unscoped.txt"
# A ruleset whose --include globs matched no file exits 0 with an empty result, which is
# indistinguishable in scans.json from a ruleset that ran and found nothing. That is how a plan
# naming the wrong languages reads as a clean audit: p/python on a Go tree reports 0 findings
# exactly like p/gosec would have. semgrep counts what it opened in .paths.scanned, so the two
# can be told apart and the ones that covered nothing named.
COVERED_NOTHING="$WORK/covered-nothing.txt"
OVERSIZED="$WORK/oversized.txt"
: >"$SCAN_LIST"
: >"$SKIPPED"
: >"$ALSO_SHARED"
: >"$UNSCOPED"
: >"$COVERED_NOTHING"
: >"$OVERSIZED"
# Stems are the filename half of every output path and the key results are matched on, so a
# collision would let one scan's result be read as another's.
USED_STEMS="$WORK/stems.txt"
: >"$USED_STEMS"
unique_stem() {
local base=$1 n=2 candidate=$1
while grep -Fxq "$candidate" "$USED_STEMS" 2>/dev/null; do
candidate="$base-$n"
n=$((n + 1))
done
printf '%s\n' "$candidate" >>"$USED_STEMS"
printf '%s' "$candidate"
}
add_scan() {
local lang=$1 ruleset=$2 config=$3 includes=$4 stem
stem=$(unique_stem "$(printf '%s' "$lang" | sed 's/[^A-Za-z0-9._-]\{1,\}/-/g')-$(slug "$ruleset")")
printf '%s\t%s\t%s\t%s\t%s\n' "$stem" "$lang" "$ruleset" "$config" "$includes" >>"$SCAN_LIST"
}
mapfile_compat() { # read newline-separated stdin into a named array, bash 3.2 safe
local __name=$1 __line
eval "$__name=()"
while IFS= read -r __line; do
[ -n "$__line" ] || continue
eval "$__name+=(\"\$__line\")"
done
}
BASELINE=()
THIRD_PARTY=()
LANG_KEYS=()
mapfile_compat BASELINE < <(jq -r '(.baseline // []) | unique[]' "$RULESETS_FILE")
mapfile_compat THIRD_PARTY < <(jq -r '(.third_party // [])[]' "$RULESETS_FILE")
mapfile_compat LANG_KEYS < <(jq -r 'to_entries[] | select(.key != "baseline" and .key != "third_party" and (.value | length) > 0) | .key' "$RULESETS_FILE")
# Deduplicated by clone directory rather than by exact string: two spellings of one repository
# (with and without .git) survive a string comparison but collide on one destination, and the
# second clone would fail into a non-empty tree.
CLONE_URLS=()
CLONE_NAMES=()
for url in ${THIRD_PARTY[@]+"${THIRD_PARTY[@]}"}; do
name=$(repo_dir_name "$url")
seen=""
for existing in ${CLONE_NAMES[@]+"${CLONE_NAMES[@]}"}; do
[ "$existing" = "$name" ] && seen=1 && break
done
[ -n "$seen" ] && continue
CLONE_URLS+=("$url")
CLONE_NAMES+=("$name")
done
if [ -z "$DRY_RUN" ]; then
# Cleared for the same reason each clone destination is. merge_sarif.py globs every *.sarif
# in here, so into a reused output directory a run that drops a ruleset still merges the
# previous run's output for it: results.sarif and its total then cover a ruleset this run's
# scans.json never mentions. Only this script's own output lives here.
rm -rf "$RAW_DIR"
mkdir -p "$RAW_DIR"
[ ${#CLONE_URLS[@]} -eq 0 ] || mkdir -p "$REPOS_DIR"
fi
# ------------------------------------------------------------------ clone phase
CLONED_URLS=()
CLONED_PATHS=()
i=0
while [ $i -lt ${#CLONE_URLS[@]} ]; do
url=${CLONE_URLS[$i]}
name=${CLONE_NAMES[$i]}
i=$((i + 1))
dest="$REPOS_DIR/$name"
if [ -n "$DRY_RUN" ]; then
CLONED_URLS+=("$url")
CLONED_PATHS+=("$dest")
continue
fi
# Cleared first: git clone refuses a non-empty directory, so a reused output directory would
# drop an approved ruleset while usable rules sat on disk.
rm -rf "$dest"
if ! err=$(git clone --depth 1 "$url" "$dest" 2>&1); then
printf '%s\t%s\n' "$url" "$(printf '%s' "$err" | tail -n 3 | tr '\n' ' ')" >>"$SKIPPED"
continue
fi
# semgrep parses EVERY .yaml/.yml under a --config directory as a rule file, and a single
# unparseable one aborts the whole scan with exit 7 — no findings from any of the rules that
# were fine. Rule repos ship their own CI config alongside their rules, and a workflow's
# `on: pull_request:` is a null value, which semgrep rejects outright. Observed killing both
# trailofbits/semgrep-rules (.github/workflows/semgrep-rules-format.yml) and
# elttam/semgrep-rules (perf-templates/benchmark-tests.yml) — two required rulesets silently
# contributing nothing.
#
# A semgrep rule file always has a top-level `rules:` key; nothing else here does. Pruning on
# that also drops the `*.test.yaml` fixtures, which are rule test inputs rather than rules.
# Measured on this prune alone: keeps 118/145 files for trailofbits and 80/94 for elttam,
# losing no real rule. The join prune below then takes elttam's one join rule, leaving 79.
#
# `mode: join` rules are the second thing semgrep 1.173 cannot take: they crash it outright
# (AttributeError in join_rule.py), which kills the whole batch and writes no output at all —
# a hard process failure rather than the rule-level error a bad rule produces. elttam's
# rules/generic/jsp-likely-xss.yaml is one. Only .yaml/.yml are considered, so a README
# quoting the docs is not a candidate, and the pair of keys above is what makes it a rule.
#
# A prune that deletes nothing is a legitimate outcome — a repo may ship rules and nothing
# else — but a prune that cannot run is not, so its status is checked and the ruleset is
# skipped with a reason rather than scanned from a half-pruned tree.
if ! non_rules=$(prune_yaml "$dest" ! -exec grep -q '^rules:' {} \;) ||
! join_rules=$(prune_yaml "$dest" -exec grep -qE "$JOIN_MODE_RE" {} \; \
-exec grep -qE "$JOIN_BLOCK_RE" {} \;); then
printf '%s\t%s\n' "$url" "could not prune unscannable YAML from the clone" >>"$SKIPPED"
rm -rf "$dest"
continue
fi
# Reported every time, including as zeroes. These two prunes are the difference between a
# ruleset that runs and one that contributes nothing, and a run that stopped pruning would
# otherwise look exactly like a run with nothing to prune.
printf '%s: pruned %s non-rule YAML file(s) and %s join-mode rule file(s)\n' \
"$name" "$(count_lines "$non_rules")" "$(count_lines "$join_rules")" >&2
# A repository that cloned but carries no rules scans nothing, and reporting it as fine would
# show a completed scan against a ruleset that never ran.
#
# -print -quit rather than `find … | head -1`: under pipefail, head exits after the first line
# and find dies on SIGPIPE with 141 once its output passes the pipe buffer. pipefail makes the
# pipeline non-zero, so a repository with enough rules to fill 64 KiB of pathnames — which is
# every real rule repo, trailofbits/semgrep-rules included — was recorded as carrying none.
# The required third-party rules then silently did not run. -quit stops at the first match
# with no reader to race.
if [ -z "$(find "$dest" \( -name '*.yaml' -o -name '*.yml' \) -type f -print -quit)" ]; then
printf '%s\t%s\n' "$url" "cloned but contains no rule files" >>"$SKIPPED"
continue
fi
CLONED_URLS+=("$url")
CLONED_PATHS+=("$dest")
done
# ------------------------------------------------------------- build the scan list
# Cross-language rulesets scan the whole target unscoped, so they run once for the run rather
# than once per language, and they never take --include: they carry rules for every language
# and a filter would drop findings in the files it does not match.
for ruleset in ${BASELINE[@]+"${BASELINE[@]}"}; do
add_scan "all" "$ruleset" "$ruleset" ""
done
i=0
while [ $i -lt ${#CLONED_URLS[@]} ]; do
add_scan "all" "${CLONED_URLS[$i]}" "${CLONED_PATHS[$i]}" ""
i=$((i + 1))
done
is_baseline() {
for b in ${BASELINE[@]+"${BASELINE[@]}"}; do
[ "$b" = "$1" ] && return 0
done
return 1
}
# Language keys are folded onto their canonical name before any unit exists, so `js` and
# `javascript` in one plan produce one unit rather than two with identical globs.
CANON_SEEN="$WORK/canon.txt"
: >"$CANON_SEEN"
for key in ${LANG_KEYS[@]+"${LANG_KEYS[@]}"}; do
# The key reaches the scan command as the language half of the output filename, and it
# arrives as free-form generated text, so it gets the same treatment the paths do.
! has_unsafe_char "$key" || die "ruleset key reaches the scan command as a filename; '$key' is not a language"
lang=$(canonical_lang "$key")
[ "$lang" != "all" ] || die "ruleset key '$key' names the reserved language 'all' used by the cross-language unit"
printf '%s\n' "$lang" >>"$CANON_SEEN"
done
# Redirected rather than piped: a `for … in $(…)` splits on every whitespace character, and a
# pipeline would put the loop in a subshell.
sort -u "$CANON_SEEN" >"$WORK/langs.txt"
while IFS= read -r lang; do
[ -n "$lang" ] || continue
# jq cannot express canonical_lang, so the union happens here: every key folding onto this
# language contributes its rulesets, deduplicated. A ruleset listed twice for one language
# would otherwise scan twice, and the second copy's failure would read as the first's success.
own=""
for key in ${LANG_KEYS[@]+"${LANG_KEYS[@]}"}; do
[ "$(canonical_lang "$key")" = "$lang" ] || continue
own="$own$(jq -r --arg k "$key" '.[$k][]' "$RULESETS_FILE")
"
done
own=$(printf '%s' "$own" | grep -v '^$' | sort -u || true)
[ -n "$own" ] || continue
globs=$(includes_for "$lang")
had_own=""
while IFS= read -r ruleset; do
[ -n "$ruleset" ] || continue
# A ruleset already running unscoped over the whole target does not need a narrower second
# run. The merged SARIF dedups the copies; a per-scan sum of findings does not.
if is_baseline "$ruleset"; then
printf '%s/%s\n' "$lang" "$ruleset" >>"$ALSO_SHARED"
continue
fi
add_scan "$lang" "$ruleset" "$ruleset" "$globs"
had_own=1
done <<EOF
$own
EOF
# Reported only when the language actually got a unit. An unrecognized name costs the
# --include optimization, not coverage: the rules run against every file and fail to match
# what they do not understand. A language emptied by the baseline dedup ran nothing at all,
# so naming it here would point at a scan that never happened.
if [ -n "$had_own" ] && [ -z "$globs" ]; then
printf '%s\n' "$lang" >>"$UNSCOPED"
fi
done <"$WORK/langs.txt"
[ -s "$SCAN_LIST" ] || die "the ruleset plan produced no scans; there is nothing to run"
# ---------------------------------------------------------------------- commands
# Populates the global ARGV with the command for one scan. An array executed directly rather
# than a string run through eval: `eval "$cmd" &` reports exit status 1 whatever the command
# actually exited with, which would mark every failed scan as a success, and building argv also
# leaves no shell-quoting surface for a ruleset or path to escape through.
ARGV=()
build_argv() {
local config=$1 includes=$2 json=$3 sarif=$4 g
ARGV=(semgrep)
[ -z "$PRO" ] || ARGV+=(--pro)
ARGV+=("$METRICS_OFF")
ARGV+=(--max-target-bytes "$MAX_TARGET_BYTES")
[ "$MODE" != "important-only" ] || ARGV+=("${SEVERITY_FLAGS[@]}")
# 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
# On every command including the unscoped cross-language ones: those are precisely the
# rulesets that would otherwise read the cloned rule repositories.
[ -z "$EXCLUDE_ARG" ] || ARGV+=("$EXCLUDE_ARG")
ARGV+=(--config "$config" --json -o "$json" "--sarif-output=$sarif" "$TARGET")
}
# A pasteable rendering of ARGV for --dry-run. Anything outside a plainly safe set is wrapped
# whole, so a glob reaches semgrep rather than being expanded by the shell it is pasted into.
render_argv() {
local out="" a
for a in "${ARGV[@]}"; do
case "$a" in
*[!A-Za-z0-9._=/:-]*) out="$out \"$a\"" ;;
*) out="$out $a" ;;
esac
done
printf '%s' "${out# }"
}
if [ -n "$DRY_RUN" ]; then
while IFS=$'\t' read -r stem lang ruleset config includes; do
build_argv "$config" "$includes" "$RAW_DIR/$stem.json" "$RAW_DIR/$stem.sarif"
render_argv
printf '\n'
done <"$SCAN_LIST"
exit 0
fi
# ------------------------------------------------------------- oversized files
# What semgrep refused to open, as against what it opened and found clean. filesScanned counts
# the second and nothing in the JSON reports the first, so a file over the limit reads as a file
# that is clean. Named here for the reason coveredNothing and excludePattern are: a gap only
# stderr knows about is a gap the report calls coverage.
#
# Scoped to the --include globs this run actually passes, because those are the files a scan
# claimed to cover. The cross-language rulesets take no --include and semgrep decides for itself
# which files it can parse, so their share of the gap is not enumerable from here;
# references/scan-modes.md says so, and maxTargetBytes is in the report for a reader to check by
# hand. includes_for only ever emits basename patterns, which is what makes -name enough.
scoped_globs() {
cut -f5 "$SCAN_LIST" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' '
}
# The globs are read into an array rather than left to word-split: unquoted expansion is
# pathname expansion too, and these patterns are meant to match under the target, not the CWD.
find_oversized() { # <limit> <globs> -> paths relative to TARGET, one per line
local limit=$1 globs=$2 g f
local gl nameargs prune
# 0 disables semgrep's own filter, so nothing is dropped for its size and there is no gap to
# report. Guarded rather than left to `find -size +0c`, which matches every non-empty file.
[ "$limit" -gt 0 ] || return 0
gl=()
nameargs=()
prune=()
[ -n "$(printf '%s' "$globs" | tr -d ' ')" ] || return 0
IFS=' ' read -r -a gl <<<"$globs"
for g in ${gl[@]+"${gl[@]}"}; do
[ -n "$g" ] || continue
if [ ${#nameargs[@]} -eq 0 ]; then
nameargs=(-name "$g")
else
nameargs+=(-o -name "$g")
fi
done
[ ${#nameargs[@]} -gt 0 ] || return 0
# The output directory is already --exclude'd from every scan, so a file inside it is not a
# coverage gap; listing it would put this run's own raw JSON in the report.
[ -z "$EXCLUDE_PATTERN" ] || prune=(-name "$EXCLUDE_PATTERN" -prune -o)
find "$TARGET" ${prune[@]+"${prune[@]}"} -type f -size +"${limit}"c \
\( "${nameargs[@]}" \) -print 2>/dev/null | while IFS= read -r f; do
printf '%s\n' "${f#"$TARGET"/}"
done | sort
}
OVERSIZED_LIST=$(find_oversized "$MAX_TARGET_BYTES" "$(scoped_globs)")
printf '%s\n' "$OVERSIZED_LIST" >"$OVERSIZED"
if [ -n "$OVERSIZED_LIST" ]; then
echo "note: $(count_lines "$OVERSIZED_LIST") file(s) exceed --max-target-bytes" >&2
echo " ($MAX_TARGET_BYTES) and no scan will open them; see .oversized in scans.json." >&2
fi
# ------------------------------------------------------------------- run the scans
# Batched rather than a rolling slot count, because `wait -n` needs bash 4.3 and this has to run
# on the bash 3.2 that ships with macOS. semgrep holds the rules and the scanned ASTs in memory,
# so an unbounded fan-out gets processes OOM-killed and those come back as ordinary scan
# failures with nothing pointing at memory as the cause.
total=$(wc -l <"$SCAN_LIST" | tr -d ' ')
echo "running $total scan(s), $JOBS at a time" >&2
pids=()
stems=()
run_batch() {
local idx=0
for idx in "${!pids[@]}"; do
if wait "${pids[$idx]}"; then
echo 0 >"$WORK/rc.${stems[$idx]}"
else
echo $? >"$WORK/rc.${stems[$idx]}"
fi
done
pids=()
stems=()
}
while IFS=$'\t' read -r stem lang ruleset config includes; do
json="$RAW_DIR/$stem.json"
sarif="$RAW_DIR/$stem.sarif"
build_argv "$config" "$includes" "$json" "$sarif"
"${ARGV[@]}" >"$WORK/out.$stem" 2>"$WORK/err.$stem" &
pids+=("$!")
stems+=("$stem")
[ ${#pids[@]} -lt "$JOBS" ] || run_batch
done <"$SCAN_LIST"
[ ${#pids[@]} -eq 0 ] || run_batch
# ---------------------------------------------------------------------- assemble
: >"$WORK/scans.jsonl"
: >"$WORK/failed.jsonl"
while IFS=$'\t' read -r stem lang ruleset config includes; do
json="$RAW_DIR/$stem.json"
sarif="$RAW_DIR/$stem.sarif"
rc=$(cat "$WORK/rc.$stem" 2>/dev/null || echo 127)
findings=-1
scanned=-1
ok=""
# Exit 0 covers both "found nothing" and "found plenty", so it says nothing about findings.
# Exit 1 is a successful scan on older versions.
#
# Exit 2 is not only "no scan happened". semgrep also returns 2 when individual rules fail
# to compile while the rest of the run completes and writes complete output — e.g. 12 Java
# rules in elttam/semgrep-rules that current semgrep cannot parse, alongside 107 that ran
# fine, and apiiro/malicious-code-ruleset whose own log read "Scan completed successfully
# • Findings: 51". Both were thrown away. Exit 2 also still covers a bad argument, where
# nothing is written at all; the artifact checks below tell the two apart, so 2 is allowed
# through and flagged partial rather than trusted outright.
#
# Anything outside 0/1/2 stays fatal however plausible the artifacts look, exit 7 (config
# would not load) included. Verified on semgrep 1.173: the exit code for an unloadable
# config depends on the OUTPUT FLAGS, which is worth knowing before trusting either number.
# Same rules directory, same target, back to back:
#
# semgrep --config rules target -> 7, nothing written
# semgrep --config rules -o out.json --sarif-output=out.sarif -> 2, nothing written
#
# This script uses the second form, so a config that will not load reaches here as 2 with
# no artifacts, and the -s checks below reject it on their own. The fatal branch is
# therefore belt-and-braces rather than the thing doing the work — but it costs nothing,
# it is what the suite pins, and it means a future semgrep that writes an empty result set
# alongside a hard failure cannot be read as a clean scan.
partial=""
fatal=""
case "$rc" in
0 | 1) ;;
2) partial=1 ;;
*) fatal=1 ;;
esac
if [ -z "$fatal" ] && [ -s "$json" ] && [ -s "$sarif" ]; then
if findings=$(jq -e '.results | length' "$json" 2>/dev/null); then
ok=1
# null and empty are different answers: a semgrep that does not report .paths gives -1,
# "not known", while a present-but-empty list is a scan that genuinely opened no file.
# Collapsing them with `// []` would report every scan as covering nothing.
scanned=$(jq 'if .paths.scanned == null then -1 else (.paths.scanned | length) end' \
"$json" 2>/dev/null || echo -1)
else
findings=-1
fi
fi
if [ -n "$ok" ]; then
[ "$scanned" -ne 0 ] || printf '%s/%s\n' "$lang" "$ruleset" >>"$COVERED_NOTHING"
# `partial` marks a run whose results are real but incomplete — some rules failed to
# compile. Reporting it as an unqualified success would overstate coverage; dropping it
# entirely (the old behaviour) understated it far worse.
jq -nc --arg lang "$lang" --arg ruleset "$ruleset" --arg json "$json" \
--arg sarif "$sarif" --argjson findings "$findings" --argjson scanned "$scanned" \
--argjson partial "$([ -n "$partial" ] && echo true || echo false)" --arg rc "$rc" \
'{lang:$lang, ruleset:$ruleset, json:$json, sarif:$sarif, findings:$findings,
filesScanned:$scanned, partial:$partial, exitCode:($rc|tonumber)}' \
>>"$WORK/scans.jsonl"
else
# Carries the same paths a success does: a scan that crashed part-way may still have
# written a partial file, and the report needs to be able to name it.
err=$(tail -c 800 "$WORK/err.$stem" 2>/dev/null || true)
[ -n "$err" ] || err="semgrep exited $rc"
jq -nc --arg lang "$lang" --arg ruleset "$ruleset" --arg json "$json" \
--arg sarif "$sarif" --arg error "$err" \
'{lang:$lang, ruleset:$ruleset, json:$json, sarif:$sarif, error:$error}' \
>>"$WORK/failed.jsonl"
fi
done <"$SCAN_LIST"
PRO_JSON=false
[ -z "$PRO" ] || PRO_JSON=true
jq -n \
--arg outputDir "$OUTPUT_ROOT" \
--arg rawDir "$RAW_DIR" \
--arg reposPath "$REPOS_DIR" \
--arg mode "$MODE" \
--arg excludePattern "$EXCLUDE_PATTERN" \
--argjson pro "$PRO_JSON" \
--slurpfile scans "$WORK/scans.jsonl" \
--slurpfile failed "$WORK/failed.jsonl" \
--rawfile skippedRaw "$SKIPPED" \
--rawfile alsoSharedRaw "$ALSO_SHARED" \
--rawfile unscopedRaw "$UNSCOPED" \
--rawfile coveredNothingRaw "$COVERED_NOTHING" \
--rawfile oversizedRaw "$OVERSIZED" \
--argjson maxTargetBytes "$MAX_TARGET_BYTES" \
'{
outputDir: $outputDir, rawDir: $rawDir, reposPath: $reposPath, mode: $mode, pro: $pro,
excludePattern: $excludePattern, maxTargetBytes: $maxTargetBytes,
scans: $scans, failed: $failed,
skipped: ($skippedRaw | split("\n") | map(select(length > 0)) | map(split("\t"))
| map({ruleset: .[0], reason: (.[1] // "clone failed")})),
alsoShared: ($alsoSharedRaw | split("\n") | map(select(length > 0)) | unique),
unscoped: ($unscopedRaw | split("\n") | map(select(length > 0)) | unique),
coveredNothing: ($coveredNothingRaw | split("\n") | map(select(length > 0)) | unique),
oversized: ($oversizedRaw | split("\n") | map(select(length > 0)) | unique)
}' >"$OUTPUT_ROOT/scans.json"
n_ok=$(jq '.scans | length' "$OUTPUT_ROOT/scans.json")
n_failed=$(jq '.failed | length' "$OUTPUT_ROOT/scans.json")
n_skipped=$(jq '.skipped | length' "$OUTPUT_ROOT/scans.json")
echo "$n_ok scan(s) succeeded, $n_failed failed, $n_skipped skipped" >&2
echo "$OUTPUT_ROOT/scans.json"
# A run where nothing succeeded is a failed run, not a clean one. Reporting zero findings from
# it is the failure this whole script is shaped to avoid.
[ "$n_ok" -gt 0 ] || exit 1
Requirements and compatibility
Written for: Claude Code
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Declares allowed tools, Names Claude Code tools, Needs Python
Security
What our scanner noticed· Low risk
- Scripts that your agent may run
This skill includes scripts that your agent may run. Read them before you use it.
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- Trail of Bits
- Publisher
- Trail of Bits (Official)
- Source
- trailofbits/skills · plugins/static-analysis/skills/semgrep · commit e4dd0bb
- Licence
- Creative Commons Attribution-ShareAlike 4.0 International (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Trail of Bits.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download semgrep.zip