use crate::report::RustinelReport;
use crate::signals::{RiskSignal, Severity};
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Serialize)]
pub struct SarifLog {
#[serde(rename = "$schema")]
schema: String,
version: String,
runs: Vec<SarifRun>,
}
#[derive(Serialize)]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
}
#[derive(Serialize)]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Serialize)]
struct SarifDriver {
name: String,
version: String,
#[serde(rename = "informationUri")]
information_uri: String,
rules: Vec<SarifRule>,
}
#[derive(Serialize)]
struct SarifRule {
id: String,
name: String,
#[serde(rename = "shortDescription")]
short_description: SarifText,
help: SarifText,
}
#[derive(Serialize)]
struct SarifResult {
#[serde(rename = "ruleId")]
rule_id: String,
level: String,
message: SarifText,
locations: Vec<SarifLocation>,
#[serde(rename = "partialFingerprints")]
partial_fingerprints: BTreeMap<String, String>,
}
#[derive(Serialize)]
struct SarifLocation {
#[serde(rename = "physicalLocation")]
physical_location: SarifPhysicalLocation,
}
#[derive(Serialize)]
struct SarifPhysicalLocation {
#[serde(rename = "artifactLocation")]
artifact_location: SarifArtifactLocation,
region: SarifRegion,
}
#[derive(Serialize)]
struct SarifArtifactLocation {
uri: String,
}
#[derive(Serialize)]
struct SarifRegion {
#[serde(rename = "startLine")]
start_line: u32,
}
#[derive(Serialize)]
struct SarifText {
text: String,
}
const LOCKFILE_URI: &str = "Cargo.lock";
fn fnv1a_hex(s: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{hash:016x}")
}
fn level_for(severity: Severity) -> &'static str {
match severity {
Severity::Critical | Severity::High => "error",
Severity::Medium => "warning",
Severity::Low | Severity::Info => "note",
}
}
fn flatten(text: &str) -> String {
text.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect()
}
pub fn build(report: &RustinelReport) -> SarifLog {
let mut rules_map: BTreeMap<String, SarifRule> = BTreeMap::new();
for finding in &report.findings {
rules_map
.entry(finding.id.clone())
.or_insert_with(|| SarifRule {
id: finding.id.clone(),
name: rule_name(finding),
short_description: SarifText {
text: rule_name(finding),
},
help: SarifText {
text: flatten(&finding.recommendation),
},
});
}
let rules: Vec<SarifRule> = rules_map.into_values().collect();
let results: Vec<SarifResult> = report
.findings
.iter()
.map(|f| {
let detail = f
.evidence
.first()
.map(|e| flatten(&e.summary))
.unwrap_or_else(|| f.id.clone());
let mut fingerprints = BTreeMap::new();
fingerprints.insert(
"rustinel/v1".to_string(),
fnv1a_hex(&format!("{}\u{0}{}", f.id, f.package)),
);
SarifResult {
rule_id: f.id.clone(),
level: level_for(f.severity).to_string(),
message: SarifText {
text: flatten(&format!("{}: {}", f.package, detail)),
},
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation {
uri: LOCKFILE_URI.to_string(),
},
region: SarifRegion { start_line: 1 },
},
}],
partial_fingerprints: fingerprints,
}
})
.collect();
SarifLog {
schema: "https://json.schemastore.org/sarif-2.1.0.json".into(),
version: "2.1.0".into(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: report.tool.name.clone(),
version: report.tool.version.clone(),
information_uri: "https://github.com/kosiorkosa47/rustinel".into(),
rules,
},
},
results,
}],
}
}
fn rule_name(finding: &RiskSignal) -> String {
match finding.id.as_str() {
"native_ffi_detected" => "Native FFI dependency detected".into(),
"build_script_present" => "Build script present".into(),
"build_script_suspicious" => "Suspicious build script (network / payload)".into(),
"suspicious_source_exfil" => "Source matches secret-exfiltration malware pattern".into(),
"unsafe_present" => "Unsafe code present".into(),
"multiple_versions_same_crate" => "Multiple versions of the same crate".into(),
"possible_typosquat" => "Possible typosquat of a popular crate".into(),
"yanked_crate" => "Yanked crate version".into(),
"license_unknown" => "Unknown license".into(),
"license_detected" => "License detected".into(),
id if id.starts_with("advisory_") => "Known security advisory".into(),
other => other.to_string(),
}
}