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 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 "--metrics=off".to_owned(),
89 "--disable-version-check".to_owned(),
90 "--no-rewrite-rule-ids".to_owned(),
97 "--config".to_owned(),
98 assets.arg(RULES_ASSET),
99 ".".to_owned(),
100 ],
101 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 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 advisory_db: None,
144 source: ctx.source.clone(),
145 findings,
146 })
147 }
148}
149
150fn 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 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 identity: vec![
180 result.check_id.clone(),
181 result.path.clone(),
182 start.to_string(),
183 snippet_hash_at(ctx.snippets, &result.path, start, end),
189 ],
190 rule: result.check_id.clone(),
191 severity: severity(&result.extra.severity),
192 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
210fn 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
222fn 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#[derive(Debug, Deserialize)]
245struct SemgrepOutput {
246 #[serde(default)]
247 version: Option<String>,
248 #[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#[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 worktree: None,
318 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 assert_eq!(report.analyzer_version, "1.96.0");
364 assert_eq!(report.rules_digest.as_deref(), Some("cafe1234"));
365 assert!(report.advisory_db.is_none());
367
368 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 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 #[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 #[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 assert_eq!(a.findings[0].identity[..3], b.findings[0].identity[..3]);
428 }
429
430 #[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 #[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 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 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 #[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 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 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}