1use 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
40pub const ANALYZER: &str = "semgrep";
42
43pub const RULES_ASSET: &str = "semgrep-rules";
45
46const 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#[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 &[
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 "--metrics=off".to_owned(),
117 "--disable-version-check".to_owned(),
118 "--no-rewrite-rule-ids".to_owned(),
125 "--config".to_owned(),
126 assets.arg(RULES_ASSET),
127 ".".to_owned(),
128 ],
129 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 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 advisory_db: None,
172 source: ctx.source.clone(),
173 findings,
174 })
175 }
176}
177
178fn 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 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 identity: vec![
208 result.check_id.clone(),
209 result.path.clone(),
210 start.to_string(),
211 snippet_hash_at(ctx.snippets, &result.path, start, end),
217 ],
218 rule: result.check_id.clone(),
219 severity: severity(&result.extra.severity),
220 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
238fn 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
250fn 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#[derive(Debug, Deserialize)]
273struct SemgrepOutput {
274 #[serde(default)]
275 version: Option<String>,
276 #[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#[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 worktree: None,
346 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 assert_eq!(report.analyzer_version, "1.96.0");
392 assert_eq!(report.rules_digest.as_deref(), Some("cafe1234"));
393 assert!(report.advisory_db.is_none());
395
396 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 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 #[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 #[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 assert_eq!(a.findings[0].identity[..3], b.findings[0].identity[..3]);
456 }
457
458 #[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 #[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 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 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 #[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 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 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}