Files
Marius CiepluchandClaude Fable 5.1 e55e44d981 research: Semgrep CE and Opengrep as the quick tier, measured
Engine LGPL-2.1, shelling out carries no obligation; the registry rules are
under the Semgrep Rules License since 2024-12-13 and cannot ship, only our
own converted rules can. Measured 4.7 s startup and 5 to 10 ms per file;
converted regex and taint rules fire for Python, PHP and JavaScript; C
propagation and Groovy do not; one sanitizer semantic we refuse on purpose
is accepted by Semgrep. Recommendation: a Semgrep arm on the valid pilot
first. Recorded in the hunter-learning-loop research note and breadth.toml.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 12:21:02 +02:00

13 KiB

Could Semgrep CE or Opengrep replace the quick-rule engine and the tree-sitter taint overlay?

Date: 2026-10-13. Read-only research; measurements ran on this host (semgrep 1.179.0, 4 cores). Short answer: as an engine, yes for most quick rules and the intra-function taint overlay; as a shipped rule corpus, no; obligations, sanitizer semantics and measured floors do not carry over. Recommend C first.

1. What we have

Quick rules. src/openultrasast/findings.py:45-75 compiles every enabled TOML rule to a Python regex, filters by rule.languages, skips matches in comments (:197-218), and emits a StaticFinding (:15-36: finding_id, path, title, severity, confidence, evidence_level, rationale, line, function_name, reachability_status/evidence/conditions, tags, ranking_priority, status, rung). Severity comes from policy/CWE_Score.tsv (168 rows: Flaw Category, CWE ID, Name, Flaw Severity 0-5, Static, Dynamic) via resolve_severity (findings.py:121). Floors min_emit_priority / min_emit_precision (:52-60) and the ledger overlay (ruleset/store.py:44-74) keep low-precision rules shadowed. Rule schema, ruleset/store.py:20-42: rule_id, title, languages, cwe, tags, pattern, status (enabled|shadow|disabled), min_evidence_level, precision_estimate, version, framework, library, fallback_for. Counts: C 6, Groovy 1, Java 4, JavaScript 12, PHP 18 (6 shadow), Python 11 = 52 rules.

Example 1 (ruleset/python/rules.toml):

[[rule]]  rule_id = "python-shell-true"   cwe = "CWE-78"   tags = ["syscall_entry"]   languages = ["python"]
status = "enabled"   precision_estimate = 0.0   version = "1"
pattern = '''subprocess\.[a-zA-Z_]+\s*\([^\n]*shell\s*=\s*True'''

Example 2, ruleset/php/rules.toml:48-59: php-sql-unquoted-concat, cwe = "CWE-89", framework = "wordpress", precision_estimate = 0.034, a 300-character lookbehind regex over ORDER BY ... " . $x.

Taint overlay. Facts live in ruleset/semantic/*.toml (101 tables: Python 5 sources / 18 sinks / 4 sanitizers; PHP 3/11/11; JavaScript 2/17/6; Java 2/9/4; C 2/3/1). The dataclasses (semantic/facts.py:27-70) carry semantics Semgrep has no word for: SanitizerFact.quoted_only (an escape that only protects a quoted literal), guard (a check, safe only where it dominates), parameterized, literal_format_arg; SinkFact.prefix_fixes_origin, origin_anchors; plus DispatchFact (string-keyed hook registries) and LayoutFact. semantic/taint.py:1 is "intra-file taint on FileIR": per function, parameters start tainted (:84), sources match by substring (:222-227), and the result is promote / demote / unadjudicated / coverage (semantic/overlay.py:32-50), never a bare match. Obligation checks (semantic/obligations/, 1,635 lines; ruleset/obligations/*.toml, 37 tables) find a missing guard, an absence, not a pattern. The facts tables also feed the Joern model layer (model/candidates.py:216, model/layout.py:14).

Languages, gate, scorers, hook. preprocess.py:11-36 maps 15 languages (c, cpp, python, javascript, typescript, go, rust, java, groovy, kotlin, ruby, php, csharp, swift, solidity); README.md:167-177 names the three scan modes. gate.py:37-38 requires recall >= 0.90 and FP < 0.10 on the fixture manifests (:30-35), scored on quick_scan_findings (:80-84). The pair scorer (pairs.py:1089-1111) matches an overlay record by rule_id in proposal_id, by sink id, or by CWE inside the named function; obligations only by an obligation:<kind> tag (:1114-1126). The push scorer (benchmarks/unseen/score.py:49-91) needs cwe (mapped to a family), path, line, function on each quick finding. push/quick.py:60-119 runs the rules over the head side of changed files and keeps a hit only on a changed line or in a changed function (:122-130); advisory (:35).

Steering. .kiro/steering/safety-net.md:85-88: "Start with the existing detector, earn alert admission per capability, and add scheduling sophistication only when it improves the measured developer outcome. Do not rebuild a custom IR, add a second engine, or assume incremental Joern updates as a shortcut to an unmeasured latency promise."

2. Licensing in 2026

3. Install and runtime

4. Capability

  • CE taint: mode: taint with pattern-sources/sinks/sanitizers, intraprocedural only ("can only analyze interactions within a single function", https://docs.semgrep.dev/semgrep-code/semgrep-pro-engine-intro); pattern-propagators and --pro-intrafile are Pro (https://docs.semgrep.dev/writing-rules/data-flow/taint-mode). Verified locally: PHP, JavaScript and Python taint rules fire with a dataflow_trace; a C rule argv -> system() did not fire through strcpy (needs a propagator). No Groovy parser: generic + regex only.
  • JSON observed on 1.179.0 CE (docs list some as platform-only): check_id, path, start/end.{line,col,offset}, extra.{message, metadata, severity, lines, fingerprint, metavars, dataflow_trace, engine_kind, is_ignored}; SARIF 2.1.0 with fingerprints (https://docs.semgrep.dev/semgrep-appsec-platform/json-and-sarif). metadata is free-form but rejects a float ("expected a list, dict, tree, int or str"): precision_estimate becomes a string.
  • Changed-line scoping: --baseline-commit reports findings absent at a commit and "aborts run if not currently in a git directory, there are unstaged changes"; per file, so push/quick.py:122-130 stays on top.

5. Rule conversion

Regex rule python-shell-true as a syntax pattern or a verbatim PCRE2 regex:

- id: python-shell-true
  languages: [python]   severity: WARNING   message: Subprocess shell execution needs review
  metadata: {cwe: CWE-78, family: injection, tags: [syscall_entry], precision_estimate: "0.0", status: enabled}
  pattern: subprocess.$F(..., shell=True, ...)   # or pattern-regex: 'subprocess\.[a-zA-Z_]+\s*\([^\n]*shell\s*=\s*True'

Fact triple (semantic/python.toml: source request, sink os.system, a sanitizer) becomes:

- id: python-os-command-taint
  mode: taint   languages: [python]   severity: ERROR   message: request data reaches an OS command sink
  metadata: {cwe: CWE-78, family: injection, sink: os.system, source: request}
  pattern-sources: [{pattern-either: [{pattern: request.args}, {pattern: request.form}, {pattern: request.get_json(...)}, {pattern: sys.argv}]}]
  pattern-sanitizers: [{pattern: shlex.quote(...)}]
  pattern-sinks: [{pattern-either: [{pattern: os.system(...)}, {pattern: subprocess.run(...)}, {pattern: subprocess.Popen(...)}]}]

Both validated and fired on a test file (two findings, with traces). Not expressible: quoted_only (Semgrep's PHP rule silenced ORDER BY " . mysqli_real_escape_string(...), the CVE-2024-1071 shape the project keeps), guard dominance, prefix_fixes_origin, DispatchFact routing, every obligation, shadow status and emit floors (a post-filter on our side), framework priors and fallback_for (two rule files plus a selector), uncovered_languages (findings.py:78-99). Regex rules on non-code files map to languages: [generic] + pattern-regex.

6. Risks

  • Steering forbids "a second engine". Semgrep beside Joern is one; adopting it means amending safety-net.md with a measured reason, the rule's own test.
  • LGPL: running semgrep-core as a subprocess and reading JSON is "communicate at arm's length"; the GNU FAQ says fork-and-exec with pipes or command-line arguments keeps two programs separate (https://www.gnu.org/licenses/gpl-faq.html#GPLPlugins, #MereAggregation; gnu.org rate-limited today, quoted via mirrors). No obligation on our Apache-2.0 code; shipping the binary in the Docker image obliges only passing on the LGPL text and a source offer.
  • Supply chain: weekly releases, pin version and hash. Opengrep: signed binaries, 2.0 in alpha.
  • Detection quality is unknown: the 52 rules carry measured floors and six shadow rules learned on PHP; gate and pair numbers restart from zero, and Semgrep's rules cannot be shipped to close the gap.

7. Comparison, options, recommendation

Ours Semgrep CE Opengrep
Licence Apache-2.0 engine LGPL-2.1; rules not open engine LGPL-2.1; no own rules
Languages with taint C, Java, JS, PHP, Python (facts) all parsed languages, no Groovy same, plus Apex/VB/Crystal
Changed-line scoping spans + changed function --baseline-commit, per file same
Offline yes yes, three flags yes
Install size 0 extra (tree-sitter extra) 70 MB wheel / 256 MB binary one binary, similar
JSON / SARIF own JSON + SARIF both, with traces both, with fingerprints
Interprocedural no (Joern does that) no (Pro) within one file

Lines a swap retires: findings.py 223 + ruleset/*.py 328 + semantic/{taint,overlay,engines, prove_filter}.py 695 = about 1,250. Kept: semantic/ir.py, cst.py, facts.py (used by source.py, model/candidates.py, learn/slice.py), obligations (1,635), policy, scorers.

  • A. Semgrep CE, our rules converted, no registry. Effort L (TOML-to-YAML emitter, adapter to StaticFinding, re-measure gate and pairs, hook latency). Risk high: +4.7 s per hook run, sanitizer semantics lost, steering amended.
  • B. Opengrep with our rules. Effort L plus binary packaging. Risk high: smaller project, 2.0 in alpha; gains in-file cross-function taint.
  • C. Keep ours; Semgrep as a benchmark arm. Effort S. Risk low: nothing ships; it answers the one open question (does the engine catch more on the valid pilot?).

Recommendation. Do C. The engine is cheap and licence-clean, but this project's value is in the facts and the policy around a match (quoted-only escapes, guards, shadow rules, obligations, the rung), and none of that moves with the engine. A swap buys a slower hook and an unmeasured gate for about 1,250 lines removed. Only a measured catch gap on unseen application repos justifies amending the steering rule; C produces that number.

First increment. benchmarks/valid/semgrep_arm.py: emit ours.yaml from the 52 TOML rules and five facts files (regex -> pattern-regex, facts -> mode: taint); run semgrep scan --config ours.yaml --metrics=off --disable-version-check --json on each head checkout under ~/ousast-results/valid/pilot/hist/; map results to the quick_tier shape (rule, cwe, path, line, function, scope via head_spans); write a second replay record; score both with benchmarks/unseen/score.py (catches per family, false alarms, McNemar). Instrument rule: print paths.scanned and bytes read per repo before believing a zero.