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