|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | + |
| 3 | +//! SARIF 2.1.0 output for GitHub Security tab integration |
| 4 | +//! |
| 5 | +//! Converts AssailReport weak points into OASIS SARIF format. |
| 6 | +//! See: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html |
| 7 | +
|
| 8 | +use crate::types::{AssailReport, Severity, WeakPointCategory}; |
| 9 | +use anyhow::Result; |
| 10 | +use serde::Serialize; |
| 11 | + |
| 12 | +const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json"; |
| 13 | +const SARIF_VERSION: &str = "2.1.0"; |
| 14 | + |
| 15 | +/// Top-level SARIF log |
| 16 | +#[derive(Debug, Serialize)] |
| 17 | +#[serde(rename_all = "camelCase")] |
| 18 | +pub struct SarifLog { |
| 19 | + #[serde(rename = "$schema")] |
| 20 | + pub schema: String, |
| 21 | + pub version: String, |
| 22 | + pub runs: Vec<SarifRun>, |
| 23 | +} |
| 24 | + |
| 25 | +/// A single SARIF run (one tool execution) |
| 26 | +#[derive(Debug, Serialize)] |
| 27 | +#[serde(rename_all = "camelCase")] |
| 28 | +pub struct SarifRun { |
| 29 | + pub tool: SarifTool, |
| 30 | + pub results: Vec<SarifResult>, |
| 31 | +} |
| 32 | + |
| 33 | +/// Tool descriptor |
| 34 | +#[derive(Debug, Serialize)] |
| 35 | +#[serde(rename_all = "camelCase")] |
| 36 | +pub struct SarifTool { |
| 37 | + pub driver: SarifToolComponent, |
| 38 | +} |
| 39 | + |
| 40 | +/// Tool component with rules |
| 41 | +#[derive(Debug, Serialize)] |
| 42 | +#[serde(rename_all = "camelCase")] |
| 43 | +pub struct SarifToolComponent { |
| 44 | + pub name: String, |
| 45 | + pub version: String, |
| 46 | + pub information_uri: String, |
| 47 | + pub rules: Vec<SarifRule>, |
| 48 | +} |
| 49 | + |
| 50 | +/// Rule descriptor |
| 51 | +#[derive(Debug, Serialize)] |
| 52 | +#[serde(rename_all = "camelCase")] |
| 53 | +pub struct SarifRule { |
| 54 | + pub id: String, |
| 55 | + pub name: String, |
| 56 | + pub short_description: SarifMessage, |
| 57 | + pub default_configuration: SarifConfiguration, |
| 58 | +} |
| 59 | + |
| 60 | +/// Configuration with level |
| 61 | +#[derive(Debug, Serialize)] |
| 62 | +#[serde(rename_all = "camelCase")] |
| 63 | +pub struct SarifConfiguration { |
| 64 | + pub level: String, |
| 65 | +} |
| 66 | + |
| 67 | +/// A single finding |
| 68 | +#[derive(Debug, Serialize)] |
| 69 | +#[serde(rename_all = "camelCase")] |
| 70 | +pub struct SarifResult { |
| 71 | + pub rule_id: String, |
| 72 | + pub level: String, |
| 73 | + pub message: SarifMessage, |
| 74 | + pub locations: Vec<SarifLocation>, |
| 75 | +} |
| 76 | + |
| 77 | +/// Message with text |
| 78 | +#[derive(Debug, Serialize)] |
| 79 | +#[serde(rename_all = "camelCase")] |
| 80 | +pub struct SarifMessage { |
| 81 | + pub text: String, |
| 82 | +} |
| 83 | + |
| 84 | +/// Physical location |
| 85 | +#[derive(Debug, Serialize)] |
| 86 | +#[serde(rename_all = "camelCase")] |
| 87 | +pub struct SarifLocation { |
| 88 | + pub physical_location: SarifPhysicalLocation, |
| 89 | +} |
| 90 | + |
| 91 | +/// Physical location with artifact |
| 92 | +#[derive(Debug, Serialize)] |
| 93 | +#[serde(rename_all = "camelCase")] |
| 94 | +pub struct SarifPhysicalLocation { |
| 95 | + pub artifact_location: SarifArtifactLocation, |
| 96 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 97 | + pub region: Option<SarifRegion>, |
| 98 | +} |
| 99 | + |
| 100 | +/// Artifact URI |
| 101 | +#[derive(Debug, Serialize)] |
| 102 | +#[serde(rename_all = "camelCase")] |
| 103 | +pub struct SarifArtifactLocation { |
| 104 | + pub uri: String, |
| 105 | +} |
| 106 | + |
| 107 | +/// Region (line number) |
| 108 | +#[derive(Debug, Serialize)] |
| 109 | +#[serde(rename_all = "camelCase")] |
| 110 | +pub struct SarifRegion { |
| 111 | + pub start_line: u32, |
| 112 | +} |
| 113 | + |
| 114 | +/// Map WeakPointCategory to a stable rule ID |
| 115 | +fn rule_id(category: &WeakPointCategory) -> &'static str { |
| 116 | + match category { |
| 117 | + WeakPointCategory::UncheckedAllocation => "PA001", |
| 118 | + WeakPointCategory::UnboundedLoop => "PA002", |
| 119 | + WeakPointCategory::BlockingIO => "PA003", |
| 120 | + WeakPointCategory::UnsafeCode => "PA004", |
| 121 | + WeakPointCategory::PanicPath => "PA005", |
| 122 | + WeakPointCategory::RaceCondition => "PA006", |
| 123 | + WeakPointCategory::DeadlockPotential => "PA007", |
| 124 | + WeakPointCategory::ResourceLeak => "PA008", |
| 125 | + WeakPointCategory::CommandInjection => "PA009", |
| 126 | + WeakPointCategory::UnsafeDeserialization => "PA010", |
| 127 | + WeakPointCategory::DynamicCodeExecution => "PA011", |
| 128 | + WeakPointCategory::UnsafeFFI => "PA012", |
| 129 | + WeakPointCategory::AtomExhaustion => "PA013", |
| 130 | + WeakPointCategory::InsecureProtocol => "PA014", |
| 131 | + WeakPointCategory::ExcessivePermissions => "PA015", |
| 132 | + WeakPointCategory::PathTraversal => "PA016", |
| 133 | + WeakPointCategory::HardcodedSecret => "PA017", |
| 134 | + WeakPointCategory::UncheckedError => "PA018", |
| 135 | + WeakPointCategory::InfiniteRecursion => "PA019", |
| 136 | + WeakPointCategory::UnsafeTypeCoercion => "PA020", |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +/// Map WeakPointCategory to a human-readable name |
| 141 | +fn rule_name(category: &WeakPointCategory) -> &'static str { |
| 142 | + match category { |
| 143 | + WeakPointCategory::UncheckedAllocation => "unchecked-allocation", |
| 144 | + WeakPointCategory::UnboundedLoop => "unbounded-loop", |
| 145 | + WeakPointCategory::BlockingIO => "blocking-io", |
| 146 | + WeakPointCategory::UnsafeCode => "unsafe-code", |
| 147 | + WeakPointCategory::PanicPath => "panic-path", |
| 148 | + WeakPointCategory::RaceCondition => "race-condition", |
| 149 | + WeakPointCategory::DeadlockPotential => "deadlock-potential", |
| 150 | + WeakPointCategory::ResourceLeak => "resource-leak", |
| 151 | + WeakPointCategory::CommandInjection => "command-injection", |
| 152 | + WeakPointCategory::UnsafeDeserialization => "unsafe-deserialization", |
| 153 | + WeakPointCategory::DynamicCodeExecution => "dynamic-code-execution", |
| 154 | + WeakPointCategory::UnsafeFFI => "unsafe-ffi", |
| 155 | + WeakPointCategory::AtomExhaustion => "atom-exhaustion", |
| 156 | + WeakPointCategory::InsecureProtocol => "insecure-protocol", |
| 157 | + WeakPointCategory::ExcessivePermissions => "excessive-permissions", |
| 158 | + WeakPointCategory::PathTraversal => "path-traversal", |
| 159 | + WeakPointCategory::HardcodedSecret => "hardcoded-secret", |
| 160 | + WeakPointCategory::UncheckedError => "unchecked-error", |
| 161 | + WeakPointCategory::InfiniteRecursion => "infinite-recursion", |
| 162 | + WeakPointCategory::UnsafeTypeCoercion => "unsafe-type-coercion", |
| 163 | + } |
| 164 | +} |
| 165 | + |
| 166 | +/// Map Severity to SARIF level |
| 167 | +fn sarif_level(severity: &Severity) -> &'static str { |
| 168 | + match severity { |
| 169 | + Severity::Critical => "error", |
| 170 | + Severity::High => "error", |
| 171 | + Severity::Medium => "warning", |
| 172 | + Severity::Low => "note", |
| 173 | + } |
| 174 | +} |
| 175 | + |
| 176 | +/// Parse a location string like "src/main.rs:42" into (path, optional line) |
| 177 | +fn parse_location(loc: &str) -> (&str, Option<u32>) { |
| 178 | + if let Some(colon_pos) = loc.rfind(':') { |
| 179 | + let (path, rest) = loc.split_at(colon_pos); |
| 180 | + if let Ok(line) = rest[1..].parse::<u32>() { |
| 181 | + return (path, Some(line)); |
| 182 | + } |
| 183 | + } |
| 184 | + (loc, None) |
| 185 | +} |
| 186 | + |
| 187 | +/// Convert an AssailReport to SARIF JSON |
| 188 | +pub fn to_sarif(report: &AssailReport) -> Result<SarifLog> { |
| 189 | + // Collect unique rules |
| 190 | + let mut seen_categories = std::collections::HashSet::new(); |
| 191 | + let mut rules = Vec::new(); |
| 192 | + |
| 193 | + for wp in &report.weak_points { |
| 194 | + if seen_categories.insert(wp.category) { |
| 195 | + rules.push(SarifRule { |
| 196 | + id: rule_id(&wp.category).to_string(), |
| 197 | + name: rule_name(&wp.category).to_string(), |
| 198 | + short_description: SarifMessage { |
| 199 | + text: format!("{:?}", wp.category), |
| 200 | + }, |
| 201 | + default_configuration: SarifConfiguration { |
| 202 | + level: sarif_level(&wp.severity).to_string(), |
| 203 | + }, |
| 204 | + }); |
| 205 | + } |
| 206 | + } |
| 207 | + |
| 208 | + // Convert weak points to results |
| 209 | + let results: Vec<SarifResult> = report |
| 210 | + .weak_points |
| 211 | + .iter() |
| 212 | + .map(|wp| { |
| 213 | + let loc_str = wp.location.as_deref().unwrap_or("unknown"); |
| 214 | + let (path, line) = parse_location(loc_str); |
| 215 | + |
| 216 | + SarifResult { |
| 217 | + rule_id: rule_id(&wp.category).to_string(), |
| 218 | + level: sarif_level(&wp.severity).to_string(), |
| 219 | + message: SarifMessage { |
| 220 | + text: wp.description.clone(), |
| 221 | + }, |
| 222 | + locations: vec![SarifLocation { |
| 223 | + physical_location: SarifPhysicalLocation { |
| 224 | + artifact_location: SarifArtifactLocation { |
| 225 | + uri: path.to_string(), |
| 226 | + }, |
| 227 | + region: line.map(|l| SarifRegion { start_line: l }), |
| 228 | + }, |
| 229 | + }], |
| 230 | + } |
| 231 | + }) |
| 232 | + .collect(); |
| 233 | + |
| 234 | + Ok(SarifLog { |
| 235 | + schema: SARIF_SCHEMA.to_string(), |
| 236 | + version: SARIF_VERSION.to_string(), |
| 237 | + runs: vec![SarifRun { |
| 238 | + tool: SarifTool { |
| 239 | + driver: SarifToolComponent { |
| 240 | + name: "panic-attack".to_string(), |
| 241 | + version: env!("CARGO_PKG_VERSION").to_string(), |
| 242 | + information_uri: "https://github.com/hyperpolymath/panic-attacker".to_string(), |
| 243 | + rules, |
| 244 | + }, |
| 245 | + }, |
| 246 | + results, |
| 247 | + }], |
| 248 | + }) |
| 249 | +} |
| 250 | + |
| 251 | +/// Serialize a SARIF log to JSON string |
| 252 | +pub fn to_sarif_json(report: &AssailReport) -> Result<String> { |
| 253 | + let log = to_sarif(report)?; |
| 254 | + let json = serde_json::to_string_pretty(&log)?; |
| 255 | + Ok(json) |
| 256 | +} |
0 commit comments