1use std::collections::HashSet;
12
13use rto_graph::{
14 AdvisoryDb, AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, Isolation,
15 RunnerKind, Severity, SourceIdentity, Span, is_valid_analyzer_id, layer_key,
16};
17use serde::{Deserialize, Serialize};
18
19use crate::adapter::{NativeContext, adapter_for, known_analyzers};
20use crate::runner::{
21 AnalysisRequest, AnalysisResponse, AnalyzerRunner, ExecError, check_reported_path,
22 check_request,
23};
24use crate::sha256_hex;
25
26pub const REPORT_SCHEMA: &str = "roteiro.findings/v1";
30
31pub const MAX_REPORT_FINDINGS: usize = 100_000;
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ReportFinding {
49 pub identity: Vec<String>,
51 pub rule: String,
53 pub severity: Severity,
55 pub title: String,
57 #[serde(default)]
59 pub message: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub path: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub span: Option<Span>,
66 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
68 pub meta: serde_json::Value,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct NormalizedReport {
79 pub schema: String,
81 pub analyzer: String,
83 pub analyzer_version: String,
85 pub started_at: String,
87 pub ended_at: String,
89 #[serde(default)]
91 pub exit_status: i32,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub rules_digest: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub image_digest: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub advisory_db: Option<AdvisoryDb>,
101 #[serde(default)]
103 pub source: SourceIdentity,
104 #[serde(default)]
106 pub findings: Vec<ReportFinding>,
107}
108
109#[derive(Debug, Clone)]
116pub struct IngestRunner {
117 report: Vec<u8>,
118}
119
120impl IngestRunner {
121 #[must_use]
123 pub fn new(report: impl Into<Vec<u8>>) -> Self {
124 Self {
125 report: report.into(),
126 }
127 }
128}
129
130impl AnalyzerRunner for IngestRunner {
131 fn kind(&self) -> RunnerKind {
132 RunnerKind::Ingested
133 }
134
135 fn isolation(&self) -> Isolation {
136 Isolation::Ingested
140 }
141
142 fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError> {
143 check_request(request)?;
144 let report: NormalizedReport = serde_json::from_slice(&self.report)?;
145 assemble(report, request, self.kind(), self.isolation(), &self.report)
146 }
147}
148
149pub fn normalize_native(
163 analyzer: &str,
164 native: &[u8],
165 ctx: &NativeContext<'_>,
166) -> Result<NormalizedReport, ExecError> {
167 let adapter = adapter_for(analyzer).ok_or_else(|| ExecError::UnknownAnalyzer {
168 requested: analyzer.to_owned(),
169 known: known_analyzers().join(", "),
170 })?;
171 adapter.normalize(native, ctx)
172}
173
174pub(crate) fn assemble(
182 report: NormalizedReport,
183 request: &AnalysisRequest,
184 runner: RunnerKind,
185 isolation: Isolation,
186 raw: &[u8],
187) -> Result<AnalysisResponse, ExecError> {
188 validate_report(&report, &request.analyzer)?;
189 let findings = normalize_findings(&report)?;
190 let layer = layer_key(&request.analyzer, &request.worktree.id)?;
191 let run = AnalysisRun {
192 layer,
193 analyzer: report.analyzer,
194 analyzer_version: report.analyzer_version,
195 runner,
196 isolation,
197 image_digest: report.image_digest,
198 rules_digest: report.rules_digest,
199 advisory_db: report.advisory_db,
200 command_policy: CommandPolicy {
205 network: request.network,
206 worktree: request.worktree.access,
207 environment: EnvironmentPolicy::Scrubbed,
208 },
209 source: merge_source(&request.source, report.source),
212 started_at: report.started_at,
213 ended_at: report.ended_at,
214 exit_status: report.exit_status,
215 report_digest: sha256_hex(raw),
216 };
217 Ok(AnalysisResponse { run, findings })
218}
219
220fn merge_source(requested: &SourceIdentity, reported: SourceIdentity) -> SourceIdentity {
224 SourceIdentity {
225 commit: requested.commit.clone().or(reported.commit),
226 tree: requested.tree.clone().or(reported.tree),
227 lockfile_blob: requested.lockfile_blob.clone().or(reported.lockfile_blob),
228 }
229}
230
231fn validate_report(report: &NormalizedReport, requested: &str) -> Result<(), ExecError> {
233 if report.schema != REPORT_SCHEMA {
234 return Err(ExecError::UnsupportedSchema {
235 found: report.schema.clone(),
236 expected: REPORT_SCHEMA,
237 });
238 }
239 if !is_valid_analyzer_id(&report.analyzer) {
240 return Err(ExecError::InvalidAnalyzerId(report.analyzer.clone()));
241 }
242 if report.analyzer != requested {
243 return Err(ExecError::AnalyzerMismatch {
244 requested: requested.to_owned(),
245 reported: report.analyzer.clone(),
246 });
247 }
248 for (field, value) in [
251 ("analyzer_version", &report.analyzer_version),
252 ("started_at", &report.started_at),
253 ("ended_at", &report.ended_at),
254 ] {
255 if value.trim().is_empty() {
256 return Err(ExecError::MalformedReport(format!("{field} is empty")));
257 }
258 }
259 if report.findings.len() > MAX_REPORT_FINDINGS {
260 return Err(ExecError::TooManyFindings {
261 count: report.findings.len(),
262 max: MAX_REPORT_FINDINGS,
263 });
264 }
265 Ok(())
266}
267
268fn normalize_findings(report: &NormalizedReport) -> Result<Vec<Finding>, ExecError> {
272 let mut seen: HashSet<String> = HashSet::with_capacity(report.findings.len());
273 let mut out = Vec::with_capacity(report.findings.len());
274 for reported in &report.findings {
275 if reported.rule.trim().is_empty() {
276 return Err(ExecError::MalformedReport(
277 "a finding has an empty rule id".to_owned(),
278 ));
279 }
280 if reported.title.trim().is_empty() {
281 return Err(ExecError::MalformedReport(format!(
282 "finding {:?} has an empty title",
283 reported.rule
284 )));
285 }
286 if let Some(path) = &reported.path {
287 check_reported_path(path)?;
288 }
289 if let Some(span) = reported.span
290 && span.end < span.start
291 {
292 return Err(ExecError::MalformedReport(format!(
293 "finding {:?} has a span that runs backwards ({}..{})",
294 reported.rule, span.start, span.end
295 )));
296 }
297 let key = FindingKey::new(&report.analyzer, &reported.identity)?;
298 let rendered = key.render();
299 if !seen.insert(rendered.clone()) {
300 return Err(ExecError::DuplicateFinding(rendered));
301 }
302 out.push(Finding {
303 key,
304 rule: reported.rule.clone(),
305 severity: reported.severity.clone(),
306 title: reported.title.clone(),
307 message: reported.message.clone(),
308 path: reported.path.clone(),
309 span: reported.span,
310 meta: reported.meta.clone(),
311 });
312 }
313 out.sort_by(|a, b| a.key.cmp(&b.key));
314 Ok(out)
315}
316
317#[cfg(test)]
318mod tests {
319 use super::{
320 IngestRunner, MAX_REPORT_FINDINGS, NormalizedReport, REPORT_SCHEMA, ReportFinding,
321 };
322 use crate::runner::{
323 AnalysisRequest, AnalysisResponse, AnalyzerRunner, Consent, ExecError, Worktree,
324 };
325 use rto_graph::{Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, Span};
326
327 fn request() -> AnalysisRequest {
328 AnalysisRequest {
329 analyzer: "cargo-audit".to_owned(),
330 worktree: Worktree::read_only("/repo".as_ref()).expect("worktree"),
331 network: NetworkPolicy::Deny,
332 consent: Consent::Granted,
333 source: SourceIdentity::default(),
334 }
335 }
336
337 fn report() -> NormalizedReport {
338 NormalizedReport {
339 schema: REPORT_SCHEMA.to_owned(),
340 analyzer: "cargo-audit".to_owned(),
341 analyzer_version: "0.21.0".to_owned(),
342 started_at: "2026-08-15T09:00:00Z".to_owned(),
343 ended_at: "2026-08-15T09:00:04Z".to_owned(),
344 exit_status: 1,
345 rules_digest: None,
346 image_digest: None,
347 advisory_db: None,
348 source: SourceIdentity::default(),
349 findings: vec![
350 ReportFinding {
351 identity: vec![
352 "RUSTSEC-2024-0002".to_owned(),
353 "time".to_owned(),
354 "0.1.44".to_owned(),
355 "lock123".to_owned(),
356 ],
357 rule: "RUSTSEC-2024-0002".to_owned(),
358 severity: Severity::Medium,
359 title: "time is vulnerable".to_owned(),
360 message: "segfault".to_owned(),
361 path: Some("Cargo.lock".to_owned()),
362 span: None,
363 meta: serde_json::Value::Null,
364 },
365 ReportFinding {
366 identity: vec![
367 "RUSTSEC-2024-0001".to_owned(),
368 "openssl".to_owned(),
369 "0.10.5".to_owned(),
370 "lock123".to_owned(),
371 ],
372 rule: "RUSTSEC-2024-0001".to_owned(),
373 severity: Severity::High,
374 title: "openssl is vulnerable".to_owned(),
375 message: "upgrade".to_owned(),
376 path: Some("Cargo.lock".to_owned()),
377 span: Some(Span::new(10, 20)),
378 meta: serde_json::json!({"cvss": 9.1}),
379 },
380 ],
381 }
382 }
383
384 fn ingest(report: &NormalizedReport) -> Result<AnalysisResponse, ExecError> {
385 let bytes = serde_json::to_vec(report).expect("serialize");
386 IngestRunner::new(bytes).run(&request())
387 }
388
389 #[test]
390 fn ingests_a_well_formed_report_deterministically() {
391 let response = ingest(&report()).expect("ingest");
392 assert_eq!(response.run.runner, RunnerKind::Ingested);
393 assert_eq!(response.run.isolation, Isolation::Ingested);
394 assert_eq!(response.run.analyzer_version, "0.21.0");
395 assert_eq!(response.run.exit_status, 1);
396 assert_eq!(response.run.command_policy.network, NetworkPolicy::Deny);
397 assert!(
398 response.run.layer.starts_with("security:cargo-audit:"),
399 "layer key was {}",
400 response.run.layer
401 );
402 let keys: Vec<String> = response.findings.iter().map(|f| f.key.render()).collect();
405 assert_eq!(
406 keys,
407 vec![
408 "finding:cargo-audit:RUSTSEC-2024-0001:openssl:0.10.5:lock123",
409 "finding:cargo-audit:RUSTSEC-2024-0002:time:0.1.44:lock123",
410 ]
411 );
412 assert_eq!(response.findings[0].span.map(|s| s.start), Some(10));
413 }
414
415 #[test]
416 fn the_report_digest_is_over_the_exact_bytes_received() {
417 let bytes = serde_json::to_vec(&report()).expect("serialize");
418 let digest = IngestRunner::new(bytes.clone())
419 .run(&request())
420 .expect("ingest")
421 .run
422 .report_digest;
423 assert_eq!(digest, crate::sha256_hex(&bytes));
424
425 let spaced = serde_json::to_vec_pretty(&report()).expect("serialize");
428 let other = IngestRunner::new(spaced)
429 .run(&request())
430 .expect("ingest")
431 .run
432 .report_digest;
433 assert_ne!(digest, other);
434 }
435
436 #[test]
437 fn a_run_carries_the_callers_source_identity_over_the_reports() {
438 let mut req = request();
439 req.source.commit = Some("c0ffee".to_owned());
440 let mut rep = report();
441 rep.source.commit = Some("stale".to_owned());
442 rep.source.lockfile_blob = Some("lock123".to_owned());
443 let bytes = serde_json::to_vec(&rep).expect("serialize");
444 let run = IngestRunner::new(bytes).run(&req).expect("ingest").run;
445 assert_eq!(run.source.commit.as_deref(), Some("c0ffee"));
446 assert_eq!(run.source.lockfile_blob.as_deref(), Some("lock123"));
448 }
449
450 #[test]
451 fn rejects_a_report_with_the_wrong_schema_tag() {
452 let mut rep = report();
453 rep.schema = "roteiro.findings/v999".to_owned();
454 assert!(matches!(
455 ingest(&rep),
456 Err(ExecError::UnsupportedSchema { .. })
457 ));
458 }
459
460 #[test]
461 fn rejects_a_report_from_a_different_analyzer() {
462 let mut rep = report();
463 rep.analyzer = "semgrep".to_owned();
464 assert!(matches!(
465 ingest(&rep),
466 Err(ExecError::AnalyzerMismatch { .. })
467 ));
468 }
469
470 #[test]
471 fn rejects_a_report_missing_its_evidence() {
472 for mutate in [
473 (|r: &mut NormalizedReport| r.analyzer_version = String::new()) as fn(&mut _),
474 |r: &mut NormalizedReport| r.started_at = " ".to_owned(),
475 |r: &mut NormalizedReport| r.ended_at = String::new(),
476 ] {
477 let mut rep = report();
478 mutate(&mut rep);
479 assert!(
480 matches!(ingest(&rep), Err(ExecError::MalformedReport(_))),
481 "a run with no evidence must be refused"
482 );
483 }
484 }
485
486 #[test]
487 fn rejects_a_finding_with_no_stable_identity() {
488 let mut rep = report();
489 rep.findings[0].identity.clear();
490 assert!(matches!(ingest(&rep), Err(ExecError::Identity(_))));
491 }
492
493 #[test]
494 fn rejects_duplicate_identities_within_one_report() {
495 let mut rep = report();
496 rep.findings[1].identity = rep.findings[0].identity.clone();
497 assert!(matches!(ingest(&rep), Err(ExecError::DuplicateFinding(_))));
498 }
499
500 #[test]
501 fn rejects_a_finding_claiming_a_path_outside_the_worktree() {
502 for hostile in ["/etc/shadow", "../../../etc/passwd"] {
503 let mut rep = report();
504 rep.findings[0].path = Some(hostile.to_owned());
505 assert!(
506 matches!(ingest(&rep), Err(ExecError::PathEscapesWorktree(_))),
507 "{hostile:?} should be refused"
508 );
509 }
510 }
511
512 #[test]
513 fn rejects_empty_rules_titles_and_backwards_spans() {
514 let mut rep = report();
515 rep.findings[0].rule = " ".to_owned();
516 assert!(matches!(ingest(&rep), Err(ExecError::MalformedReport(_))));
517
518 let mut rep = report();
519 rep.findings[0].title = String::new();
520 assert!(matches!(ingest(&rep), Err(ExecError::MalformedReport(_))));
521
522 let mut rep = report();
523 rep.findings[0].span = Some(Span::new(90, 10));
524 assert!(matches!(ingest(&rep), Err(ExecError::MalformedReport(_))));
525 }
526
527 #[test]
528 fn rejects_a_runaway_report() {
529 let mut rep = report();
530 let template = rep.findings[0].clone();
531 rep.findings = (0..=MAX_REPORT_FINDINGS)
532 .map(|i| {
533 let mut f = template.clone();
534 f.identity[1] = format!("pkg{i}");
535 f
536 })
537 .collect();
538 assert!(matches!(
539 ingest(&rep),
540 Err(ExecError::TooManyFindings { .. })
541 ));
542 }
543
544 #[test]
545 fn rejects_bytes_that_are_not_a_report_at_all() {
546 for junk in [
547 &b"not json at all"[..],
548 &b"[]"[..],
549 &b"null"[..],
550 &b"{\"schema\":\"roteiro.findings/v1\"}"[..],
551 ] {
552 assert!(
553 matches!(
554 IngestRunner::new(junk.to_vec()).run(&request()),
555 Err(ExecError::Json(_))
556 ),
557 "{:?} should be refused as JSON",
558 String::from_utf8_lossy(junk)
559 );
560 }
561 }
562
563 #[test]
564 fn refuses_to_run_without_consent() {
565 let mut req = request();
566 req.consent = Consent::Withheld;
567 let bytes = serde_json::to_vec(&report()).expect("serialize");
568 assert!(matches!(
569 IngestRunner::new(bytes).run(&req),
570 Err(ExecError::ConsentRequired)
571 ));
572 }
573
574 #[test]
575 fn a_report_with_no_findings_is_a_valid_clean_run() {
576 let mut rep = report();
577 rep.findings.clear();
578 rep.exit_status = 0;
579 let response = ingest(&rep).expect("ingest");
580 assert!(response.findings.is_empty());
581 assert_eq!(response.run.exit_status, 0);
582 }
583
584 #[test]
585 fn the_report_format_round_trips_through_json() {
586 let rep = report();
587 let json = serde_json::to_string(&rep).expect("serialize");
588 assert_eq!(
589 serde_json::from_str::<NormalizedReport>(&json).expect("deserialize"),
590 rep
591 );
592 }
593}