cargo_audit/
sarif.rs

1//! SARIF (Static Analysis Results Interchange Format) output support
2//!
3//! This module provides functionality to convert cargo-audit reports to SARIF format,
4//! which can be uploaded to GitHub Security tab and other security analysis platforms.
5//!
6//! SARIF is an OASIS Standard that defines a common format for static analysis tools
7//! to report their findings. This implementation follows SARIF 2.1.0 specification
8//! and is compatible with GitHub's code scanning requirements.
9
10use std::collections::{HashMap, HashSet};
11
12use rustsec::{Report, Vulnerability, Warning, WarningKind, advisory};
13use serde::{Serialize, Serializer, ser::SerializeStruct};
14
15/// SARIF log root object
16#[derive(Debug)]
17pub struct SarifLog {
18    /// Array of analysis runs
19    runs: Vec<Run>,
20}
21
22impl SarifLog {
23    /// Convert a cargo-audit report to SARIF format
24    pub fn from_report(report: &Report, cargo_lock_path: &str) -> Self {
25        Self {
26            runs: vec![Run::from_report(report, cargo_lock_path)],
27        }
28    }
29}
30
31impl Serialize for SarifLog {
32    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
33        let mut state = Serializer::serialize_struct(serializer, "SarifLog", 3)?;
34        state.serialize_field("$schema", "https://json.schemastore.org/sarif-2.1.0.json")?;
35        state.serialize_field("version", "2.1.0")?;
36        state.serialize_field("runs", &self.runs)?;
37        state.end()
38    }
39}
40
41/// A run represents a single invocation of an analysis tool
42#[derive(Debug, Serialize)]
43#[serde(rename_all = "camelCase")]
44pub struct Run {
45    /// Tool information for this run
46    tool: Tool,
47    /// Array of results (findings) from the analysis
48    results: Vec<SarifResult>,
49}
50
51impl Run {
52    fn from_report(report: &Report, cargo_lock_path: &str) -> Self {
53        let mut rules = Vec::new();
54        let mut seen_rules = HashSet::new();
55        let mut results = Vec::new();
56
57        for vuln in &report.vulnerabilities.list {
58            let rule_id = vuln.advisory.id.to_string();
59
60            if seen_rules.insert(rule_id.clone()) {
61                rules.push(ReportingDescriptor::from_advisory(&vuln.advisory, true));
62            }
63
64            results.push(SarifResult::from_vulnerability(vuln, cargo_lock_path));
65        }
66
67        for (warning_kind, warnings) in &report.warnings {
68            for warning in warnings {
69                let rule_id = if let Some(advisory) = &warning.advisory {
70                    advisory.id.to_string()
71                } else {
72                    format!("{warning_kind:?}").to_lowercase()
73                };
74
75                if seen_rules.insert(rule_id) {
76                    rules.push(match &warning.advisory {
77                        Some(advisory) => ReportingDescriptor::from_advisory(advisory, false),
78                        None => ReportingDescriptor::from_warning_kind(*warning_kind),
79                    });
80                }
81
82                results.push(SarifResult::from_warning(warning, cargo_lock_path));
83            }
84        }
85
86        Self {
87            tool: Tool {
88                driver: ToolComponent { rules },
89            },
90            results,
91        }
92    }
93}
94
95/// Tool information
96#[derive(Debug, Serialize)]
97#[serde(rename_all = "camelCase")]
98struct Tool {
99    /// The analysis tool that was run
100    driver: ToolComponent,
101}
102
103/// Tool component (driver) information
104#[derive(Debug)]
105struct ToolComponent {
106    /// Rules defined by this tool
107    rules: Vec<ReportingDescriptor>,
108}
109
110impl Serialize for ToolComponent {
111    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
112        let mut state = serializer.serialize_struct("ToolComponent", 4)?;
113        state.serialize_field("name", "cargo-audit")?;
114        state.serialize_field("version", env!("CARGO_PKG_VERSION"))?;
115        state.serialize_field("semanticVersion", env!("CARGO_PKG_VERSION"))?;
116        state.serialize_field("rules", &self.rules)?;
117        state.end()
118    }
119}
120
121/// Rule/reporting descriptor
122#[derive(Debug, Serialize)]
123#[serde(rename_all = "camelCase")]
124struct ReportingDescriptor {
125    /// Unique identifier for the rule
126    id: String,
127    /// Human-readable name of the rule
128    name: String,
129    /// Brief description of the rule
130    short_description: MultiformatMessageString,
131    /// Detailed description of the rule
132    #[serde(skip_serializing_if = "Option::is_none")]
133    full_description: Option<MultiformatMessageString>,
134    /// Default severity and enablement for the rule
135    default_configuration: ReportingConfiguration,
136    /// Help text or URI for the rule
137    #[serde(skip_serializing_if = "Option::is_none")]
138    help: Option<MultiformatMessageString>,
139    /// Additional properties including tags and severity scores
140    properties: RuleProperties,
141}
142
143impl ReportingDescriptor {
144    /// Create a ReportingDescriptor from an advisory
145    fn from_advisory(metadata: &advisory::Metadata, is_vulnerability: bool) -> Self {
146        let tags = if is_vulnerability {
147            &[Tag::Security, Tag::Vulnerability]
148        } else {
149            &[Tag::Security, Tag::Warning]
150        };
151
152        let security_severity = metadata
153            .cvss
154            .as_ref()
155            .map(|cvss| format!("{:.1}", cvss.score()));
156
157        ReportingDescriptor {
158            id: metadata.id.to_string(),
159            name: metadata.id.to_string(),
160            short_description: MultiformatMessageString {
161                text: metadata.title.clone(),
162                markdown: None,
163            },
164            full_description: if metadata.description.is_empty() {
165                None
166            } else {
167                Some(MultiformatMessageString {
168                    text: metadata.description.clone(),
169                    markdown: None,
170                })
171            },
172            default_configuration: ReportingConfiguration {
173                level: match is_vulnerability {
174                    true => ReportingLevel::Error,
175                    false => ReportingLevel::Warning,
176                },
177            },
178            help: metadata.url.as_ref().map(|url| MultiformatMessageString {
179                text: format!("For more information, see: {url}"),
180                markdown: Some(format!(
181                    "For more information, see: [{}]({url})",
182                    metadata.id
183                )),
184            }),
185            properties: RuleProperties {
186                tags,
187                precision: Precision::VeryHigh,
188                problem_severity: if !is_vulnerability {
189                    Some(ProblemSeverity::Warning)
190                } else {
191                    None
192                },
193                security_severity,
194            },
195        }
196    }
197
198    /// Create a ReportingDescriptor from a warning kind
199    fn from_warning_kind(kind: WarningKind) -> Self {
200        let (name, description) = match kind {
201            WarningKind::Unmaintained => (
202                "unmaintained",
203                "Package is unmaintained and may have unaddressed security vulnerabilities",
204            ),
205            WarningKind::Unsound => (
206                "unsound",
207                "Package has known soundness issues that may lead to memory safety problems",
208            ),
209            WarningKind::Yanked => (
210                "yanked",
211                "Package version has been yanked from the registry",
212            ),
213            _ => ("unknown", "Unknown warning type"),
214        };
215
216        ReportingDescriptor {
217            id: name.to_string(),
218            name: name.to_string(),
219            short_description: MultiformatMessageString {
220                text: description.to_string(),
221                markdown: None,
222            },
223            full_description: None,
224            default_configuration: ReportingConfiguration {
225                level: ReportingLevel::Warning,
226            },
227            help: None,
228            properties: RuleProperties {
229                tags: &[Tag::Security, Tag::Warning],
230                precision: Precision::High,
231                problem_severity: Some(ProblemSeverity::Warning),
232                security_severity: None,
233            },
234        }
235    }
236}
237
238/// Rule properties
239#[derive(Debug, Serialize)]
240#[serde(rename_all = "camelCase")]
241struct RuleProperties {
242    /// Tags associated with the rule
243    #[serde(skip_serializing_if = "<[Tag]>::is_empty")]
244    tags: &'static [Tag],
245    /// Precision of the rule (e.g., "very-high", "high")
246    precision: Precision,
247    /// Problem severity for non-security issues
248    #[serde(skip_serializing_if = "Option::is_none")]
249    #[serde(rename = "problem.severity")]
250    problem_severity: Option<ProblemSeverity>,
251    /// CVSS score as a string (0.0-10.0)
252    #[serde(skip_serializing_if = "Option::is_none")]
253    #[serde(rename = "security-severity")]
254    security_severity: Option<String>,
255}
256
257#[derive(Debug, Serialize)]
258#[serde(rename_all = "lowercase")]
259enum ProblemSeverity {
260    Warning,
261}
262
263/// Reporting configuration for a rule
264#[derive(Debug, Serialize)]
265#[serde(rename_all = "camelCase")]
266struct ReportingConfiguration {
267    /// Default level for the rule ("error", "warning", "note")
268    level: ReportingLevel,
269}
270
271#[derive(Debug, Serialize)]
272#[serde(rename_all = "camelCase")]
273enum ReportingLevel {
274    Error,
275    Warning,
276}
277
278/// Message with optional markdown
279#[derive(Debug, Serialize)]
280#[serde(rename_all = "camelCase")]
281pub struct MultiformatMessageString {
282    /// Plain text message
283    text: String,
284    /// Optional markdown-formatted message
285    #[serde(skip_serializing_if = "Option::is_none")]
286    markdown: Option<String>,
287}
288
289/// A result (finding/alert)
290#[derive(Debug, Serialize)]
291#[serde(rename_all = "camelCase")]
292pub struct SarifResult {
293    /// ID of the rule that was violated
294    rule_id: String,
295    /// Message describing the result
296    message: Message,
297    /// Severity level of the result
298    level: ResultLevel,
299    /// Locations where the issue was detected
300    locations: Vec<Location>,
301    /// Fingerprints for result matching
302    partial_fingerprints: HashMap<String, String>,
303}
304
305impl SarifResult {
306    /// Create a Result from a vulnerability
307    fn from_vulnerability(vuln: &Vulnerability, cargo_lock_path: &str) -> Self {
308        let fingerprint = format!(
309            "{}:{}:{}",
310            vuln.advisory.id, vuln.package.name, vuln.package.version
311        );
312
313        SarifResult {
314            rule_id: vuln.advisory.id.to_string(),
315            message: Message {
316                text: format!(
317                    "{} {} is vulnerable to {} ({})",
318                    vuln.package.name, vuln.package.version, vuln.advisory.id, vuln.advisory.title
319                ),
320            },
321            level: ResultLevel::Error,
322            locations: vec![Location::new(cargo_lock_path)],
323            partial_fingerprints: {
324                let mut fingerprints = HashMap::new();
325                // Use a custom fingerprint key instead of primaryLocationLineHash
326                // to avoid conflicts with GitHub's calculated fingerprints
327                fingerprints.insert("cargo-audit/advisory-fingerprint".to_string(), fingerprint);
328                fingerprints
329            },
330        }
331    }
332
333    /// Create a Result from a warning
334    fn from_warning(warning: &Warning, cargo_lock_path: &str) -> Self {
335        let rule_id = if let Some(advisory) = &warning.advisory {
336            advisory.id.to_string()
337        } else {
338            format!("{:?}", warning.kind).to_lowercase()
339        };
340
341        let message_text = if let Some(advisory) = &warning.advisory {
342            format!(
343                "{} {} has a {} warning: {}",
344                warning.package.name,
345                warning.package.version,
346                warning.kind.as_str(),
347                advisory.title
348            )
349        } else {
350            format!(
351                "{} {} has a {} warning",
352                warning.package.name,
353                warning.package.version,
354                warning.kind.as_str()
355            )
356        };
357
358        let fingerprint = format!(
359            "{rule_id}:{}:{}",
360            warning.package.name, warning.package.version
361        );
362
363        SarifResult {
364            rule_id,
365            message: Message { text: message_text },
366            level: ResultLevel::Warning,
367            locations: vec![Location::new(cargo_lock_path)],
368            partial_fingerprints: {
369                let mut fingerprints = HashMap::new();
370                // Use a custom fingerprint key instead of primaryLocationLineHash
371                // to avoid conflicts with GitHub's calculated fingerprints
372                fingerprints.insert("cargo-audit/advisory-fingerprint".to_string(), fingerprint);
373                fingerprints
374            },
375        }
376    }
377}
378
379#[derive(Debug, Serialize)]
380#[serde(rename_all = "camelCase")]
381enum ResultLevel {
382    Error,
383    Warning,
384}
385
386/// Simple message
387#[derive(Debug, Serialize)]
388#[serde(rename_all = "camelCase")]
389struct Message {
390    /// The message text
391    text: String,
392}
393
394/// Location of a finding
395#[derive(Debug, Serialize)]
396#[serde(rename_all = "camelCase")]
397struct Location {
398    /// Physical location of the finding
399    physical_location: PhysicalLocation,
400}
401
402impl Location {
403    fn new(cargo_lock_path: &str) -> Self {
404        Self {
405            physical_location: PhysicalLocation {
406                artifact_location: ArtifactLocation {
407                    uri: cargo_lock_path.to_string(),
408                },
409                region: Region { start_line: 1 },
410            },
411        }
412    }
413}
414
415/// Physical location in a file
416#[derive(Debug, Serialize)]
417#[serde(rename_all = "camelCase")]
418struct PhysicalLocation {
419    /// The artifact (file) containing the issue
420    artifact_location: ArtifactLocation,
421    /// Region within the artifact
422    region: Region,
423}
424
425/// Artifact (file) location
426#[derive(Debug, Serialize)]
427#[serde(rename_all = "camelCase")]
428struct ArtifactLocation {
429    /// URI of the artifact
430    uri: String,
431}
432
433/// Region within a file
434#[derive(Debug, Serialize)]
435#[serde(rename_all = "camelCase")]
436struct Region {
437    /// Starting line number (1-based)
438    start_line: u32,
439}
440
441#[derive(Debug, Serialize)]
442#[serde(rename_all = "camelCase")]
443enum Tag {
444    Security,
445    Vulnerability,
446    Warning,
447}
448
449#[derive(Debug, Serialize)]
450#[serde(rename_all = "kebab-case")]
451enum Precision {
452    High,
453    VeryHigh,
454}