1use 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
37pub const ANALYZER: &str = "semgrep";
39
40pub const RULES_ASSET: &str = "semgrep-rules";
42
43#[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 &[
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 "--metrics=off".to_owned(),
85 "--disable-version-check".to_owned(),
86 "--no-rewrite-rule-ids".to_owned(),
93 "--config".to_owned(),
94 assets.arg(RULES_ASSET),
95 ".".to_owned(),
96 ],
97 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 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 advisory_db: None,
140 source: ctx.source.clone(),
141 findings,
142 })
143 }
144}
145
146fn 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 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 identity: vec![
176 result.check_id.clone(),
177 result.path.clone(),
178 start.to_string(),
179 snippet_hash_at(ctx.snippets, &result.path, start, end),
185 ],
186 rule: result.check_id.clone(),
187 severity: severity(&result.extra.severity),
188 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
206fn 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
218fn 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#[derive(Debug, Deserialize)]
241struct SemgrepOutput {
242 #[serde(default)]
243 version: Option<String>,
244 #[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#[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 worktree: None,
314 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 assert_eq!(report.analyzer_version, "1.96.0");
360 assert_eq!(report.rules_digest.as_deref(), Some("cafe1234"));
361 assert!(report.advisory_db.is_none());
363
364 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 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 #[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 #[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 assert_eq!(a.findings[0].identity[..3], b.findings[0].identity[..3]);
424 }
425
426 #[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 #[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 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 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 #[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 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 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}