Source SAST
One row in rules/cwe-22-path-traversal.toml names three different analysis engines:
[[sinks]]
name = "open"
detectors = ["source-c", "source-python", "binary"]
args = [{ idx = 0, kind = "Path" }]
notes = "POSIX open() / Python builtin open() — first arg is the path."
It is the only row in the catalog that names all three. source-c merges it into a tree-sitter walker's sink table, source-python into a rustpython AST walker's. The binary engine — the SSA taint engine over lifted machine code — already carries an identical hardcoded open, so it discards the catalog copy on the name collision: three surfaces behave the same, two are driven by this row.
| Rows | Engines named | Which rules |
|---|---|---|
| 1,190 | at least one | every row carrying a detectors line |
| 1,084 | exactly one | single-surface rules |
| 106 | more than one | the genuinely shared part of the catalog |
| 96 | source-c + binary | C-level primitives — libc string, format, file and privilege calls, SSL_CTX_set_verify, and the SQLite, libpq and MySQL query entry points |
| 9 | Kotlin + Java | JVM-family pairs |
| 1 | source-c + source-python + binary | the open row above |
Naming an engine is not the same as driving it: of those 96 rows, 25 are the definition the binary engine actually reads, and the rest are shadowed, gated out or unmodelled.
Both surfaces emit the same finding shape — CWE id, rule id, severity, confidence, location — into the same store, and both project into the same SARIF 2.1.0 run. The binary engine answers what is wrong with this code? by lifting an executable; the source walkers answer it by parsing the code the executable came from. system(user_input) fires the same CWE-78 topic whether an analyst uploaded the C file or the compiled ELF.
Ten language walkers run in production over uploaded source and over files pulled out of firmware images, propagating taint from request objects and process inputs to catalog sinks, alongside two secret scanners and a dependency inventory.
| Question | Section |
|---|---|
| What does a run actually emit? | Worked example: Python command injection |
| Which languages are covered, and by what parser? | Production language walkers · Walkers outside production dispatch |
| Where do the rules come from? | The shared catalog |
| When does a sanitiser really clear a flow? | Taint propagation and sanitiser scope |
| How are credentials and dependencies found? | Secret detection: two subsystems · The firmware filename gate · Secret output modes · Source SCA |
| What gets skipped, and where do findings land? | File dispatch and size limits · Storage and SARIF export |
| How good is it, and what does it miss? | The labelled corpus · Gap-corpus scores · Limits |
Worked example: Python command injection
The Python walker returns 30 findings on a 130-line Flask fixture and none on the lines marked safe. training/vulnerabilities/cwe-78-python-os-system-injection/main.py carries semgrep-style markers — # ruleid: on a line that must fire, # ok: on a line that must not:
7 @app.route("/route_param/<route_param>")
8 def route_param(route_param):
10 # ruleid: os-system-injection
11 return os.system(route_param)
13 @app.route("/route_param_ok/<route_param>")
14 def route_param_ok(route_param):
16 # ok: os-system-injection
17 return os.system("ls -la")
37 @app.route("/get_param_inline", methods=["GET"])
38 def get_param_inline():
39 # ruleid: os-system-injection
40 os.system(flask.request.args.get("param"))
120 md5 = hashlib.md5(url+app.config['MD5_SALT']).hexdigest()
122 # ruleid: os-system-injection
123 r = os.system('wget %s -O "%s"'%(url, fpath))
Abridged, one line per finding:
cwe rule_id severity confidence location symbol
78 command-injection.os-system-non-literal Critical Medium main.py:11:12 route_param os.system
78 command-injection.os-system Critical High main.py:11:12 route_param web route handler parameter
78 command-injection.os-system-non-literal Critical Medium main.py:40:5 get_param_inline os.system
78 command-injection.os-system Critical High main.py:40:5 get_param_inline http-query
78 command-injection.os-system-non-literal Critical Medium main.py:75:5 post_param os.system
327 weak-crypto.broken-primitive Medium Medium main.py:120:15 index weak crypto
327 weak-crypto.hashlib-md5 Medium Medium main.py:120:15 index hashlib.md5
78 command-injection.os-system-non-literal Critical Medium main.py:123:13 index os.system
78 command-injection.os-system Critical High main.py:123:13 index http-form
Line 17 produces nothing.
Two tiers, but not on every line. 17 lines carry a ruleid: marker; 11 get both tiers and 6 get only Tier-A. command-injection.os-system-non-literal is Tier-A: the argument to os.system is not a string literal, true regardless of where the value came from — Medium confidence, naming the sink. command-injection.os-system is the taint verdict — High confidence, naming the source the flow started from (web route handler parameter, http-query, http-form).
The six Tier-A-only lines are a source-recognition gap, not a design choice. All six are the flask.request.form['param'] subscript idiom (lines 75, 82, 89, 96, 103, 110). The source table recognises flask.request.form.get('url', None) — line 118's value is tracked all the way to the sink on line 123 — but not the subscript form, so those flows are invisible to taint and only the non-literal shape survives. Route-handler parameters are auto-tainted, as line 11's High-confidence finding shows; Tier-A earns its place here by covering an unrecognised source idiom, and elsewhere by covering ordinary function parameters, which no rule auto-taints.
Location is path:line:col plus the enclosing scope name. That is what distinguishes a source finding from a binary one downstream: a binary finding puts a virtual address where this puts a line. The detector field does not — 118 of the 166 places a binary finding is built emit static-rule exactly as every source finding does, and only the other 48 emit heuristic or symbolic-exec.
A promoted case pins a floor, not a ceiling. The two findings on line 120 are correct CWE-327 hits inside a fixture written for CWE-78, and the case records all four rule ids, including the two weak-crypto ones it was never written to test — but a fifth rule appearing tomorrow would go unnoticed.
Production language walkers
Ten languages are dispatched by file extension in production, and nine of them propagate taint at binding level: a tainted name, not a tainted expression tree.
| Language | Parser | Taint | Sanitisers | Scope-typed | Catalog rows | Hardcoded sinks + calls |
|---|---|---|---|---|---|---|
| Python | rustpython AST | binding-level | 20 | yes | 321 | 19 + 0 |
| PHP | tree-sitter | binding-level | 31 | yes | 223 | 3 + 18 |
| JavaScript / TypeScript | oxc | binding-level | 19 | yes | 201 | 0 + 15 |
| C | tree-sitter | binding-level | 2 | no | 123 | 0 + 0 |
| Ruby | tree-sitter | binding-level | 16 | no | 107 | 73 + 25 |
| Java | tree-sitter | binding-level | 21 | no | 100 | 39 + 7 |
| Go | tree-sitter | binding-level | 13 | no | 74 | 48 + 25 |
| C# | tree-sitter | binding-level | 23 | no | 8 | 54 + 6 |
| Kotlin | tree-sitter | binding-level | 24 | no | 9 | 35 + 23 |
| Swift | tree-sitter | none | — | — | 22 | 0 + 0 |
The Catalog rows column counts shared-catalog entries carrying that walker's detector token, merged on top of that walker's own hardcoded table and deduplicated by name. It is not a coverage figure — read it against the last column, which is what the walker knows without the catalog. That last column counts hardcoded sink and dangerous-call tables only; shape rules held elsewhere, such as Swift's two config checks, are outside it.
TypeScript folds into the JavaScript walker: js, mjs, cjs, jsx, ts, tsx, mts, cts all route to the oxc parser, which picks TS flavour from the filename.
C has two sanitisers, realpath and canonicalize_file_name; nothing else in C clears a tainted value, so a hand-rolled path check leaves the flow tainted all the way to the sink. C is also the only walker whose behaviour is entirely catalog-driven — both its hardcoded tables are empty.
Kotlin and C# invert the ratio — 58 and 60 hardcoded entries against 9 and 8 catalog rows, so an edit to a shared topic file barely moves either walker. C# still carries 42 labelled corpus cases, because almost all of its detection lives in the hardcoded table underneath.
Swift has no taint propagation by design: it runs dangerous-call and shape rules. Its 22 catalog rows are 11 insecure-RNG, 6 log-injection and 5 SQLite entries; a hardcoded pair adds a secret-named UserDefaults key and JavaScriptCanOpenWindowsAutomatically = true. The SQLite check fires on any query argument it cannot resolve to a string literal, so a local bound to "SELECT …" stays quiet while an interpolated one fires. The six log-injection rules — NSLog, os_log, os_log_info, os_log_debug, os_log_error, os_log_fault — apply that same test to argument 0, and a non-literal format argument to NSLog is ordinary Swift, so CWE-117 is the family most likely to dominate the output on a real codebase.
Walkers outside production dispatch
Nine further walkers are absent from the production extension table and reachable only from the evaluation harness; a tenth crate, Rust, is wired to neither. The harness knows all nine, but training/gaps/ holds fixtures for only two.
| Walker | Measured by | Shape |
|---|---|---|
| Terraform | gap harness only — 260 fixtures, no promoted proofs, no corpus gate | 26 distinct rule ids over HCL |
| Generic (any text file) | gap harness only — 77 fixtures, no promoted proofs, no corpus gate | runs the source secret scanner over the file |
| Scala | in-crate corpus test over 22 promoted cases; zero gap fixtures | line-based, with a per-method taint-lite context rather than a parse tree |
| Apex, Dockerfile, OCaml, Clojure, HTML, Bash | in-crate corpus test over 12 / 7 / 7 / 5 / 4 / 3 promoted cases; zero gap fixtures | — |
| Rust | nothing | crate exists with zero consumers, wired to neither production dispatch nor the harness |
The shared catalog
Every rule ships inside the compiled binary: crates/rules embeds 40 cwe-*.toml topic files at build time, so there is no runtime filesystem read, and a missing catalog directory fails the build rather than degrading to a smaller built-in list at run time.
| Contents | Count | Notes |
|---|---|---|
| Topic files | 40 | cwe-*.toml, embedded at build time |
| Distinct CWE ids | 42 | |
| Dangerous-call entries | 640 | |
| Sink entries | 543 | |
| Lifecycle state machines | 7 | 19 transitions, 23 emit rules |
The seven machines are C FILE* streams, POSIX file descriptors, descriptors opened on /dev/random or /dev/urandom, pthread mutexes, heap pointers, Go channels and Go mutexes. Only the heap-pointer machine routes to the binary engine; the other six are source-only.
Routing a sink to both the C walker and the binary taint engine is one field on the row: detectors = ["source-c", "binary"].
Merge order is hardcoded-wins: each consumer merges the rows carrying its own token on top of its hardcoded table, and a name collision resolves to the hardcoded entry, because the curated tables carry per-symbol severity tiers a topic-wide severity cannot express.
Gates are honoured or declined by each consuming engine. A non_literal_arg = 0 gate means "fire only when argument 0 is not a literal constant". The C walker sees the argument expression and can enforce it; the binary side's import scan cannot, so it drops those rows instead of firing them unguarded:
if row.dc.non_literal_arg.is_some() {
// Source-level literal-arg gate doesn't translate to the
// binary import-scan layer; skip rather than emit unguarded.
continue;
}
14 dangerous-call rows declare binary and are dropped by that gate:
| Dropped family | Calls | Why the import scan cannot judge it |
|---|---|---|
| CWE-285 POSIX privilege | 10 — setuid, seteuid, setreuid, setresuid, their group twins, initgroups, setgroups | setuid(0) and setuid(NOBODY_UID) are ordinary startup code; only a caller-derived UID is the bug |
| CWE-120 bounded copy | 4 — strncpy, strncat, stpncpy, wcpncpy | the bug is a size argument the scan never sees |
SSL_CTX_set_verify, which carries no gate, fires on both surfaces.
Enterprise overrides append topics on top of the embedded floor and never replace the built-in catalog. The CLI entry point parses and validates the override directory before dispatching any command, and a configured-but-unreadable override aborts startup rather than running a partial ruleset. Ordering is enforced there, not by the catalog: the active catalog caches on first read and a second install call is silently discarded, so a consumer that reads before that hook runs gets the embedded floor with no error.
Which shared rows actually drive the binary engine
A row's detectors line is an intent, not a guarantee.
| Fate on the binary side | Rows | Effect |
|---|---|---|
| Shadowed by an identical hardcoded entry | 55 | detection unchanged, but editing the catalog row changes nothing on the binary surface |
Dropped by the non_literal_arg gate | 14 | source-only in practice |
| Kind not modelled by the binary sink table | 1 | PQexecParams — no CWE-89 sink kind on the binary side |
| Lifecycle machine | 1 | heap-pointer state machine |
| Catalog-driven — the row is the definition | 25 | an edit reaches the binary engine |
Across the whole catalog the same shape holds: 64 sink rows declare binary, and 13 survive dedup and kind-mapping to become entries the binary engine did not already have.
The binary engine models SQL injection but refuses a catalog SQL row. Its sink table defines a SqlInjection kind and hardcodes sqlite3_exec, sqlite3_prepare, sqlite3_prepare_v2, PQexec, mysql_query and mysql_real_query, yet the catalog importer maps the sql-string kind to nothing. Adding a SQL sink to rules/cwe-89-sql-injection.toml with detectors = ["binary"] is a silent no-op even though the capability is there — a wiring omission, not a precision tradeoff. The C walker is worse off: the same seven SQL rows declare source-c, its kind map drops sql-string too, and its hardcoded sink table is empty, so it cannot emit a CWE-89 finding at all and the catalog's SQLite, libpq and MySQL entries are inert on the source side.
Taint propagation and sanitiser scope
Taint is binding-level and per file. Sources — argv, environment, stdin, and framework request objects recognised by name and idiom — mark bindings tainted; assignment propagates; reassignment to a clean value kills it; a sink entry keyed on callee name and argument index fires when a tainted binding reaches it.
A sanitiser that clears every sink kind is a false-negative machine. os.system("ping " + re.escape(user)) is still command injection; a regex escape neutralises nothing a shell cares about. Python, JavaScript and PHP therefore type their sanitisers by scope — Universal, Html, Url, Shell, Sql, Path, Regex — and the walker asks whether that scope clears this sink kind. Five Flask handlers identical but for the wrapper around the tainted value, through the Python walker:
# five files, each: @app.route("/p/<user>")
# def f(user): <the one line below>
os.system("ping " + user) -> os-system-non-literal, os-system
os.system("ping " + re.escape(user)) -> os-system-non-literal, os-system
os.system("ping " + html.escape(user)) -> os-system-non-literal, os-system
os.system("ping " + shlex.quote(user)) -> os-system-non-literal
os.system("ping " + str(int(user))) -> os-system-non-literal
re.escape has scope Regex and clears nothing; html.escape has scope Html, which does not cover a shell argument; shlex.quote and the integer cast kill the taint verdict. The Tier-A non-literal rule survives all five, because the argument is still not a literal.
Six walkers do not have this, and in all six any sanitiser clears any sink:
| Walker | Sanitiser record | Scope-typed |
|---|---|---|
| Python, JavaScript, PHP | name plus scope — Universal, Html, Url, Shell, Sql, Path, Regex | yes |
| C | name, a return flag, and a list of out-arguments the call also cleans | no |
| Go, Java, Ruby, C#, Kotlin | bare name | no |
Runtime.exec on a value passed through Encode.forHtml is a false negative in the Java walker today.
Where scope is uncertain it stays Universal: narrowing a sanitiser can only create new false positives, so a scope is tightened only when the escaper is unambiguously context-specific.
The binary side's SSA lattice is the deeper instrument — dominator analysis, alias-aware seeding, interprocedural summaries (Findings). The source side sees names, argument expressions, and framework idioms the binary lost at compile time.
Secret detection: two subsystems
"High-entropy string inside a compiled binary" and "AWS key in a config file" have different false-positive profiles, so they are different scanners.
| Binary-magic secrets | Source-side secrets | |
|---|---|---|
| Scans | compiled bytes | text |
| Anchored detectors | 20 magic-anchored signatures, one per secret kind | 28 typed high-precision rules |
| Kinds | PEM private/public/certificate blocks, OpenSSH and PuTTY private keys, X.509 DER, PKCS#8/#12/#7, raw RSA and EC DER keys, PGP private keys, OpenPGP RSA session keys, JWTs, AWS AKIA/ASIA keys, GitHub tokens, Slack tokens, GCP service accounts, crypt(3) and LDAP-SHA password hashes | aws-access-key-id, github-pat, slack-bot-token, google-api-key, openai-api-key, stripe-live-secret and the rest |
| Unanchored passes | 3, with no single magic to anchor on, so they slide over the whole region: expanded AES round-key schedules, expanded SM4 schedules, a fingerprint table of known-leaked firmware signing keys | a vendored gitleaks pattern set, a Shannon-entropy scanner, an obfuscation pass that hex- and base64-decodes candidates and rescans the plaintext |
| Structural parsers | a real DER parse or key-structure check behind every signature, never a regex alone | PEM bodies, /etc/shadow rows, htpasswd files, default-credential files, network-service configs |
| Cap | 256 findings per binary | none |
The firmware signing-key table is transcribed from Binarly's FwHunt supply-chain rules: leaked OEM unlock keys, MSI Boot Guard and firmware-capsule keys, the Intel Alder Lake leak.
Three source-side structural parsers cover credentials the prefix and entropy rules miss.
| Parser | Fires on | Emits |
|---|---|---|
| Password hashes | its own basename list, separate from the credential-file gate below — shadow, shadow.default, shadow-, gshadow, gshadow-, master.passwd, spwd.db, smbpasswd, smbpasswd-, vsftpd.user_list | severity gated on the crypt scheme |
| Network-service configs | its own basename list — wpa_supplicant*.conf, hostapd*.conf, wg*.conf / wireguard*.conf, snmpd.conf, *.ovpn | CWE-798 for WPA PSKs and SNMP community strings, CWE-321 for WireGuard and inline OpenVPN key material |
| Vendor default credentials | the credential-file gate below or a network-config name, then one of 15 shipped defaults (admin, password, 1234, root, toor, changeme, the empty string and the rest) or username-equals-password for an admin-ish username | CWE-1392 |
The shadow parser is not a regex. It reads user:$id$rounds$salt$hash and gates severity on the scheme, because MD5-crypt cracks in minutes where SHA-512-crypt takes days.
The default-credential parser is a closed list, not a dictionary. It catches the admin:admin row a token scanner reads as low-entropy noise, and never fires on a hashed value.
Redaction shape. A binary-magic finding carries first 8 characters, ellipsis, last 4, 64 bytes maximum; PEM reduces to its header line, DER to a 16-byte hex prefix. The redaction protects the report, not the artifact — the verbatim bytes stay recoverable from the binary itself at the recorded offset and length.
The firmware filename gate
Over files pulled out of a firmware image the text scanner is filename-gated, because a generic password\s*= content grep returns about 0.1% true positives on real firmware extractions — jQuery's password:!0 selector, form handlers, sample configs. Restricting content scans to files whose name already says credential lifts precision to roughly 70%. 26 of the 28 typed patterns run on every file regardless, because AKIA followed by 16 base32 characters is not ambiguous; only basic-auth-in-url and cloudflare-global-api-key sit behind the gate, being the two whose shape is not self-identifying.
The gate is a closed list, matched exactly — there is no globbing, so id_rsa.bak and id_rsa_backup, the two shapes a careless backup actually produces, do not open it.
| Opens the gate | Values |
|---|---|
| 17 basenames | htpasswd, passwd, credentials, credentials.json, .env, .npmrc, .netrc, .pypirc, config.json, secrets.yml, secrets.yaml, id_rsa, id_dsa, id_ecdsa, id_ed25519, authorized_keys, known_hosts |
| 13 extensions | pem, key, crt, cer, der, p12, pfx, p7b, keystore, jks, htpasswd, kdbx, env |
| Not on it | shadow — it runs on its own list, feeding the shadow parser rather than the pattern table |
The gate cuts both ways: the gitleaks set, the entropy scanner and the obfuscation pass run only behind it, so a token sitting in an extracted /www/js/app.js is found only if it matches one of those 26 prefixes or carries PEM armour, which is checked on every file because -----BEGIN is its own out-of-band signal. The scan also sees only the first 4 MiB of each extracted file — the head the unpack pipeline already holds for the artifact classifier and the banner scan — so a credential deeper in than that is invisible whatever its shape. A recognised manifest or package-database path is the one exception, read to 128 MiB so a multi-megabyte rpmdb survives intact, and that whole head goes to the secret scanner too.
Two further losses are silent:
- Four gitleaks rules never load. The vendored gitleaks file holds 222 rules; 218 survive to run.
pkcs12-filematches on filename and ships no content regex;generic-api-key,pypi-upload-tokenandvault-batch-tokenhave compiled automata that each exceed the Rust regex crate's 10 MiB size limit. The first of those three is gitleaks' broadest catch-all, so the rule most likely to catch an unbranded in-house token is precisely the one that never loads. - Dedicated key files run one stage. Under the ten key and certificate extensions above — everything but
htpasswd,kdbxandenv— the entropy, obfuscation and gitleaks stages are skipped entirely, so the PEM detector's verdict stands alone rather than being buried under dozens of high-entropy hits on the key's own base64 body. An extensionlessid_rsais not in that set: it opens the gate but still runs every stage.
Secret output modes
Output differs by mode, and neither mode puts the matched bytes in the report.
| Firmware mode | Source mode (generic walker) | |
|---|---|---|
| Filename gate | content scans restricted to credential-named files | none |
| Typed patterns | 26 of 28 on every file; 2 behind the gate | all, over every line of every file |
| gitleaks set | behind the gate | over every line of every file |
| Output fields | redacted preview plus a BLAKE3 fingerprint of the raw match | rule id, CWE id, line number |
| Matched bytes in report | never | never |
The BLAKE3 fingerprint collapses one Apache testkey.pem rehosted under eight paths on a NAS rootfs into one finding and seven sightings instead of eight independent rows.
Dropping the filename gate is paid for by a per-match filter. A hit must:
- be at least 8 characters,
- carry Shannon entropy of 3.0 bits per character or more,
- avoid the placeholder markers
EXAMPLE/PLACEHOLDER/CHANGEME/REDACTED/XXXX/YOUR_, - and end on a byte that is neither alphanumeric nor
=.
So AKIAIOSFODNN7EXAMPLE in a README stays quiet, and so does a fixed-length pattern that matched only the prefix of a longer ==-padded dummy. FAKE, SAMPLE and TEST are deliberately absent from that list: they occur as substrings inside genuine high-entropy tokens, so excluding them would trade false positives for false negatives.
Source SCA
Dependency manifests and lockfiles are parsed for 11 ecosystems into a plain inventory — ecosystem, name, version, origin — with no transitive resolution and no network access, because a lockfile already enumerates the resolved tree. CVE attribution happens downstream, and the ecosystem determines how.
| Ecosystems | CVE join |
|---|---|
| npm, PyPI, Packagist, RubyGems, Go modules, Cargo, Maven, Debian dpkg, Alpine apk | map onto an OSV ecosystem name directly |
| opkg, RPM | matched by package name against a curated native table — there is no OSV OpenWRT feed, and the distro-release qualifier the OSV rpm feeds require is not recoverable from the rpmdb alone |
That native-table join is the same one described in CVEs & SBOM, which covers the firmware-tree side of it.
Bundled JavaScript is found by a separate scanner that recognises 15 banner patterns — jQuery, Bootstrap, Angular, lodash, moment and others — in web assets. It reads the licence banner, not the filename: /*! jQuery v1.10.2 | (c) 2005, 2013 jQuery Foundation yields jQuery 1.10.2, which is how a vendored copy no lockfile mentions still reaches the CVE join. A hit requires the canonical name and an adjacent dotted version, so prose like "see jQuery 1.x for details" does not match — and a bundle whose build stripped the licence header is invisible.
File dispatch and size limits
One entry point routes a file by extension through a single language table, skips non-UTF-8 content, and never errors: an unparseable file yields zero findings, not a failed analysis.
A file over 8 MB is skipped whole and logged, not truncated. The cap exists because firmware carries huge generated artefacts under source extensions (minified bundles, machine-emitted C#, data-table Python) that are zero-signal for taint analysis and cost a full parse tree in memory, which is an adversarial-upload denial-of-service vector. The consequence is a real blind spot: an over-cap .js bundle yields no taint findings at all, whatever is in it.
Where content is truncated upstream, tree-sitter and oxc error-recover, so the loss is recall on the tail rather than a fabricated finding — a half-parsed trailing function is absent from the tree rather than misread.
Storage and SARIF export
Source findings render through the same report path as binary findings into the same findings store, and the pipeline worker runs the walk off the async thread pool because a taint walk is CPU-bound.
The access-control scope row is written first: a failure there aborts the job rather than storing a report with no scope record, since the read path fails closed on a missing record and the alternative is a silently invisible child file.
SARIF export projects a source finding as an artifact URI plus a line region, a binary finding as an address, in the same run. The source path is attacker-influenced — it comes from an uploaded archive — so the exporter strips traversal and absolute components, and falls back to a synthetic artifact when nothing safe remains, rather than letting a hostile path escape into the emitted URI.
The labelled corpus
training/vulnerabilities/ holds 362 promoted cases across 18 languages, each recording the rule ids the walker must emit — and 120 of them are ungated, sitting in the corpus with nothing re-running them.
| Language | Promoted cases | Re-run by |
|---|---|---|
| Python | 68 | integration test |
| C# | 42 | in-crate test |
| Java | 34 | nothing |
| PHP | 34 | nothing |
| JavaScript | 32 | nothing |
| Go | 27 | integration test |
| Scala | 22 | in-crate test |
| Ruby | 20 | nothing |
| C | 15 | integration test |
| TypeScript | 13 | in-crate test |
| Kotlin | 13 | in-crate test |
| Apex | 12 | in-crate test |
| OCaml | 7 | in-crate test |
| Dockerfile | 7 | in-crate test |
| Clojure | 5 | in-crate test |
| Swift | 4 | in-crate test |
| HTML | 4 | in-crate test |
| Bash | 3 | in-crate test |
JavaScript is the surprise: the crate does have a corpus test, but it filters on -typescript- in the case directory name, so it re-runs the 13 TypeScript cases and skips all 32 JavaScript ones.
A gate asserts a floor: every recorded rule must appear, and extra rules are allowed through, because one sample can legitimately trip several detectors — so a rule that starts over-firing on a promoted case is not caught here. The gates also carry a patched-twin path — a case marked class = "patched" must fire nothing at all — but no case is so marked, so that path never runs.
One of the 362 records an empty rule set: a CWE-611 Express XXE case whose two vulnerable sites carry upstream semgrep's own todoruleid: markers, because the rule is not written in a way that can find them. It is a fixture with no positive assertion to make rather than a walker miss.
Gap-corpus scores
241 of the 966 scored gap fixtures pass. training/gaps/ holds 1,001 unpromoted fixtures with ruleid:/ok: markers, of which 966 have a walker to score them; running every walker against every one of those:
| Language | Fixtures | Pass | Misses a ruleid: line | Fires on an ok: line | Both |
|---|---|---|---|---|---|
| Terraform | 260 | 24 | 236 | 0 | 0 |
| Python | 235 | 69 | 136 | 11 | 19 |
| JavaScript | 141 | 32 | 88 | 11 | 10 |
| Java | 106 | 33 | 57 | 7 | 9 |
| Ruby | 79 | 20 | 47 | 4 | 8 |
| Generic (secrets) | 77 | 28 | 44 | 2 | 3 |
| Go | 68 | 35 | 14 | 13 | 6 |
| Solidity | 35 | not scored | — | — | — |
Terraform is the extreme: 26 HCL rule ids against 260 fixtures, so 236 of them miss at least one line they are required to flag, and none over-fire. Go is the opposite shape — the highest pass rate at 35 of 68, and the highest over-fire rate, 13 fixtures where a finding lands on a line marked safe. Solidity is the remaining 35: fixtures with no walker at all, so the harness does not even load them.
The harness matches by span, not by line, and ignores rule id. A marker owns the source from its own line to the next marker or end of file, so a ruleid: marker is satisfied by any finding anywhere in that stretch — one on the right line for the wrong reason counts as a pass, and so does one several lines further down. fn:, fp:, todook: and todoruleid: are neutral: they bound a span without voting either way, which is how an upstream fixture's known-unmatchable sites stay out of the score. Promotion into the labelled corpus is what re-records the rule ids. The recurring blocker in this loop follows from the same blindness: a correct finding from rule A lands inside rule B's ok: span and marks B failed. The fix is a hand-authored case that isolates B by removing A's trigger, never a weakened A.
Limits
- No cross-file dataflow. Analysis is per file. A taint flow crossing a module boundary is a miss. The binary side's interprocedural summaries are the deeper tool once the code is compiled.
- No framework emulation. Request objects are recognised by name and idiom, not by executing framework routing.
- No scope typing outside Python, JavaScript and PHP. In the other six taint walkers any sanitiser clears any sink.
- No Rust or Solidity. The Rust walker crate is wired to nothing; Solidity has 35 marker-annotated fixtures and no walker to run against them.
- Nothing over 8 MB. A source file above the cap is skipped whole, not truncated.
- No SQL injection from C, and no new SQL sinks for the binary engine. Both consumers map the catalog's
sql-stringkind to nothing. The binary engine still detects SQL injection from its hardcoded table; the C walker detects none at all. - No remediation field and no generated fix. Project-wide policy: findings carry no patch, no diff and no per-site guidance. Finding text is a different matter — 66 of 636 catalog summaries name a safe replacement inline (
strcpy() is unbounded — use strlcpy / snprintf instead,prefer yaml.safe_load), as do several hardcoded binary detectors (Use mkstemp instead.,Use snprintf().). - No CVE scoring in SCA. The inventory is facts; the join and the severity live in the vulnerability-database domain.
Related briefs
The catalog is the seam that keeps the two engines honest with each other, and it is narrower than the row count suggests: 106 rows name more than one engine, 25 of the 96 cross-surface C rows drive the binary engine, and on the other 1,084 the two surfaces are only agreeing on a schema.
- Findings — the binary detectors and the finding schema.
- Security — the orientation map.
- VulHunt vs openbinary — the cross-surface catalog against its closest public analogue.
- CVEs & SBOM — the firmware-tree side of the SCA join.