use crate::parser::SarifResult as ParseResult;
use crate::types::{Level, Result as SarifResult, Run, SarifLog};
use std::collections::HashMap;
use std::io::Write;
type LocationInfo = (String, Option<i32>, Option<i32>, Option<i32>, Option<i32>, Option<i32>, Option<i32>);
#[derive(Debug, Clone)]
pub struct ConversionConfig {
pub include_full_paths: bool,
pub max_message_length: Option<usize>,
pub min_level: Option<Level>,
pub include_snippets: bool,
pub field_mappings: HashMap<String, String>,
}
impl Default for ConversionConfig {
fn default() -> Self {
Self {
include_full_paths: false,
max_message_length: Some(500),
min_level: None,
include_snippets: false,
field_mappings: HashMap::new(),
}
}
}
pub struct CsvConverter {
config: ConversionConfig,
}
impl CsvConverter {
pub fn new() -> Self {
Self {
config: ConversionConfig::default(),
}
}
pub fn with_config(config: ConversionConfig) -> Self {
Self { config }
}
pub fn convert_to_csv(&self, sarif: &SarifLog) -> ParseResult<String> {
let mut output = String::new();
output.push_str("Tool,Rule ID,Level,Kind,Message,File,Line,Column,Start Line,Start Column,End Line,End Column\n");
for run in &sarif.runs {
let tool_name = &run.tool.driver.name;
if let Some(results) = &run.results {
for result in results {
if self.should_include_result(result) {
output.push_str(&self.result_to_csv_row(tool_name, result));
output.push('\n');
}
}
}
}
Ok(output)
}
fn result_to_csv_row(&self, tool_name: &str, result: &SarifResult) -> String {
let rule_id = result.rule_id.as_deref().unwrap_or("N/A");
let level = result
.level
.as_ref()
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Info".to_string());
let kind = result
.kind
.as_ref()
.map(|k| format!("{:?}", k))
.unwrap_or_else(|| "Informational".to_string());
let message = self.format_message(result.message.text.as_deref().unwrap_or("No message"));
let (file, line, column, start_line, start_column, end_line, end_column) =
self.extract_location_info(result);
format!(
"{},{},{},{},{},{},{},{},{},{},{},{}",
self.escape_csv_field(tool_name),
self.escape_csv_field(rule_id),
self.escape_csv_field(&level),
self.escape_csv_field(&kind),
self.escape_csv_field(&message),
self.escape_csv_field(&file),
line.unwrap_or(0),
column.unwrap_or(0),
start_line.unwrap_or(0),
start_column.unwrap_or(0),
end_line.unwrap_or(0),
end_column.unwrap_or(0)
)
}
fn extract_location_info(&self, result: &SarifResult) -> LocationInfo {
if let Some(locations) = &result.locations
&& let Some(location) = locations.first()
&& let Some(physical_location) = &location.physical_location
{
let file = if let Some(artifact_location) = &physical_location.artifact_location {
artifact_location
.uri
.as_deref()
.unwrap_or("Unknown")
.to_string()
} else {
"Unknown".to_string()
};
if let Some(region) = &physical_location.region {
return (
file,
region.start_line,
region.start_column,
region.start_line,
region.start_column,
region.end_line,
region.end_column,
);
}
return (file, None, None, None, None, None, None);
}
("Unknown".to_string(), None, None, None, None, None, None)
}
fn format_message(&self, message: &str) -> String {
if let Some(max_len) = self.config.max_message_length {
if message.len() > max_len {
format!("{}...", &message[..max_len])
} else {
message.to_string()
}
} else {
message.to_string()
}
}
fn escape_csv_field(&self, field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
fn should_include_result(&self, result: &SarifResult) -> bool {
if let Some(min_level) = &self.config.min_level {
if let Some(result_level) = &result.level {
match (min_level, result_level) {
(Level::Error, Level::Error) => true,
(Level::Error, _) => false,
(Level::Warning, Level::Error | Level::Warning) => true,
(Level::Warning, _) => false,
(Level::Note, Level::Error | Level::Warning | Level::Note) => true,
(Level::Note, _) => false,
(Level::None, _) => true,
}
} else {
matches!(min_level, Level::None)
}
} else {
true
}
}
}
pub struct HtmlConverter {
config: ConversionConfig,
}
impl HtmlConverter {
pub fn new() -> Self {
Self {
config: ConversionConfig::default(),
}
}
pub fn with_config(config: ConversionConfig) -> Self {
Self { config }
}
pub fn convert_to_html(&self, sarif: &SarifLog) -> ParseResult<String> {
let mut html = String::new();
html.push_str(&self.html_header());
html.push_str(&self.html_summary(sarif));
for (run_index, run) in sarif.runs.iter().enumerate() {
html.push_str(&self.convert_run_to_html(run, run_index));
}
html.push_str(&self.html_footer());
Ok(html)
}
fn html_header(&self) -> String {
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SARIF Security Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; background-color: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1, h2, h3 { color: #333; }
.summary { background-color: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; }
.run { margin-bottom: 30px; border: 1px solid #ddd; border-radius: 5px; }
.run-header { background-color: #e9ecef; padding: 10px; border-radius: 5px 5px 0 0; }
.result { margin: 10px; padding: 15px; border-left: 4px solid #ddd; background-color: #fafafa; }
.result.error { border-left-color: #dc3545; }
.result.warning { border-left-color: #ffc107; }
.result.note { border-left-color: #17a2b8; }
.result.info { border-left-color: #6c757d; }
.location { font-family: monospace; background-color: #f1f3f4; padding: 5px; border-radius: 3px; }
.message { margin: 10px 0; }
.metadata { font-size: 0.9em; color: #666; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; font-weight: bold; }
.badge.error { background-color: #dc3545; color: white; }
.badge.warning { background-color: #ffc107; color: black; }
.badge.note { background-color: #17a2b8; color: white; }
.badge.info { background-color: #6c757d; color: white; }
</style>
</head>
<body>
<div class="container">
<h1>SARIF Security Analysis Report</h1>
"#.to_string()
}
fn html_summary(&self, sarif: &SarifLog) -> String {
let total_runs = sarif.runs.len();
let mut total_results = 0;
let mut level_counts = HashMap::new();
for run in &sarif.runs {
if let Some(results) = &run.results {
total_results += results.len();
for result in results {
let level = result
.level
.as_ref()
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Info".to_string());
*level_counts.entry(level).or_insert(0) += 1;
}
}
}
format!(
r#"<div class="summary">
<h2>Summary</h2>
<p><strong>Total Runs:</strong> {}</p>
<p><strong>Total Results:</strong> {}</p>
<p><strong>Breakdown by Level:</strong></p>
<ul>
<li>Errors: {}</li>
<li>Warnings: {}</li>
<li>Notes: {}</li>
<li>Info: {}</li>
</ul>
</div>
"#,
total_runs,
total_results,
level_counts.get("Error").unwrap_or(&0),
level_counts.get("Warning").unwrap_or(&0),
level_counts.get("Note").unwrap_or(&0),
level_counts.get("Info").unwrap_or(&0)
)
}
fn convert_run_to_html(&self, run: &Run, run_index: usize) -> String {
let mut html = String::new();
html.push_str(&format!(
r#"<div class="run">
<div class="run-header">
<h2>Run {}: {}</h2>
<div class="metadata">Version: {}</div>
</div>
"#,
run_index + 1,
run.tool.driver.name,
run.tool.driver.version.as_deref().unwrap_or("Unknown")
));
if let Some(results) = &run.results {
for result in results {
if self.should_include_result(result) {
html.push_str(&self.convert_result_to_html(result));
}
}
}
html.push_str("</div>\n");
html
}
fn convert_result_to_html(&self, result: &SarifResult) -> String {
let level = result
.level
.as_ref()
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Info".to_string());
let level_class = level.to_lowercase();
let rule_id = result.rule_id.as_deref().unwrap_or("No Rule ID");
let message = result.message.text.as_deref().unwrap_or("No message");
let location_html = if let Some(locations) = &result.locations {
if let Some(location) = locations.first() {
if let Some(physical_location) = &location.physical_location {
let file = if let Some(artifact_location) = &physical_location.artifact_location
{
artifact_location.uri.as_deref().unwrap_or("Unknown")
} else {
"Unknown"
};
if let Some(region) = &physical_location.region {
format!(
r#"<div class="location">{}:{}:{}</div>"#,
file,
region.start_line.unwrap_or(0),
region.start_column.unwrap_or(0)
)
} else {
format!(r#"<div class="location">{}</div>"#, file)
}
} else {
"".to_string()
}
} else {
"".to_string()
}
} else {
"".to_string()
};
format!(
r#" <div class="result {}">
<div class="metadata">
<span class="badge {}">{}</span>
<strong>Rule:</strong> {}
</div>
<div class="message">{}</div>
{}
</div>
"#,
level_class,
level_class,
level,
self.escape_html(rule_id),
self.escape_html(message),
location_html
)
}
fn escape_html(&self, text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn html_footer(&self) -> String {
r#"</div>
</body>
</html>
"#
.to_string()
}
fn should_include_result(&self, result: &SarifResult) -> bool {
if let Some(min_level) = &self.config.min_level {
if let Some(result_level) = &result.level {
match (min_level, result_level) {
(Level::Error, Level::Error) => true,
(Level::Error, _) => false,
(Level::Warning, Level::Error | Level::Warning) => true,
(Level::Warning, _) => false,
(Level::Note, Level::Error | Level::Warning | Level::Note) => true,
(Level::Note, _) => false,
(Level::None, _) => true,
}
} else {
matches!(min_level, Level::None)
}
} else {
true
}
}
}
pub struct JsonLinesConverter {
config: ConversionConfig,
}
impl JsonLinesConverter {
pub fn new() -> Self {
Self {
config: ConversionConfig::default(),
}
}
pub fn with_config(config: ConversionConfig) -> Self {
Self { config }
}
pub fn convert_to_jsonl(&self, sarif: &SarifLog) -> ParseResult<String> {
let mut output = String::new();
for run in &sarif.runs {
if let Some(results) = &run.results {
for result in results {
if self.should_include_result(result) {
let json_line = self.result_to_json_line(&run.tool.driver.name, result)?;
output.push_str(&json_line);
output.push('\n');
}
}
}
}
Ok(output)
}
fn result_to_json_line(&self, tool_name: &str, result: &SarifResult) -> ParseResult<String> {
let mut json_obj = serde_json::Map::new();
json_obj.insert(
"tool".to_string(),
serde_json::Value::String(tool_name.to_string()),
);
json_obj.insert(
"ruleId".to_string(),
serde_json::Value::String(result.rule_id.as_deref().unwrap_or("N/A").to_string()),
);
json_obj.insert(
"level".to_string(),
serde_json::Value::String(
result
.level
.as_ref()
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Info".to_string()),
),
);
json_obj.insert(
"message".to_string(),
serde_json::Value::String(
result
.message
.text
.as_deref()
.unwrap_or("No message")
.to_string(),
),
);
if let Some(locations) = &result.locations
&& let Some(location) = locations.first()
&& let Some(physical_location) = &location.physical_location
{
if let Some(artifact_location) = &physical_location.artifact_location
&& let Some(uri) = &artifact_location.uri
{
json_obj.insert("file".to_string(), serde_json::Value::String(uri.clone()));
}
if let Some(region) = &physical_location.region {
if let Some(line) = region.start_line {
json_obj.insert(
"line".to_string(),
serde_json::Value::Number(serde_json::Number::from(line)),
);
}
if let Some(column) = region.start_column {
json_obj.insert(
"column".to_string(),
serde_json::Value::Number(serde_json::Number::from(column)),
);
}
}
}
if let Some(guid) = &result.guid {
json_obj.insert("guid".to_string(), serde_json::Value::String(guid.clone()));
}
let json_value = serde_json::Value::Object(json_obj);
Ok(serde_json::to_string(&json_value)?)
}
fn should_include_result(&self, result: &SarifResult) -> bool {
if let Some(min_level) = &self.config.min_level {
if let Some(result_level) = &result.level {
match (min_level, result_level) {
(Level::Error, Level::Error) => true,
(Level::Error, _) => false,
(Level::Warning, Level::Error | Level::Warning) => true,
(Level::Warning, _) => false,
(Level::Note, Level::Error | Level::Warning | Level::Note) => true,
(Level::Note, _) => false,
(Level::None, _) => true,
}
} else {
matches!(min_level, Level::None)
}
} else {
true
}
}
}
pub struct GitHubSecurityConverter {
config: ConversionConfig,
}
impl GitHubSecurityConverter {
pub fn new() -> Self {
Self {
config: ConversionConfig::default(),
}
}
pub fn with_config(config: ConversionConfig) -> Self {
Self { config }
}
pub fn convert_to_github_format(&self, sarif: &SarifLog) -> ParseResult<String> {
let mut advisories = Vec::new();
for run in &sarif.runs {
if let Some(results) = &run.results {
for result in results {
if self.should_include_result(result) && self.is_security_relevant(result) {
advisories
.push(self.result_to_github_advisory(&run.tool.driver.name, result));
}
}
}
}
Ok(serde_json::to_string_pretty(&advisories)?)
}
fn result_to_github_advisory(
&self,
tool_name: &str,
result: &SarifResult,
) -> serde_json::Value {
let mut advisory = serde_json::Map::new();
advisory.insert(
"id".to_string(),
serde_json::Value::String(
result
.guid
.as_deref()
.unwrap_or(&format!(
"{}_{}",
tool_name,
result.rule_id.as_deref().unwrap_or("unknown")
))
.to_string(),
),
);
advisory.insert(
"summary".to_string(),
serde_json::Value::String(
result
.message
.text
.as_deref()
.unwrap_or("Security issue detected")
.to_string(),
),
);
advisory.insert(
"severity".to_string(),
serde_json::Value::String(self.map_level_to_github_severity(result.level.as_ref())),
);
if let Some(rule_id) = &result.rule_id {
advisory.insert(
"cwe".to_string(),
serde_json::Value::String(rule_id.clone()),
);
}
if let Some(locations) = &result.locations {
let mut affected_files = Vec::new();
for location in locations {
if let Some(physical_location) = &location.physical_location
&& let Some(artifact_location) = &physical_location.artifact_location
&& let Some(uri) = &artifact_location.uri
{
affected_files.push(serde_json::Value::String(uri.clone()));
}
}
if !affected_files.is_empty() {
advisory.insert(
"affected_files".to_string(),
serde_json::Value::Array(affected_files),
);
}
}
serde_json::Value::Object(advisory)
}
fn map_level_to_github_severity(&self, level: Option<&Level>) -> String {
match level {
Some(Level::Error) => "high".to_string(),
Some(Level::Warning) => "medium".to_string(),
Some(Level::Note) => "low".to_string(),
Some(Level::None) | None => "info".to_string(),
}
}
fn is_security_relevant(&self, result: &SarifResult) -> bool {
if let Some(rule_id) = &result.rule_id {
let security_patterns = [
"security",
"vulnerability",
"injection",
"xss",
"csrf",
"auth",
"crypto",
"sql",
"nosql",
"ldap",
"xpath",
"command",
"path",
"cwe-",
"cve-",
"owasp",
];
let rule_lower = rule_id.to_lowercase();
return security_patterns
.iter()
.any(|pattern| rule_lower.contains(pattern));
}
if let Some(message) = &result.message.text {
let message_lower = message.to_lowercase();
let security_keywords = [
"security",
"vulnerability",
"exploit",
"attack",
"malicious",
"injection",
"xss",
"csrf",
"authentication",
"authorization",
];
return security_keywords
.iter()
.any(|keyword| message_lower.contains(keyword));
}
false
}
fn should_include_result(&self, result: &SarifResult) -> bool {
if let Some(min_level) = &self.config.min_level {
if let Some(result_level) = &result.level {
match (min_level, result_level) {
(Level::Error, Level::Error) => true,
(Level::Error, _) => false,
(Level::Warning, Level::Error | Level::Warning) => true,
(Level::Warning, _) => false,
(Level::Note, Level::Error | Level::Warning | Level::Note) => true,
(Level::Note, _) => false,
(Level::None, _) => true,
}
} else {
matches!(min_level, Level::None)
}
} else {
true
}
}
}
pub struct SarifConverter {
config: ConversionConfig,
}
impl SarifConverter {
pub fn new() -> Self {
Self {
config: ConversionConfig::default(),
}
}
pub fn with_config(config: ConversionConfig) -> Self {
Self { config }
}
pub fn to_csv(&self, sarif: &SarifLog) -> ParseResult<String> {
CsvConverter::with_config(self.config.clone()).convert_to_csv(sarif)
}
pub fn to_html(&self, sarif: &SarifLog) -> ParseResult<String> {
HtmlConverter::with_config(self.config.clone()).convert_to_html(sarif)
}
pub fn to_jsonl(&self, sarif: &SarifLog) -> ParseResult<String> {
JsonLinesConverter::with_config(self.config.clone()).convert_to_jsonl(sarif)
}
pub fn to_github_security(&self, sarif: &SarifLog) -> ParseResult<String> {
GitHubSecurityConverter::with_config(self.config.clone()).convert_to_github_format(sarif)
}
pub fn write_to_file<W: Write>(&self, output: &str, mut writer: W) -> ParseResult<()> {
writer.write_all(output.as_bytes())?;
writer.flush()?;
Ok(())
}
}
impl Default for CsvConverter {
fn default() -> Self {
Self::new()
}
}
impl Default for HtmlConverter {
fn default() -> Self {
Self::new()
}
}
impl Default for JsonLinesConverter {
fn default() -> Self {
Self::new()
}
}
impl Default for GitHubSecurityConverter {
fn default() -> Self {
Self::new()
}
}
impl Default for SarifConverter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builder::SarifLogBuilder;
#[test]
fn test_csv_conversion() {
let sarif = SarifLogBuilder::error_finding(
"test-tool",
"TEST001",
"Test error message",
"test.rs",
10,
5,
10,
15,
)
.build_unchecked();
let converter = CsvConverter::new();
let csv = converter.convert_to_csv(&sarif).unwrap();
assert!(csv.contains("Tool,Rule ID,Level"));
assert!(csv.contains("test-tool"));
assert!(csv.contains("TEST001"));
assert!(csv.contains("Error"));
assert!(csv.contains("test.rs"));
}
#[test]
fn test_html_conversion() {
let sarif = SarifLogBuilder::warning_finding(
"eslint",
"no-unused-vars",
"Variable 'x' is assigned but never used",
"src/app.js",
15,
5,
15,
6,
)
.build_unchecked();
let converter = HtmlConverter::new();
let html = converter.convert_to_html(&sarif).unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("SARIF Security Analysis Report"));
assert!(html.contains("eslint"));
assert!(html.contains("no-unused-vars"));
assert!(html.contains("Warning"));
}
#[test]
fn test_jsonl_conversion() {
let sarif =
SarifLogBuilder::single_error("clippy", "Unused variable detected", "main.rs", 42)
.build_unchecked();
let converter = JsonLinesConverter::new();
let jsonl = converter.convert_to_jsonl(&sarif).unwrap();
assert!(jsonl.contains("\"tool\":\"clippy\""));
assert!(jsonl.contains("\"level\":\"Error\""));
assert!(jsonl.contains("\"file\":\"main.rs\""));
}
#[test]
fn test_github_security_conversion() {
let sarif = SarifLogBuilder::error_finding(
"security-scanner",
"sql-injection",
"Potential SQL injection vulnerability detected",
"app.py",
25,
10,
25,
30,
)
.build_unchecked();
let converter = GitHubSecurityConverter::new();
let github_format = converter.convert_to_github_format(&sarif).unwrap();
assert!(github_format.contains("\"severity\": \"high\""));
assert!(github_format.contains("sql-injection"));
}
#[test]
fn test_conversion_with_filters() {
let sarif = SarifLogBuilder::new()
.add_run_with(|run_builder| {
run_builder
.add_result_with(|_result_builder| {
crate::builder::ResultBuilder::with_text_message("Error message")
.with_level(crate::types::Level::Error)
})
.add_result_with(|_result_builder| {
crate::builder::ResultBuilder::with_text_message("Warning message")
.with_level(crate::types::Level::Warning)
})
.add_result_with(|_result_builder| {
crate::builder::ResultBuilder::with_text_message("Note message")
.with_level(crate::types::Level::Note)
})
})
.build_unchecked();
let config = ConversionConfig {
min_level: Some(Level::Warning),
..Default::default()
};
let converter = CsvConverter::with_config(config);
let csv = converter.convert_to_csv(&sarif).unwrap();
assert!(csv.contains("Error"));
assert!(csv.contains("Warning"));
assert!(!csv.contains("Note"));
}
#[test]
fn test_generic_converter() {
let sarif = SarifLogBuilder::single_warning("test-tool", "Test warning", "file.rs", 1)
.build_unchecked();
let converter = SarifConverter::new();
let csv = converter.to_csv(&sarif).unwrap();
assert!(csv.contains("Warning"));
let html = converter.to_html(&sarif).unwrap();
assert!(html.contains("<!DOCTYPE html>"));
let jsonl = converter.to_jsonl(&sarif).unwrap();
assert!(jsonl.contains("\"level\":\"Warning\""));
}
}