1use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12use super::types::{Confidence, SecurityCategory, SecurityFinding, SecurityReport, Severity};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct SarifLog {
22 #[serde(rename = "$schema")]
24 pub schema: String,
25 pub version: String,
27 pub runs: Vec<SarifRun>,
29}
30
31impl SarifLog {
32 #[must_use]
34 pub fn new(run: SarifRun) -> Self {
35 Self {
36 schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json".to_string(),
37 version: "2.1.0".to_string(),
38 runs: vec![run],
39 }
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct SarifRun {
47 pub tool: SarifTool,
49 pub results: Vec<SarifResult>,
51 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub artifacts: Vec<SarifArtifact>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub invocations: Option<Vec<SarifInvocation>>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct SarifTool {
63 pub driver: SarifToolComponent,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct SarifToolComponent {
71 pub name: String,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub version: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub semantic_version: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub information_uri: Option<String>,
82 #[serde(skip_serializing_if = "Vec::is_empty")]
84 pub rules: Vec<SarifReportingDescriptor>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct SarifReportingDescriptor {
91 pub id: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub short_description: Option<SarifMessage>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub full_description: Option<SarifMessage>,
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub help: Option<SarifMessage>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub help_uri: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub default_configuration: Option<SarifReportingConfiguration>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub properties: Option<SarifPropertyBag>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct SarifReportingConfiguration {
117 pub level: SarifLevel,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub enabled: Option<bool>,
122}
123
124#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
126#[serde(rename_all = "lowercase")]
127pub enum SarifLevel {
128 Error,
130 Warning,
132 Note,
134 None,
136}
137
138impl From<Severity> for SarifLevel {
139 fn from(sev: Severity) -> Self {
140 match sev {
141 Severity::Critical | Severity::High => Self::Error,
142 Severity::Medium => Self::Warning,
143 Severity::Low | Severity::Info => Self::Note,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct SarifMessage {
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub text: Option<String>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub markdown: Option<String>,
158}
159
160impl SarifMessage {
161 #[must_use]
163 pub fn text(s: impl Into<String>) -> Self {
164 Self {
165 text: Some(s.into()),
166 markdown: None,
167 }
168 }
169
170 #[must_use]
172 pub fn markdown(s: impl Into<String>) -> Self {
173 Self {
174 text: None,
175 markdown: Some(s.into()),
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct SarifResult {
184 pub rule_id: String,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub rule_index: Option<usize>,
189 pub level: SarifLevel,
191 pub message: SarifMessage,
193 pub locations: Vec<SarifLocation>,
195 #[serde(skip_serializing_if = "Vec::is_empty")]
197 pub code_flows: Vec<SarifCodeFlow>,
198 #[serde(skip_serializing_if = "Vec::is_empty")]
200 pub related_locations: Vec<SarifLocation>,
201 #[serde(skip_serializing_if = "Vec::is_empty")]
203 pub fixes: Vec<SarifFix>,
204 #[serde(skip_serializing_if = "HashMap::is_empty")]
206 pub fingerprints: HashMap<String, String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub properties: Option<SarifPropertyBag>,
210 #[serde(skip_serializing_if = "Vec::is_empty")]
212 pub suppressions: Vec<SarifSuppression>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct SarifLocation {
219 pub physical_location: SarifPhysicalLocation,
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub logical_locations: Option<Vec<SarifLogicalLocation>>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(rename_all = "camelCase")]
229pub struct SarifPhysicalLocation {
230 pub artifact_location: SarifArtifactLocation,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub region: Option<SarifRegion>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub context_region: Option<SarifRegion>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct SarifArtifactLocation {
244 pub uri: String,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub uri_base_id: Option<String>,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub index: Option<usize>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct SarifRegion {
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub start_line: Option<usize>,
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub start_column: Option<usize>,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub end_line: Option<usize>,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub end_column: Option<usize>,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub snippet: Option<SarifArtifactContent>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SarifArtifactContent {
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub text: Option<String>,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub binary: Option<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct SarifLogicalLocation {
291 #[serde(skip_serializing_if = "Option::is_none")]
293 pub name: Option<String>,
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub fully_qualified_name: Option<String>,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub kind: Option<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
304#[serde(rename_all = "camelCase")]
305pub struct SarifCodeFlow {
306 pub thread_flows: Vec<SarifThreadFlow>,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct SarifThreadFlow {
314 pub locations: Vec<SarifThreadFlowLocation>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub struct SarifThreadFlowLocation {
322 pub location: SarifLocation,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 pub importance: Option<String>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct SarifFix {
333 pub description: SarifMessage,
335 pub artifact_changes: Vec<SarifArtifactChange>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct SarifArtifactChange {
343 pub artifact_location: SarifArtifactLocation,
345 pub replacements: Vec<SarifReplacement>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct SarifReplacement {
353 pub deleted_region: SarifRegion,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub inserted_content: Option<SarifArtifactContent>,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct SarifArtifact {
364 pub location: SarifArtifactLocation,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub mime_type: Option<String>,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub length: Option<usize>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(rename_all = "camelCase")]
377pub struct SarifInvocation {
378 pub execution_successful: bool,
380 #[serde(skip_serializing_if = "Option::is_none")]
382 pub start_time_utc: Option<String>,
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub end_time_utc: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
388 pub working_directory: Option<SarifArtifactLocation>,
389}
390
391#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393#[serde(rename_all = "camelCase")]
394pub struct SarifPropertyBag {
395 #[serde(skip_serializing_if = "Vec::is_empty")]
397 pub cwe: Vec<String>,
398 #[serde(skip_serializing_if = "Vec::is_empty")]
400 pub tags: Vec<String>,
401 #[serde(skip_serializing_if = "Option::is_none", rename = "security-severity")]
403 pub security_severity: Option<String>,
404 #[serde(skip_serializing_if = "Option::is_none")]
406 pub confidence: Option<String>,
407 #[serde(flatten)]
409 pub extra: HashMap<String, serde_json::Value>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
414#[serde(rename_all = "camelCase")]
415pub struct SarifSuppression {
416 pub kind: String,
418 #[serde(skip_serializing_if = "Option::is_none")]
420 pub justification: Option<String>,
421}
422
423impl SecurityReport {
428 #[must_use]
430 pub fn to_sarif(&self) -> SarifLog {
431 let mut rules: Vec<SarifReportingDescriptor> = Vec::new();
433 let mut rule_index_map: HashMap<String, usize> = HashMap::new();
434
435 for finding in &self.findings {
436 if !rule_index_map.contains_key(&finding.id) {
437 rule_index_map.insert(finding.id.clone(), rules.len());
438 rules.push(finding_to_rule(finding));
439 }
440 }
441
442 let results: Vec<SarifResult> = self
444 .findings
445 .iter()
446 .map(|f| finding_to_result(f, rule_index_map.get(&f.id).copied()))
447 .collect();
448
449 let mut artifacts: Vec<SarifArtifact> = Vec::new();
451 let mut seen_files: HashMap<String, usize> = HashMap::new();
452
453 for finding in &self.findings {
454 if !seen_files.contains_key(&finding.location.file) {
455 seen_files.insert(finding.location.file.clone(), artifacts.len());
456 artifacts.push(SarifArtifact {
457 location: SarifArtifactLocation {
458 uri: finding.location.file.clone(),
459 uri_base_id: Some("%SRCROOT%".to_string()),
460 index: Some(artifacts.len()),
461 },
462 mime_type: guess_mime_type(&finding.location.file),
463 length: None,
464 });
465 }
466 }
467
468 let tool = SarifTool {
469 driver: SarifToolComponent {
470 name: "brrr-security".to_string(),
471 version: Some(self.scanner_version.clone()),
472 semantic_version: Some(self.scanner_version.clone()),
473 information_uri: Some(
474 "https://github.com/GrigoryEvko/go-brrr".to_string(),
475 ),
476 rules,
477 },
478 };
479
480 let run = SarifRun {
481 tool,
482 results,
483 artifacts,
484 invocations: Some(vec![SarifInvocation {
485 execution_successful: true,
486 start_time_utc: Some(self.timestamp.clone()),
487 end_time_utc: Some(self.timestamp.clone()),
488 working_directory: None,
489 }]),
490 };
491
492 SarifLog::new(run)
493 }
494
495 pub fn to_sarif_json(&self) -> Result<String, serde_json::Error> {
500 let sarif = self.to_sarif();
501 serde_json::to_string_pretty(&sarif)
502 }
503}
504
505fn finding_to_rule(finding: &SecurityFinding) -> SarifReportingDescriptor {
507 let mut properties = SarifPropertyBag::default();
508
509 if let Some(cwe) = finding.cwe_id {
510 properties.cwe.push(format!("CWE-{cwe}"));
511 }
512
513 properties.security_severity = Some(format!("{:.1}", finding.severity.cvss_score()));
514
515 let tag = match &finding.category {
517 SecurityCategory::Injection(t) => format!("injection/{t:?}").to_lowercase(),
518 SecurityCategory::SecretsExposure => "secrets".to_string(),
519 SecurityCategory::WeakCrypto => "crypto".to_string(),
520 SecurityCategory::UnsafeDeserialization => "deserialization".to_string(),
521 SecurityCategory::ReDoS => "redos".to_string(),
522 SecurityCategory::InsecureConfig => "config".to_string(),
523 SecurityCategory::AuthIssue => "auth".to_string(),
524 SecurityCategory::InfoDisclosure => "disclosure".to_string(),
525 SecurityCategory::Other(s) => s.clone(),
526 };
527 properties.tags.push(tag);
528
529 if let Some(owasp) = finding.category.owasp_category() {
531 properties.tags.push(owasp.to_string());
532 }
533
534 let help_uri = finding
535 .cwe_id
536 .map(|cwe| format!("https://cwe.mitre.org/data/definitions/{cwe}.html"));
537
538 SarifReportingDescriptor {
539 id: finding.id.clone(),
540 short_description: Some(SarifMessage::text(&finding.title)),
541 full_description: Some(SarifMessage::text(&finding.description)),
542 help: if finding.remediation.is_empty() {
543 None
544 } else {
545 Some(SarifMessage::markdown(&finding.remediation))
546 },
547 help_uri,
548 default_configuration: Some(SarifReportingConfiguration {
549 level: SarifLevel::from(finding.severity),
550 enabled: Some(true),
551 }),
552 properties: Some(properties),
553 }
554}
555
556fn finding_to_result(finding: &SecurityFinding, rule_index: Option<usize>) -> SarifResult {
558 let location = SarifLocation {
559 physical_location: SarifPhysicalLocation {
560 artifact_location: SarifArtifactLocation {
561 uri: finding.location.file.clone(),
562 uri_base_id: Some("%SRCROOT%".to_string()),
563 index: None,
564 },
565 region: Some(SarifRegion {
566 start_line: Some(finding.location.start_line),
567 start_column: Some(finding.location.start_column),
568 end_line: Some(finding.location.end_line),
569 end_column: Some(finding.location.end_column),
570 snippet: if finding.code_snippet.is_empty() {
571 None
572 } else {
573 Some(SarifArtifactContent {
574 text: Some(finding.code_snippet.clone()),
575 binary: None,
576 })
577 },
578 }),
579 context_region: None,
580 },
581 logical_locations: None,
582 };
583
584 let mut fingerprints = HashMap::new();
585 fingerprints.insert(
586 "primaryLocationLineHash".to_string(),
587 finding.fingerprint(),
588 );
589
590 let mut properties = SarifPropertyBag::default();
591 properties.confidence = Some(finding.confidence.to_string());
592
593 for (k, v) in &finding.metadata {
595 properties
596 .extra
597 .insert(k.clone(), serde_json::Value::String(v.clone()));
598 }
599
600 let suppressions = if finding.suppressed {
601 vec![SarifSuppression {
602 kind: "inSource".to_string(),
603 justification: Some("Suppressed via inline comment".to_string()),
604 }]
605 } else {
606 Vec::new()
607 };
608
609 SarifResult {
610 rule_id: finding.id.clone(),
611 rule_index,
612 level: SarifLevel::from(finding.severity),
613 message: SarifMessage::text(&finding.description),
614 locations: vec![location],
615 code_flows: Vec::new(),
616 related_locations: Vec::new(),
617 fixes: Vec::new(),
618 fingerprints,
619 properties: Some(properties),
620 suppressions,
621 }
622}
623
624fn guess_mime_type(path: &str) -> Option<String> {
626 let ext = path.rsplit('.').next()?;
627 let mime = match ext {
628 "py" => "text/x-python",
629 "js" => "text/javascript",
630 "ts" => "text/typescript",
631 "tsx" | "jsx" => "text/jsx",
632 "rs" => "text/x-rust",
633 "go" => "text/x-go",
634 "java" => "text/x-java",
635 "c" | "h" => "text/x-c",
636 "cpp" | "cc" | "cxx" | "hpp" => "text/x-c++src",
637 "rb" => "text/x-ruby",
638 "php" => "text/x-php",
639 _ => return None,
640 };
641 Some(mime.to_string())
642}
643
644#[cfg(test)]
649mod tests {
650 use super::*;
651 use crate::security::types::{InjectionType, Location};
652
653 #[test]
654 fn test_sarif_generation() {
655 let finding = SecurityFinding::new(
656 "SQLI-001",
657 SecurityCategory::Injection(InjectionType::Sql),
658 Severity::High,
659 Confidence::High,
660 Location::new("src/api.py", 42, 5, 42, 50),
661 "SQL Injection in query",
662 "User input is concatenated into SQL query without sanitization",
663 )
664 .with_remediation("Use parameterized queries")
665 .with_code_snippet("cursor.execute(f\"SELECT * FROM users WHERE id = {user_id}\")");
666
667 let report = SecurityReport::new(vec![finding], 10);
668 let sarif = report.to_sarif();
669
670 assert_eq!(sarif.version, "2.1.0");
671 assert_eq!(sarif.runs.len(), 1);
672
673 let run = &sarif.runs[0];
674 assert_eq!(run.tool.driver.name, "brrr-security");
675 assert_eq!(run.results.len(), 1);
676 assert_eq!(run.tool.driver.rules.len(), 1);
677
678 let result = &run.results[0];
679 assert_eq!(result.rule_id, "SQLI-001");
680 assert!(matches!(result.level, SarifLevel::Error));
681 }
682
683 #[test]
684 fn test_sarif_json_output() {
685 let finding = SecurityFinding::new(
686 "CMD-001",
687 SecurityCategory::Injection(InjectionType::Command),
688 Severity::Critical,
689 Confidence::High,
690 Location::new("app.py", 10, 1, 10, 30),
691 "Command Injection",
692 "os.system called with user input",
693 );
694
695 let report = SecurityReport::new(vec![finding], 1);
696 let json = report.to_sarif_json().expect("SARIF JSON serialization");
697
698 assert!(json.contains("\"version\": \"2.1.0\""));
699 assert!(json.contains("CMD-001"));
700 assert!(json.contains("error")); }
702}