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