Skip to main content

vyre_libs/security/
reporter.rs

1//! Deterministic security finding reporter output contract.
2//!
3//! Scan engines may use literal, regex, vector, or graph planners underneath,
4//! but reporter-facing bytes must stay stable: exact file, line, column, rule,
5//! confidence, ordering, and diagnostics are part of the public detection
6//! contract.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use serde::Serialize;
11
12use super::{FindingProofBundle, FindingProofStep};
13
14/// Stable reporter schema version for JSON/SARIF/CLI byte contracts.
15pub const SECURITY_REPORTER_SCHEMA_VERSION: u32 = 1;
16
17/// Planner path that produced or verified a finding.
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
19pub enum SecurityReporterPlannerPath {
20    /// Literal matcher path.
21    Literal,
22    /// Regex automata path.
23    Regex,
24    /// Vector or ANN-assisted path.
25    Vector,
26    /// Graph/dataflow path.
27    Graph,
28}
29
30impl SecurityReporterPlannerPath {
31    /// Stable lowercase token for this planner path, used in reporter output.
32    #[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/// One finding plus reporter-only metadata that is not owned by the proof bundle.
44#[derive(Clone, Debug)]
45pub struct SecurityReporterFinding {
46    /// Stable user-facing rule id.
47    pub rule_id: String,
48    /// Planner path used for this finding.
49    pub planner_path: SecurityReporterPlannerPath,
50    /// Fact-backed finding proof.
51    pub bundle: FindingProofBundle,
52}
53
54/// Source file id to path mapping used by proof spans.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct SecurityReporterSourceFile {
57    /// Stable file id used by [`super::AnalysisSourceSpan`].
58    pub file_id: u32,
59    /// Display path emitted in JSON, SARIF, and CLI output.
60    pub path: String,
61}
62
63/// Exact reporter bytes for all supported output modes.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct SecurityReporterOutputBytes {
66    /// Deterministic compact JSON bytes, newline terminated.
67    pub json: Vec<u8>,
68    /// Deterministic SARIF 2.1.0 bytes, newline terminated.
69    pub sarif: Vec<u8>,
70    /// Deterministic CLI bytes, newline terminated when findings exist.
71    pub cli: Vec<u8>,
72    /// Process exit code represented by this finding set.
73    pub exit_code: i32,
74}
75
76/// Render fact-backed security findings into stable JSON, SARIF, and CLI bytes.
77///
78/// # Errors
79/// Returns [`SecurityReporterError`] when rule ids, spans, file mappings,
80/// confidence, or JSON serialization are invalid.
81pub 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/// Reporter rendering errors.
277#[derive(Debug, thiserror::Error)]
278pub enum SecurityReporterError {
279    /// Rule id is blank.
280    #[error("finding `{finding_id}` has a blank rule id. Fix: attach stable rule ids before reporter rendering.")]
281    BlankRuleId {
282        /// Finding id.
283        finding_id: String,
284    },
285    /// Finding has no proof path.
286    #[error("finding `{finding_id}` has no proof path. Fix: reporter output needs an exact source span.")]
287    MissingProofPath {
288        /// Finding id.
289        finding_id: String,
290    },
291    /// No file path exists for the proof span file id.
292    #[error("source file id {file_id} has no reporter path mapping. Fix: pass the corpus file table to reporter rendering.")]
293    MissingSourceFile {
294        /// Missing file id.
295        file_id: u32,
296    },
297    /// Primary source span lacks line/column data.
298    #[error("finding `{finding_id}` primary span has no line/column. Fix: populate one-based line and column before reporting.")]
299    MissingLineColumn {
300        /// Finding id.
301        finding_id: String,
302    },
303    /// Finding confidence is outside 0..=10000 basis points.
304    #[error("finding `{finding_id}` confidence {confidence_bps} exceeds 10000. Fix: store confidence in basis points.")]
305    InvalidConfidence {
306        /// Finding id.
307        finding_id: String,
308        /// Invalid confidence.
309        confidence_bps: u16,
310    },
311    /// JSON serialization failed.
312    #[error("security reporter serialization failed: {0}")]
313    Json(#[from] serde_json::Error),
314}
315
316#[cfg(test)]
317mod tests {
318    use crate::dataflow::{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}