use super::html_escape; use compliance_core::models::dast::DastFinding; use compliance_core::models::finding::Finding; use compliance_core::models::pentest::CodeContextHint; use compliance_core::models::sbom::SbomEntry; /// Render the findings section with code-level correlation. /// /// For each DAST finding, if a linked SAST finding exists (via `linked_sast_finding_id`) /// or if we can match the endpoint to a code entry point, we render a "Code-Level /// Remediation" block showing the exact file, line, code snippet, and suggested fix. pub(super) fn findings( findings_list: &[DastFinding], sast_findings: &[Finding], code_context: &[CodeContextHint], sbom_entries: &[SbomEntry], ) -> String { if findings_list.is_empty() { return r#"

3. Findings

No vulnerabilities were identified during this assessment.

"#.to_string(); } let severity_order = ["critical", "high", "medium", "low", "info"]; let severity_labels = ["Critical", "High", "Medium", "Low", "Informational"]; let severity_colors = ["#991b1b", "#c2410c", "#a16207", "#1d4ed8", "#4b5563"]; // Build SAST lookup by ObjectId hex string let sast_by_id: std::collections::HashMap = sast_findings .iter() .filter_map(|f| { let id = f.id.as_ref()?.to_hex(); Some((id, f)) }) .collect(); let mut findings_html = String::new(); let mut finding_num = 0usize; for (si, &sev_key) in severity_order.iter().enumerate() { let sev_findings: Vec<&DastFinding> = findings_list .iter() .filter(|f| f.severity.to_string() == sev_key) .collect(); if sev_findings.is_empty() { continue; } findings_html.push_str(&format!( r#"

{label} ({count})

"#, color = severity_colors[si], label = severity_labels[si], count = sev_findings.len(), )); for f in sev_findings { finding_num += 1; let sev_color = severity_colors[si]; let exploitable_badge = if f.exploitable { r#"EXPLOITABLE"# } else { "" }; let cwe_cell = f .cwe .as_deref() .map(|c| format!("CWE{}", html_escape(c))) .unwrap_or_default(); let param_row = f .parameter .as_deref() .map(|p| { format!( "Parameter{}", html_escape(p) ) }) .unwrap_or_default(); let remediation = f .remediation .as_deref() .unwrap_or("Refer to industry best practices for this vulnerability class."); let evidence_html = build_evidence_html(f); // ── Code-level correlation ────────────────────────────── let code_correlation = build_code_correlation(f, &sast_by_id, code_context, sbom_entries); findings_html.push_str(&format!( r#"
F-{num:03} {title} {exploitable_badge}
{param_row} {cwe_cell}
Type{vuln_type}
Endpoint{method} {endpoint}
{description}
{evidence_html} {code_correlation}
Recommendation
{remediation}
"#, num = finding_num, title = html_escape(&f.title), vuln_type = f.vuln_type, method = f.method, endpoint = html_escape(&f.endpoint), description = html_escape(&f.description), )); } } format!( r##"

3. Findings

{findings_html}"## ) } /// Build the evidence table HTML for a finding. fn build_evidence_html(f: &DastFinding) -> String { if f.evidence.is_empty() { return String::new(); } let mut eh = String::from( r#"
Evidence
"#, ); for ev in &f.evidence { let payload_info = ev .payload .as_deref() .map(|p| { format!( "
Payload: {}", html_escape(p) ) }) .unwrap_or_default(); eh.push_str(&format!( "", html_escape(&ev.request_method), html_escape(&ev.request_url), ev.response_status, ev.response_snippet .as_deref() .map(html_escape) .unwrap_or_default(), payload_info, )); } eh.push_str("
RequestStatusDetails
{} {}{}{}{}
"); eh } /// Build the code-level correlation block for a DAST finding. /// /// Attempts correlation in priority order: /// 1. Direct link via `linked_sast_finding_id` → shows exact file, line, snippet, suggested fix /// 2. Endpoint match via code context → shows handler function, file, known SAST vulns /// 3. CWE/CVE match to SBOM → shows vulnerable dependency + version to upgrade fn build_code_correlation( dast_finding: &DastFinding, sast_by_id: &std::collections::HashMap, code_context: &[CodeContextHint], sbom_entries: &[SbomEntry], ) -> String { let mut sections: Vec = Vec::new(); // 1. Direct SAST link if let Some(ref sast_id) = dast_finding.linked_sast_finding_id { if let Some(sast) = sast_by_id.get(sast_id) { let mut s = String::new(); s.push_str(r#"
"#); s.push_str(r#"
SAST Correlation
"#); s.push_str(""); if let Some(ref fp) = sast.file_path { let line_info = sast .line_number .map(|l| format!(":{l}")) .unwrap_or_default(); s.push_str(&format!( "", html_escape(fp), line_info, )); } s.push_str(&format!( "", html_escape(&sast.scanner), html_escape(&sast.title), )); if let Some(ref cwe) = sast.cwe { s.push_str(&format!( "", html_escape(cwe) )); } if let Some(ref rule) = sast.rule_id { s.push_str(&format!( "", html_escape(rule) )); } s.push_str("
Location{}{}
Scanner{} — {}
CWE{}
Rule{}
"); // Code snippet if let Some(ref snippet) = sast.code_snippet { if !snippet.is_empty() { s.push_str(&format!( "
Vulnerable Code
{}
", html_escape(snippet) )); } } // Suggested fix if let Some(ref fix) = sast.suggested_fix { if !fix.is_empty() { s.push_str(&format!( "
Suggested Fix
{}
", html_escape(fix) )); } } // Remediation from SAST if let Some(ref rem) = sast.remediation { if !rem.is_empty() { s.push_str(&format!( "
{}
", html_escape(rem) )); } } s.push_str("
"); sections.push(s); } } // 2. Endpoint match via code context let endpoint_lower = dast_finding.endpoint.to_lowercase(); let matching_hints: Vec<&CodeContextHint> = code_context .iter() .filter(|hint| { // Match by endpoint pattern overlap let pattern_lower = hint.endpoint_pattern.to_lowercase(); endpoint_lower.contains(&pattern_lower) || pattern_lower.contains(&endpoint_lower) || hint.file_path.to_lowercase().contains( &endpoint_lower .split('/') .next_back() .unwrap_or("") .replace(".html", "") .replace(".php", ""), ) }) .collect(); for hint in &matching_hints { let mut s = String::new(); s.push_str(r#"
"#); s.push_str(r#"
Code Entry Point
"#); s.push_str(""); s.push_str(&format!( "", html_escape(&hint.handler_function), )); s.push_str(&format!( "", html_escape(&hint.file_path), )); s.push_str(&format!( "", html_escape(&hint.endpoint_pattern), )); s.push_str("
Handler{}
File{}
Route{}
"); if !hint.known_vulnerabilities.is_empty() { s.push_str("
Known SAST issues in this file:
    "); for vuln in &hint.known_vulnerabilities { s.push_str(&format!("
  • {}
  • ", html_escape(vuln))); } s.push_str("
"); } s.push_str("
"); sections.push(s); } // 3. SBOM match — if a linked SAST finding has a CVE, or we can match by CWE let linked_cve = dast_finding .linked_sast_finding_id .as_deref() .and_then(|id| sast_by_id.get(id)) .and_then(|f| f.cve.as_deref()); if let Some(cve_id) = linked_cve { let matching_deps: Vec<&SbomEntry> = sbom_entries .iter() .filter(|e| e.known_vulnerabilities.iter().any(|v| v.id == cve_id)) .collect(); for dep in &matching_deps { let mut s = String::new(); s.push_str(r#"
"#); s.push_str(r#"
Vulnerable Dependency
"#); s.push_str(""); s.push_str(&format!( "", html_escape(&dep.name), html_escape(&dep.version), html_escape(&dep.package_manager), )); let cve_ids: Vec<&str> = dep .known_vulnerabilities .iter() .map(|v| v.id.as_str()) .collect(); s.push_str(&format!( "", cve_ids.join(", "), )); if let Some(ref purl) = dep.purl { s.push_str(&format!( "", html_escape(purl), )); } s.push_str("
Package{} {} ({})
CVEs{}
PURL{}
"); s.push_str(&format!( "
Upgrade {} to the latest patched version to resolve {}.
", html_escape(&dep.name), html_escape(cve_id), )); s.push_str("
"); sections.push(s); } } if sections.is_empty() { return String::new(); } format!( r#"
Code-Level Remediation
{}
"#, sections.join("\n") ) } #[cfg(test)] mod tests { use super::*; use compliance_core::models::dast::{DastEvidence, DastVulnType}; use compliance_core::models::finding::Severity; use compliance_core::models::scan::ScanType; /// Helper: create a minimal `DastFinding`. fn make_dast(title: &str, severity: Severity, endpoint: &str) -> DastFinding { DastFinding::new( "run1".into(), "target1".into(), DastVulnType::Xss, title.into(), "desc".into(), severity, endpoint.into(), "GET".into(), ) } /// Helper: create a minimal SAST `Finding` with an ObjectId. fn make_sast(title: &str) -> Finding { let mut f = Finding::new( "repo1".into(), "fp1".into(), "semgrep".into(), ScanType::Sast, title.into(), "sast desc".into(), Severity::High, ); f.id = Some(mongodb::bson::oid::ObjectId::new()); f } #[test] fn test_findings_empty() { let result = findings(&[], &[], &[], &[]); assert!( result.contains("No vulnerabilities were identified"), "Empty findings should contain the no-vulns message" ); } #[test] fn test_findings_grouped_by_severity() { let f_high = make_dast("High vuln", Severity::High, "/a"); let f_low = make_dast("Low vuln", Severity::Low, "/b"); let f_critical = make_dast("Crit vuln", Severity::Critical, "/c"); let result = findings(&[f_high, f_low, f_critical], &[], &[], &[]); // All severity group headers should appear assert!( result.contains("Critical (1)"), "should have Critical header" ); assert!(result.contains("High (1)"), "should have High header"); assert!(result.contains("Low (1)"), "should have Low header"); // Critical should appear before High, High before Low let crit_pos = result.find("Critical (1)"); let high_pos = result.find("High (1)"); let low_pos = result.find("Low (1)"); assert!(crit_pos < high_pos, "Critical should come before High"); assert!(high_pos < low_pos, "High should come before Low"); } #[test] fn test_code_correlation_sast_link() { let mut sast = make_sast("SQL Injection in query"); sast.file_path = Some("src/db/query.rs".into()); sast.line_number = Some(42); sast.code_snippet = Some("let q = format!(\"SELECT * FROM {} WHERE id={}\", table, id);".into()); let sast_id = sast.id.as_ref().map(|oid| oid.to_hex()).unwrap_or_default(); let mut dast = make_dast("SQLi on /api/users", Severity::High, "/api/users"); dast.linked_sast_finding_id = Some(sast_id); let result = findings(&[dast], &[sast], &[], &[]); assert!( result.contains("SAST Correlation"), "should render SAST Correlation badge" ); assert!( result.contains("src/db/query.rs"), "should contain the file path" ); assert!(result.contains(":42"), "should contain the line number"); assert!( result.contains("Vulnerable Code"), "should render code snippet block" ); } #[test] fn test_code_correlation_no_match() { let dast = make_dast("XSS in search", Severity::Medium, "/search"); // No linked_sast_finding_id, no code context, no sbom let result = findings(&[dast], &[], &[], &[]); assert!( !result.contains("code-correlation"), "should not contain any code-correlation div" ); } #[test] fn test_evidence_html_empty() { let f = make_dast("No evidence", Severity::Low, "/x"); let result = build_evidence_html(&f); assert!(result.is_empty(), "no evidence should yield empty string"); } #[test] fn test_evidence_html_with_entries() { let mut f = make_dast("Has evidence", Severity::High, "/y"); f.evidence.push(DastEvidence { request_method: "POST".into(), request_url: "https://example.com/login".into(), request_headers: None, request_body: None, response_status: 200, response_headers: None, response_snippet: Some("OK".into()), screenshot_path: None, payload: Some("".into()), response_time_ms: None, }); let result = build_evidence_html(&f); assert!( result.contains("evidence-table"), "should render the evidence table" ); assert!(result.contains("POST"), "should contain request method"); assert!( result.contains("https://example.com/login"), "should contain request URL" ); assert!(result.contains("200"), "should contain response status"); assert!( result.contains("<script>alert(1)</script>"), "payload should be HTML-escaped" ); } }