1use std::collections::{BTreeMap, BTreeSet};
9
10use serde::Serialize;
11
12use super::{FindingProofBundle, FindingProofStep};
13
14pub const SECURITY_REPORTER_SCHEMA_VERSION: u32 = 1;
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
19pub enum SecurityReporterPlannerPath {
20 Literal,
22 Regex,
24 Vector,
26 Graph,
28}
29
30impl SecurityReporterPlannerPath {
31 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Literal => "literal",
36 Self::Regex => "regex",
37 Self::Vector => "vector",
38 Self::Graph => "graph",
39 }
40 }
41}
42
43#[derive(Clone, Debug)]
45pub struct SecurityReporterFinding {
46 pub rule_id: String,
48 pub planner_path: SecurityReporterPlannerPath,
50 pub bundle: FindingProofBundle,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct SecurityReporterSourceFile {
57 pub file_id: u32,
59 pub path: String,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct SecurityReporterOutputBytes {
66 pub json: Vec<u8>,
68 pub sarif: Vec<u8>,
70 pub cli: Vec<u8>,
72 pub exit_code: i32,
74}
75
76pub fn render_security_reporter_output(
82 findings: &[SecurityReporterFinding],
83 source_files: &[SecurityReporterSourceFile],
84) -> Result<SecurityReporterOutputBytes, SecurityReporterError> {
85 let file_paths = source_files
86 .iter()
87 .map(|file| (file.file_id, file.path.as_str()))
88 .collect::<BTreeMap<_, _>>();
89 let mut records = findings
90 .iter()
91 .map(|finding| reporter_record(finding, &file_paths))
92 .collect::<Result<Vec<_>, _>>()?;
93 records.sort_by(|left, right| {
94 left.path
95 .cmp(&right.path)
96 .then_with(|| left.line.cmp(&right.line))
97 .then_with(|| left.column.cmp(&right.column))
98 .then_with(|| left.rule_id.cmp(&right.rule_id))
99 .then_with(|| left.finding_id.cmp(&right.finding_id))
100 });
101 let exit_code = if records.is_empty() { 0 } else { 1 };
102 Ok(SecurityReporterOutputBytes {
103 json: json_bytes(&records)?,
104 sarif: sarif_bytes(&records)?,
105 cli: cli_bytes(&records),
106 exit_code,
107 })
108}
109
110fn reporter_record(
111 finding: &SecurityReporterFinding,
112 file_paths: &BTreeMap<u32, &str>,
113) -> Result<SecurityReporterRecord, SecurityReporterError> {
114 if finding.rule_id.trim().is_empty() {
115 return Err(SecurityReporterError::BlankRuleId {
116 finding_id: finding.bundle.finding_id.clone(),
117 });
118 }
119 let primary = finding.bundle.proof_path.first().ok_or_else(|| {
120 SecurityReporterError::MissingProofPath {
121 finding_id: finding.bundle.finding_id.clone(),
122 }
123 })?;
124 let path =
125 file_paths
126 .get(&primary.span.file_id)
127 .ok_or(SecurityReporterError::MissingSourceFile {
128 file_id: primary.span.file_id,
129 })?;
130 if primary.span.start_line == 0 || primary.span.start_column == 0 {
131 return Err(SecurityReporterError::MissingLineColumn {
132 finding_id: finding.bundle.finding_id.clone(),
133 });
134 }
135 if finding.bundle.confidence_bps > 10_000 {
136 return Err(SecurityReporterError::InvalidConfidence {
137 finding_id: finding.bundle.finding_id.clone(),
138 confidence_bps: finding.bundle.confidence_bps,
139 });
140 }
141 Ok(SecurityReporterRecord {
142 finding_id: finding.bundle.finding_id.clone(),
143 rule_id: finding.rule_id.trim().to_string(),
144 query_id: finding.bundle.query_id.clone(),
145 backend_id: finding.bundle.backend_id.clone(),
146 planner_path: finding.planner_path.as_str().to_string(),
147 file_id: primary.span.file_id,
148 path: (*path).to_string(),
149 line: primary.span.start_line,
150 column: primary.span.start_column,
151 end_line: primary.span.end_line,
152 end_column: primary.span.end_column,
153 confidence_bps: finding.bundle.confidence_bps,
154 evidence_digest: finding.bundle.evidence_digest.clone(),
155 reason: finding.bundle.reason.clone(),
156 proof_roles: proof_roles(&finding.bundle.proof_path),
157 })
158}
159
160fn proof_roles(proof_path: &[FindingProofStep]) -> Vec<String> {
161 proof_path
162 .iter()
163 .map(|step| step.role.trim().to_string())
164 .collect()
165}
166
167fn json_bytes(records: &[SecurityReporterRecord]) -> Result<Vec<u8>, SecurityReporterError> {
168 let mut bytes = serde_json::to_vec(&SecurityReporterJson {
169 schema_version: SECURITY_REPORTER_SCHEMA_VERSION,
170 finding_count: records.len(),
171 findings: records,
172 })?;
173 bytes.push(b'\n');
174 Ok(bytes)
175}
176
177fn sarif_bytes(records: &[SecurityReporterRecord]) -> Result<Vec<u8>, SecurityReporterError> {
178 let rules = records
179 .iter()
180 .map(|record| record.rule_id.as_str())
181 .collect::<BTreeSet<_>>()
182 .into_iter()
183 .map(|rule_id| serde_json::json!({ "id": rule_id }))
184 .collect::<Vec<_>>();
185 let results = records
186 .iter()
187 .map(|record| {
188 serde_json::json!({
189 "ruleId": &record.rule_id,
190 "level": "warning",
191 "message": { "text": &record.reason },
192 "locations": [{
193 "physicalLocation": {
194 "artifactLocation": { "uri": &record.path },
195 "region": {
196 "startLine": record.line,
197 "startColumn": record.column,
198 "endLine": record.end_line,
199 "endColumn": record.end_column
200 }
201 }
202 }],
203 "properties": {
204 "finding_id": &record.finding_id,
205 "query_id": &record.query_id,
206 "backend_id": &record.backend_id,
207 "planner_path": &record.planner_path,
208 "confidence_bps": record.confidence_bps,
209 "evidence_digest": &record.evidence_digest,
210 "proof_roles": &record.proof_roles
211 }
212 })
213 })
214 .collect::<Vec<_>>();
215 let mut bytes = serde_json::to_vec(&serde_json::json!({
216 "version": "2.1.0",
217 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
218 "runs": [{
219 "tool": {
220 "driver": {
221 "name": "vyre-security",
222 "rules": rules
223 }
224 },
225 "results": results
226 }]
227 }))?;
228 bytes.push(b'\n');
229 Ok(bytes)
230}
231
232fn cli_bytes(records: &[SecurityReporterRecord]) -> Vec<u8> {
233 let mut out = String::new();
234 for record in records {
235 out.push_str(&format!(
236 "{}:{}:{}: {} {}bp {} [{}]: {}\n",
237 record.path,
238 record.line,
239 record.column,
240 record.rule_id,
241 record.confidence_bps,
242 record.finding_id,
243 record.planner_path,
244 record.reason
245 ));
246 }
247 out.into_bytes()
248}
249
250#[derive(Serialize)]
251struct SecurityReporterJson<'a> {
252 schema_version: u32,
253 finding_count: usize,
254 findings: &'a [SecurityReporterRecord],
255}
256
257#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
258struct SecurityReporterRecord {
259 finding_id: String,
260 rule_id: String,
261 query_id: String,
262 backend_id: String,
263 planner_path: String,
264 file_id: u32,
265 path: String,
266 line: u32,
267 column: u32,
268 end_line: u32,
269 end_column: u32,
270 confidence_bps: u16,
271 evidence_digest: String,
272 reason: String,
273 proof_roles: Vec<String>,
274}
275
276#[derive(Debug, thiserror::Error)]
278pub enum SecurityReporterError {
279 #[error("finding `{finding_id}` has a blank rule id. Fix: attach stable rule ids before reporter rendering.")]
281 BlankRuleId {
282 finding_id: String,
284 },
285 #[error("finding `{finding_id}` has no proof path. Fix: reporter output needs an exact source span.")]
287 MissingProofPath {
288 finding_id: String,
290 },
291 #[error("source file id {file_id} has no reporter path mapping. Fix: pass the corpus file table to reporter rendering.")]
293 MissingSourceFile {
294 file_id: u32,
296 },
297 #[error("finding `{finding_id}` primary span has no line/column. Fix: populate one-based line and column before reporting.")]
299 MissingLineColumn {
300 finding_id: String,
302 },
303 #[error("finding `{finding_id}` confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points.")]
305 InvalidConfidence {
306 finding_id: String,
308 confidence_bps: u16,
310 },
311 #[error("security reporter serialization failed: {0}")]
313 Json(#[from] serde_json::Error),
314}
315
316#[cfg(test)]
317mod tests {
318 use vyre_spec::soundness::{DynamicPrimitiveSoundness, PrecisionContract, Soundness};
319
320 use super::*;
321 use crate::security::{AnalysisSourceSpan, FactId};
322
323 #[test]
324 fn reporter_output_bytes_are_stable_and_sorted_across_planner_paths() {
325 let findings = vec![
326 finding(
327 "f.regex",
328 "SEC-REGEX",
329 SecurityReporterPlannerPath::Regex,
330 9,
331 4,
332 ),
333 finding(
334 "f.literal",
335 "SEC-LITERAL",
336 SecurityReporterPlannerPath::Literal,
337 2,
338 7,
339 ),
340 finding(
341 "f.graph",
342 "SEC-GRAPH",
343 SecurityReporterPlannerPath::Graph,
344 9,
345 1,
346 ),
347 finding(
348 "f.vector",
349 "SEC-VECTOR",
350 SecurityReporterPlannerPath::Vector,
351 4,
352 3,
353 ),
354 ];
355 let output = render_security_reporter_output(
356 &findings,
357 &[SecurityReporterSourceFile {
358 file_id: 1,
359 path: "src/app.rs".to_string(),
360 }],
361 )
362 .expect("Fix: reporter rendering should accept valid finding bundles.");
363
364 assert_eq!(output.exit_code, 1);
365 let cli = String::from_utf8(output.cli).expect("Fix: CLI bytes must be UTF-8.");
366 assert!(
367 cli.find("src/app.rs:2:7: SEC-LITERAL") < cli.find("src/app.rs:4:3: SEC-VECTOR"),
368 "Fix: CLI output must be sorted by file, line, column, rule, finding id; cli={cli}"
369 );
370 assert!(
371 cli.contains("src/app.rs:9:1: SEC-GRAPH 9800bp f.graph [graph]: source reaches sink"),
372 "Fix: CLI output must include exact location, rule, confidence, finding id, planner path, and reason; cli={cli}"
373 );
374 let json = String::from_utf8(output.json).expect("Fix: JSON bytes must be UTF-8.");
375 assert!(json.contains(r#""finding_count":4"#));
376 assert!(json.contains(r#""planner_path":"regex""#));
377 assert!(json.ends_with('\n'));
378 let sarif = String::from_utf8(output.sarif).expect("Fix: SARIF bytes must be UTF-8.");
379 assert!(sarif.contains(r#""version":"2.1.0""#));
380 assert!(sarif.contains(r#""ruleId":"SEC-GRAPH""#));
381 assert!(sarif.contains(r#""confidence_bps":9800"#));
382 }
383
384 #[test]
385 fn reporter_rejects_missing_line_column() {
386 let mut missing = finding(
387 "f.missing-location",
388 "SEC-MISSING",
389 SecurityReporterPlannerPath::Literal,
390 0,
391 0,
392 );
393 missing.bundle.proof_path[0].span.start_line = 0;
394
395 let error = render_security_reporter_output(
396 &[missing],
397 &[SecurityReporterSourceFile {
398 file_id: 1,
399 path: "src/app.rs".to_string(),
400 }],
401 )
402 .expect_err("Fix: reporter must reject source spans without one-based line/column.");
403
404 assert!(matches!(
405 error,
406 SecurityReporterError::MissingLineColumn { .. }
407 ));
408 }
409
410 fn finding(
411 finding_id: &str,
412 rule_id: &str,
413 planner_path: SecurityReporterPlannerPath,
414 line: u32,
415 column: u32,
416 ) -> SecurityReporterFinding {
417 SecurityReporterFinding {
418 rule_id: rule_id.to_string(),
419 planner_path,
420 bundle: FindingProofBundle {
421 finding_id: finding_id.to_string(),
422 query_id: "vyre-libs::security::flows_to_with_sanitizer".to_string(),
423 backend_id: "cpu-ref".to_string(),
424 evidence_digest: "evidence:abc123".to_string(),
425 precision_contract: PrecisionContract::ZeroFalsePositive,
426 soundness: Soundness::Exact,
427 primitive_soundness: vec![DynamicPrimitiveSoundness::new(
428 "vyre-libs::security::flows_to",
429 Soundness::Exact,
430 )],
431 fact_ids: vec![FactId(1)],
432 proof_path: vec![FindingProofStep::new(
433 FactId(1),
434 AnalysisSourceSpan {
435 file_id: 1,
436 start_byte: 8,
437 end_byte: 16,
438 start_line: line,
439 start_column: column,
440 end_line: line,
441 end_column: column + 8,
442 },
443 "source",
444 )],
445 confidence_bps: 9800,
446 reason: "source reaches sink".to_string(),
447 },
448 }
449 }
450}