Skip to main content

rto_exec/adapter/
semgrep.rs

1//! `semgrep` — static analysis across the project's languages.
2//!
3//! Semgrep is the SAST half of the coverage matrix (ADR-0018): it parses source
4//! and matches patterns against the AST, and it is the one tool that covers
5//! Rust, Python, Java, JavaScript and TypeScript with a single output format.
6//!
7//! # SQL is covered by the generic engine, and that is a real limitation
8//!
9//! Semgrep's published language list has **no SQL entry at any maturity level** —
10//! not GA, not beta, not experimental. SQL findings therefore come from
11//! semgrep's `generic` mode, which is Generally available but is a *token*
12//! matcher, not a parser: no AST, no dataflow, no type information. A SQL rule
13//! can say "this statement grants ALL PRIVILEGES"; it cannot say "this value
14//! reaches a query unsanitised". That is stated here, in ADR-0018, and in the
15//! rule file itself, so nobody reads a clean SQL scan as an AST-backed one.
16//!
17//! # Rules are ours, and pinned
18//!
19//! The `--config` this adapter passes is a local file provisioned by
20//! [`crate::assets`], never a registry entry: `semgrep --config p/default` is a
21//! network call to a service, which would make an "offline" analyzer quietly
22//! network-dependent. The shipped rule set is written for this project and
23//! carries the repository's own licence; no rule from the Semgrep Registry is
24//! vendored, because Registry rules are under the Semgrep Rules License v1.0
25//! rather than an SPDX-allowlisted licence.
26//!
27//! @rto:0012
28//! @rto:0018
29
30use serde::Deserialize;
31
32use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext, snippet_hash_at};
33use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
34use crate::runner::ExecError;
35use rto_graph::{Severity, Span};
36
37/// The analyzer id, and the first component of every finding key it produces.
38pub const ANALYZER: &str = "semgrep";
39
40/// Asset id of the pinned rule set this adapter runs with.
41pub const RULES_ASSET: &str = "semgrep-rules";
42
43/// The adapter.
44#[derive(Debug, Clone, Copy)]
45pub struct Semgrep;
46
47impl Adapter for Semgrep {
48    fn analyzer(&self) -> &'static str {
49        ANALYZER
50    }
51
52    fn summary(&self) -> &'static str {
53        "static analysis (SAST) against a pinned local rule set"
54    }
55
56    fn languages(&self) -> &'static [&'static str] {
57        // SQL is listed because findings are produced for it, and qualified
58        // everywhere it matters: the engine behind it is `generic`, not a SQL
59        // parser. See the module docs and ADR-0018.
60        &[
61            "rust",
62            "python",
63            "java",
64            "javascript",
65            "typescript",
66            "sql (generic mode)",
67        ]
68    }
69
70    fn asset_ids(&self) -> &'static [&'static str] {
71        &[RULES_ASSET]
72    }
73
74    fn host_programs(&self) -> &'static [&'static str] {
75        &["semgrep"]
76    }
77
78    fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
79        Invocation {
80            program: "semgrep".to_owned(),
81            args: vec![
82                "scan".to_owned(),
83                "--json".to_owned(),
84                "--quiet".to_owned(),
85                // Egress configured off: no telemetry, no update ping, and a
86                // `--config` that is a local file rather than a registry id.
87                // Configured, not enforced — see `SubprocessRunner`.
88                "--metrics=off".to_owned(),
89                "--disable-version-check".to_owned(),
90                // Without this, semgrep prefixes every rule id with the
91                // *filesystem path* of the config it was loaded from, so a
92                // finding key would embed the local asset-cache directory —
93                // user-identifying data in a stored record, and a key that
94                // differs between two machines running the same scan. Verified
95                // against semgrep 1.136.0.
96                "--no-rewrite-rule-ids".to_owned(),
97                "--config".to_owned(),
98                assets.arg(RULES_ASSET),
99                ".".to_owned(),
100            ],
101            // 0 = clean, 1 = findings. Semgrep uses 2 and above for a scan that
102            // actually failed, and those must not be normalised into "no
103            // findings" — a failed scan is not a clean bill of health.
104            success_statuses: vec![0, 1],
105        }
106    }
107
108    fn normalize(
109        &self,
110        native: &[u8],
111        ctx: &NativeContext<'_>,
112    ) -> Result<NormalizedReport, ExecError> {
113        let output: SemgrepOutput = serde_json::from_slice(native)?;
114        let Some(results) = output.results else {
115            return Err(ExecError::MalformedReport(
116                "not a semgrep report: no `results` array".to_owned(),
117            ));
118        };
119
120        let mut findings = Vec::with_capacity(results.len());
121        for result in results {
122            // `nosemgrep` suppressions arrive as ignored results rather than
123            // being omitted. Honouring them here means an in-source suppression
124            // behaves the same whether the scan ran locally or in CI.
125            if result.extra.is_ignored {
126                continue;
127            }
128            findings.push(convert(&result, ctx)?);
129        }
130
131        Ok(NormalizedReport {
132            schema: REPORT_SCHEMA.to_owned(),
133            analyzer: ANALYZER.to_owned(),
134            analyzer_version: ctx.version_or(output.version.as_deref()),
135            started_at: ctx.started_at.clone(),
136            ended_at: ctx.ended_at.clone(),
137            exit_status: ctx.exit_status,
138            rules_digest: ctx.rules_digest.clone(),
139            image_digest: None,
140            // Semgrep consults no advisory database: it matches patterns against
141            // source. Claiming one would put a staleness label on a result that
142            // has no such axis.
143            advisory_db: None,
144            source: ctx.source.clone(),
145            findings,
146        })
147    }
148}
149
150/// One semgrep result → one normalized finding.
151fn convert(result: &SemgrepResult, ctx: &NativeContext<'_>) -> Result<ReportFinding, ExecError> {
152    if result.check_id.trim().is_empty() {
153        return Err(ExecError::MalformedReport(
154            "a semgrep result has no `check_id`".to_owned(),
155        ));
156    }
157    if result.path.trim().is_empty() {
158        return Err(ExecError::MalformedReport(format!(
159            "semgrep result {:?} has no `path`",
160            result.check_id
161        )));
162    }
163    // `Span` is 32-bit, which is a 4 GiB ceiling on a single source file. A
164    // larger offset is saturated rather than rejected: the finding is still
165    // true, and losing the exact byte on a file that size is not what makes it
166    // wrong. Clamping before the identity is built keeps the key and the span
167    // agreeing on one number.
168    let start = u32::try_from(result.start.offset).unwrap_or(u32::MAX);
169    let end = u32::try_from(result.end.offset)
170        .unwrap_or(u32::MAX)
171        .max(start);
172    let message = result.extra.message.trim();
173
174    Ok(ReportFinding {
175        // ADR-0012's recipe: rule, path, start byte, snippet hash. The snippet
176        // is what makes the key notice that the *code* changed while the rule
177        // and offset stayed put, so a re-run does not silently carry an old
178        // finding onto new source.
179        identity: vec![
180            result.check_id.clone(),
181            result.path.clone(),
182            start.to_string(),
183            // Read from the tree, not from `extra.lines`: the open-source
184            // semgrep CLI redacts that field to the literal "requires login"
185            // unless the caller is authenticated to Semgrep's hosted platform,
186            // which would make this component a constant today and change every
187            // stored key the day someone logs in. See `crate::snippet`.
188            snippet_hash_at(ctx.snippets, &result.path, start, end),
189        ],
190        rule: result.check_id.clone(),
191        severity: severity(&result.extra.severity),
192        // Semgrep has no title field; its message is a sentence or a paragraph.
193        // The first line is the title, the whole thing is the message, so a
194        // listing stays one line per finding without losing detail.
195        title: title_from(message, &result.check_id),
196        message: message.to_owned(),
197        path: Some(result.path.clone()),
198        span: Some(Span::new(start, end)),
199        meta: serde_json::json!({
200            "line": result.start.line,
201            "column": result.start.col,
202            "end_line": result.end.line,
203            "semgrep_severity": result.extra.severity,
204            "metadata": result.extra.metadata,
205            "engine": result.extra.engine_kind,
206        }),
207    })
208}
209
210/// The first line of `message`, falling back to the rule id when the message is
211/// empty — a finding with no title is refused downstream, and the rule id is
212/// always more use than a blank.
213fn title_from(message: &str, check_id: &str) -> String {
214    let first = message.lines().next().unwrap_or("").trim();
215    if first.is_empty() {
216        check_id.to_owned()
217    } else {
218        first.to_owned()
219    }
220}
221
222/// Map semgrep's severity vocabulary onto [`Severity`].
223///
224/// Semgrep emits the three-level `ERROR`/`WARNING`/`INFO` set, and newer rule
225/// metadata also uses `CRITICAL`/`HIGH`/`MEDIUM`/`LOW`. Both are accepted;
226/// anything else round-trips verbatim through [`Severity::Other`] rather than
227/// being flattened into a level the analyzer did not assign.
228fn severity(raw: &str) -> Severity {
229    match raw.to_ascii_uppercase().as_str() {
230        "CRITICAL" => Severity::Critical,
231        "ERROR" | "HIGH" => Severity::High,
232        "WARNING" | "MEDIUM" => Severity::Medium,
233        "LOW" => Severity::Low,
234        "INFO" | "INFORMATION" => Severity::Info,
235        _ => Severity::from_token(&raw.to_ascii_lowercase()),
236    }
237}
238
239/// The shape of `semgrep --json`, narrowed to what is needed.
240///
241/// Unknown fields are ignored on purpose: semgrep adds keys between minor
242/// versions, and a parser that refused them would break on an upgrade that
243/// changed nothing this adapter reads.
244#[derive(Debug, Deserialize)]
245struct SemgrepOutput {
246    #[serde(default)]
247    version: Option<String>,
248    /// Absent rather than empty distinguishes "not a semgrep report" from "a
249    /// clean scan", and only the latter is a valid result.
250    #[serde(default)]
251    results: Option<Vec<SemgrepResult>>,
252}
253
254#[derive(Debug, Deserialize)]
255struct SemgrepResult {
256    check_id: String,
257    path: String,
258    #[serde(default)]
259    start: Position,
260    #[serde(default)]
261    end: Position,
262    #[serde(default)]
263    extra: Extra,
264}
265
266#[derive(Debug, Default, Deserialize)]
267struct Position {
268    #[serde(default)]
269    line: u64,
270    #[serde(default)]
271    col: u64,
272    #[serde(default)]
273    offset: u64,
274}
275
276/// Semgrep's `extra` block.
277///
278/// `lines` and `fingerprint` are deliberately **not** deserialized. In the
279/// open-source CLI both are the literal string `"requires login"` unless the
280/// caller is authenticated to Semgrep's hosted platform, so parsing them would
281/// only offer a field that looks like the matched code and is not. The snippet
282/// comes from the worktree ([`crate::snippet`]) instead.
283#[derive(Debug, Default, Deserialize)]
284struct Extra {
285    #[serde(default)]
286    message: String,
287    #[serde(default)]
288    severity: String,
289    #[serde(default)]
290    is_ignored: bool,
291    #[serde(default)]
292    metadata: serde_json::Value,
293    #[serde(default)]
294    engine_kind: Option<String>,
295}
296
297#[cfg(test)]
298mod tests {
299    use super::{ANALYZER, RULES_ASSET, Semgrep, severity, title_from};
300    use crate::adapter::{Adapter, AssetPaths, NativeContext};
301    use crate::runner::ExecError;
302    use rto_graph::{Severity, SourceIdentity};
303
304    fn ctx() -> NativeContext<'static> {
305        static SOURCE: std::sync::LazyLock<SourceIdentity> =
306            std::sync::LazyLock::new(SourceIdentity::default);
307        NativeContext {
308            started_at: "2026-08-15T09:00:00Z".to_owned(),
309            ended_at: "2026-08-15T09:00:09Z".to_owned(),
310            analyzer_version: None,
311            exit_status: 1,
312            source: &SOURCE,
313            rules_digest: Some("cafe1234".to_owned()),
314            advisory_db: None,
315            // Semgrep reports worktree-relative paths, so this adapter never
316            // consults the worktree.
317            worktree: None,
318            // The unit tests here exercise the *parsing*; the snippet component
319            // is covered by `tests/equivalence.rs`, which reads a real tree.
320            snippets: &crate::snippet::NoSnippets,
321        }
322    }
323
324    const NATIVE: &str = r#"{
325      "version": "1.96.0",
326      "results": [
327        {
328          "check_id": "roteiro.python.subprocess-shell-true",
329          "path": "svc/app.py",
330          "start": {"line": 12, "col": 5, "offset": 240},
331          "end": {"line": 12, "col": 45, "offset": 280},
332          "extra": {
333            "message": "Shell injection risk.\nPass a list of arguments instead.",
334            "severity": "ERROR",
335            "lines": "    subprocess.run(cmd, shell=True)",
336            "is_ignored": false,
337            "metadata": {"category": "security"},
338            "engine_kind": "OSS"
339          }
340        },
341        {
342          "check_id": "roteiro.python.assert-used",
343          "path": "svc/app.py",
344          "start": {"line": 3, "col": 1, "offset": 40},
345          "end": {"line": 3, "col": 20, "offset": 60},
346          "extra": {
347            "message": "assert is stripped under -O",
348            "severity": "WARNING",
349            "lines": "assert user.is_admin",
350            "is_ignored": true
351          }
352        }
353      ],
354      "errors": [],
355      "paths": {"scanned": ["svc/app.py"]}
356    }"#;
357
358    #[test]
359    fn normalizes_a_native_report() {
360        let report = Semgrep.normalize(NATIVE.as_bytes(), &ctx()).expect("parse");
361        assert_eq!(report.analyzer, ANALYZER);
362        // No version was supplied out of band, so the report's own wins.
363        assert_eq!(report.analyzer_version, "1.96.0");
364        assert_eq!(report.rules_digest.as_deref(), Some("cafe1234"));
365        // Semgrep consults no advisory database, so it must not claim one.
366        assert!(report.advisory_db.is_none());
367
368        // The suppressed (`nosemgrep`) result is gone; the live one converted.
369        assert_eq!(report.findings.len(), 1);
370        let finding = &report.findings[0];
371        assert_eq!(finding.rule, "roteiro.python.subprocess-shell-true");
372        assert_eq!(finding.severity, Severity::High);
373        assert_eq!(finding.title, "Shell injection risk.");
374        assert!(finding.message.contains("Pass a list of arguments"));
375        assert_eq!(finding.path.as_deref(), Some("svc/app.py"));
376        assert_eq!(finding.span.map(|s| (s.start, s.end)), Some((240, 280)));
377    }
378
379    /// A stand-in worktree: whatever text was put in it, for any span.
380    struct FakeTree(&'static str);
381
382    impl crate::snippet::SnippetSource for FakeTree {
383        fn snippet(&self, _path: &str, _start: u32, _end: u32) -> Option<String> {
384            Some(self.0.to_owned())
385        }
386    }
387
388    fn ctx_with_tree(tree: &'static FakeTree) -> NativeContext<'static> {
389        let mut ctx = ctx();
390        ctx.snippets = tree;
391        ctx
392    }
393
394    /// The identity recipe ADR-0012 specifies, component by component. It is
395    /// asserted positionally because the *order* is the contract: a reordering
396    /// would silently re-key every stored finding.
397    #[test]
398    fn uses_the_rule_path_offset_snippet_identity() {
399        static TREE: FakeTree = FakeTree("    subprocess.run(cmd, shell=True)");
400        let report = Semgrep
401            .normalize(NATIVE.as_bytes(), &ctx_with_tree(&TREE))
402            .expect("parse");
403        let identity = &report.findings[0].identity;
404        assert_eq!(identity[0], "roteiro.python.subprocess-shell-true");
405        assert_eq!(identity[1], "svc/app.py");
406        assert_eq!(identity[2], "240");
407        assert_eq!(
408            identity[3],
409            crate::adapter::snippet_hash("    subprocess.run(cmd, shell=True)")
410        );
411    }
412
413    /// The snippet component exists so that new code at an unchanged offset is a
414    /// new finding rather than the old one silently carried forward.
415    #[test]
416    fn changed_code_at_the_same_offset_is_a_different_finding() {
417        static BEFORE: FakeTree = FakeTree("subprocess.run(cmd, shell=True)");
418        static AFTER: FakeTree = FakeTree("os.system(cmd)");
419        let a = Semgrep
420            .normalize(NATIVE.as_bytes(), &ctx_with_tree(&BEFORE))
421            .expect("a");
422        let b = Semgrep
423            .normalize(NATIVE.as_bytes(), &ctx_with_tree(&AFTER))
424            .expect("b");
425        assert_ne!(a.findings[0].identity, b.findings[0].identity);
426        // …and only the snippet component moved.
427        assert_eq!(a.findings[0].identity[..3], b.findings[0].identity[..3]);
428    }
429
430    /// Semgrep's own `extra.lines` is the literal "requires login" in the
431    /// open-source CLI, so it must never reach an identity: a finding key that
432    /// depended on it would be a constant today and would change the day a user
433    /// authenticated. The tree is the source of truth instead.
434    #[test]
435    fn the_identity_ignores_semgreps_redacted_snippet_field() {
436        static TREE: FakeTree = FakeTree("subprocess.run(cmd, shell=True)");
437        let redacted = NATIVE.replace(
438            r#""lines": "    subprocess.run(cmd, shell=True)","#,
439            r#""lines": "requires login","#,
440        );
441        assert!(
442            redacted.contains("requires login"),
443            "the fixture was rewritten"
444        );
445        let from_real = Semgrep
446            .normalize(NATIVE.as_bytes(), &ctx_with_tree(&TREE))
447            .expect("a");
448        let from_redacted = Semgrep
449            .normalize(redacted.as_bytes(), &ctx_with_tree(&TREE))
450            .expect("b");
451        assert_eq!(
452            from_real.findings[0].identity,
453            from_redacted.findings[0].identity
454        );
455    }
456
457    /// A report about a tree this checkout does not have still normalises; the
458    /// identity says the snippet was unavailable instead of inventing one.
459    #[test]
460    fn a_missing_tree_yields_a_named_snippet_component() {
461        let report = Semgrep.normalize(NATIVE.as_bytes(), &ctx()).expect("parse");
462        assert_eq!(report.findings[0].identity[3], crate::adapter::NO_SNIPPET);
463    }
464
465    #[test]
466    fn maps_both_severity_vocabularies() {
467        for (raw, want) in [
468            ("ERROR", Severity::High),
469            ("WARNING", Severity::Medium),
470            ("INFO", Severity::Info),
471            ("CRITICAL", Severity::Critical),
472            ("HIGH", Severity::High),
473            ("MEDIUM", Severity::Medium),
474            ("LOW", Severity::Low),
475        ] {
476            assert_eq!(severity(raw), want, "{raw}");
477        }
478        // An unknown level is kept verbatim rather than flattened into one the
479        // analyzer never assigned.
480        assert_eq!(
481            severity("EXPERIMENTAL"),
482            Severity::Other("experimental".to_owned())
483        );
484    }
485
486    #[test]
487    fn a_clean_scan_is_a_valid_empty_report() {
488        let clean = br#"{"version":"1.96.0","results":[],"errors":[]}"#;
489        let report = Semgrep.normalize(clean, &ctx()).expect("parse");
490        assert!(report.findings.is_empty());
491    }
492
493    #[test]
494    fn refuses_output_that_is_not_a_semgrep_report() {
495        // No `results` key at all: an empty scan and "the wrong file" must not
496        // look the same.
497        let err = Semgrep
498            .normalize(br#"{"version":"1.96.0"}"#, &ctx())
499            .expect_err("must be refused");
500        assert!(matches!(err, ExecError::MalformedReport(_)));
501        assert!(err.to_string().contains("no `results` array"), "{err}");
502
503        assert!(matches!(
504            Semgrep.normalize(b"not json", &ctx()),
505            Err(ExecError::Json(_))
506        ));
507    }
508
509    #[test]
510    fn refuses_a_result_with_no_rule_or_no_path() {
511        for native in [
512            r#"{"results":[{"check_id":"  ","path":"a.py","start":{},"end":{},"extra":{}}]}"#,
513            r#"{"results":[{"check_id":"r","path":"","start":{},"end":{},"extra":{}}]}"#,
514        ] {
515            assert!(
516                matches!(
517                    Semgrep.normalize(native.as_bytes(), &ctx()),
518                    Err(ExecError::MalformedReport(_))
519                ),
520                "{native}"
521            );
522        }
523    }
524
525    /// A result whose `end` offset precedes its `start` would be refused by the
526    /// shared validation as a backwards span. Clamping here keeps a merely odd
527    /// report usable while still never producing a span that runs backwards.
528    #[test]
529    fn clamps_a_backwards_span_rather_than_emitting_one() {
530        let native = r#"{"results":[{"check_id":"r","path":"a.py",
531            "start":{"offset":90},"end":{"offset":10},"extra":{"message":"m","lines":"x"}}]}"#;
532        let report = Semgrep.normalize(native.as_bytes(), &ctx()).expect("parse");
533        assert_eq!(
534            report.findings[0].span.map(|s| (s.start, s.end)),
535            Some((90, 90))
536        );
537    }
538
539    #[test]
540    fn a_message_less_finding_is_titled_by_its_rule() {
541        assert_eq!(title_from("", "rules.x"), "rules.x");
542        assert_eq!(title_from("  first\nsecond", "rules.x"), "first");
543    }
544
545    #[test]
546    fn the_invocation_configures_egress_off_and_points_at_the_pinned_rules() {
547        let entries = [(RULES_ASSET, std::path::PathBuf::from("/cache/rules.yaml"))];
548        let invocation = Semgrep.command(&AssetPaths::new(&entries));
549        assert_eq!(invocation.program, "semgrep");
550        assert!(invocation.args.contains(&"--metrics=off".to_owned()));
551        assert!(
552            invocation
553                .args
554                .contains(&"--disable-version-check".to_owned())
555        );
556        // The `--config` must be the provisioned local file: a registry id here
557        // would be a network call, which is exactly what pinning prevents.
558        let config = invocation
559            .args
560            .iter()
561            .position(|a| a == "--config")
562            .map(|i| invocation.args[i + 1].clone())
563            .expect("a --config argument");
564        assert_eq!(config, "/cache/rules.yaml");
565        // Semgrep exits 1 when it *found* something; treating that as failure
566        // would discard every run that mattered.
567        assert_eq!(invocation.success_statuses, vec![0, 1]);
568    }
569
570    #[test]
571    fn declares_the_rule_set_as_the_asset_it_needs() {
572        assert_eq!(Semgrep.asset_ids(), &[RULES_ASSET]);
573        assert!(Semgrep.languages().contains(&"rust"));
574        assert!(
575            Semgrep.languages().iter().any(|l| l.starts_with("sql")),
576            "SQL coverage must be claimed, and qualified"
577        );
578    }
579}