Files
compliance-scanner-agent/compliance-agent/src/pentest/report/html/findings.rs
Sharang Parnerkar c461faa2fb
All checks were successful
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 7s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Successful in 2s
CI / Deploy MCP (push) Successful in 2s
feat: pentest onboarding — streaming, browser automation, reports, user cleanup (#16)
Complete pentest feature overhaul: SSE streaming, session-persistent browser tool (CDP), AES-256 credential encryption, auto-screenshots in reports, code-level remediation correlation, SAST triage chunking, context window optimization, test user cleanup (Keycloak/Auth0/Okta), wizard dropdowns, attack chain improvements, architecture docs with Mermaid diagrams.

Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com>
Reviewed-on: #16
2026-03-17 20:32:20 +00:00

523 lines
19 KiB
Rust

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 ═══════════════ -->
<div class="page-break"></div>
<h2><span class="section-num">3.</span> Findings</h2>
<p style="color: var(--text-muted);">No vulnerabilities were identified during this assessment.</p>"#.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<String, &Finding> = 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#"<h4 class="sev-group-title" style="border-color: {color}">{label} ({count})</h4>"#,
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#"<span class="badge badge-exploit">EXPLOITABLE</span>"#
} else {
""
};
let cwe_cell = f
.cwe
.as_deref()
.map(|c| format!("<tr><td>CWE</td><td>{}</td></tr>", html_escape(c)))
.unwrap_or_default();
let param_row = f
.parameter
.as_deref()
.map(|p| {
format!(
"<tr><td>Parameter</td><td><code>{}</code></td></tr>",
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#"
<div class="finding" style="border-left-color: {sev_color}">
<div class="finding-header">
<span class="finding-id">F-{num:03}</span>
<span class="finding-title">{title}</span>
{exploitable_badge}
</div>
<table class="finding-meta">
<tr><td>Type</td><td>{vuln_type}</td></tr>
<tr><td>Endpoint</td><td><code>{method} {endpoint}</code></td></tr>
{param_row}
{cwe_cell}
</table>
<div class="finding-desc">{description}</div>
{evidence_html}
{code_correlation}
<div class="remediation">
<div class="remediation-label">Recommendation</div>
{remediation}
</div>
</div>
"#,
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 ═══════════════ -->
<div class="page-break"></div>
<h2><span class="section-num">3.</span> Findings</h2>
{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#"<div class="evidence-block"><div class="evidence-title">Evidence</div><table class="evidence-table"><thead><tr><th>Request</th><th>Status</th><th>Details</th></tr></thead><tbody>"#,
);
for ev in &f.evidence {
let payload_info = ev
.payload
.as_deref()
.map(|p| {
format!(
"<br><span class=\"evidence-payload\">Payload: <code>{}</code></span>",
html_escape(p)
)
})
.unwrap_or_default();
eh.push_str(&format!(
"<tr><td><code>{} {}</code></td><td>{}</td><td>{}{}</td></tr>",
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("</tbody></table></div>");
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<String, &Finding>,
code_context: &[CodeContextHint],
sbom_entries: &[SbomEntry],
) -> String {
let mut sections: Vec<String> = 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#"<div class="code-correlation-item">"#);
s.push_str(r#"<div class="code-correlation-badge">SAST Correlation</div>"#);
s.push_str("<table class=\"code-meta\">");
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!(
"<tr><td>Location</td><td><code>{}{}</code></td></tr>",
html_escape(fp),
line_info,
));
}
s.push_str(&format!(
"<tr><td>Scanner</td><td>{} &mdash; {}</td></tr>",
html_escape(&sast.scanner),
html_escape(&sast.title),
));
if let Some(ref cwe) = sast.cwe {
s.push_str(&format!(
"<tr><td>CWE</td><td>{}</td></tr>",
html_escape(cwe)
));
}
if let Some(ref rule) = sast.rule_id {
s.push_str(&format!(
"<tr><td>Rule</td><td><code>{}</code></td></tr>",
html_escape(rule)
));
}
s.push_str("</table>");
// Code snippet
if let Some(ref snippet) = sast.code_snippet {
if !snippet.is_empty() {
s.push_str(&format!(
"<div class=\"code-snippet-block\"><div class=\"code-snippet-label\">Vulnerable Code</div><pre class=\"code-snippet\">{}</pre></div>",
html_escape(snippet)
));
}
}
// Suggested fix
if let Some(ref fix) = sast.suggested_fix {
if !fix.is_empty() {
s.push_str(&format!(
"<div class=\"code-fix-block\"><div class=\"code-fix-label\">Suggested Fix</div><pre class=\"code-fix\">{}</pre></div>",
html_escape(fix)
));
}
}
// Remediation from SAST
if let Some(ref rem) = sast.remediation {
if !rem.is_empty() {
s.push_str(&format!(
"<div class=\"code-remediation\">{}</div>",
html_escape(rem)
));
}
}
s.push_str("</div>");
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#"<div class="code-correlation-item">"#);
s.push_str(r#"<div class="code-correlation-badge">Code Entry Point</div>"#);
s.push_str("<table class=\"code-meta\">");
s.push_str(&format!(
"<tr><td>Handler</td><td><code>{}</code></td></tr>",
html_escape(&hint.handler_function),
));
s.push_str(&format!(
"<tr><td>File</td><td><code>{}</code></td></tr>",
html_escape(&hint.file_path),
));
s.push_str(&format!(
"<tr><td>Route</td><td><code>{}</code></td></tr>",
html_escape(&hint.endpoint_pattern),
));
s.push_str("</table>");
if !hint.known_vulnerabilities.is_empty() {
s.push_str("<div class=\"code-linked-vulns\"><strong>Known SAST issues in this file:</strong><ul>");
for vuln in &hint.known_vulnerabilities {
s.push_str(&format!("<li>{}</li>", html_escape(vuln)));
}
s.push_str("</ul></div>");
}
s.push_str("</div>");
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#"<div class="code-correlation-item">"#);
s.push_str(r#"<div class="code-correlation-badge">Vulnerable Dependency</div>"#);
s.push_str("<table class=\"code-meta\">");
s.push_str(&format!(
"<tr><td>Package</td><td><code>{} {}</code> ({})</td></tr>",
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!(
"<tr><td>CVEs</td><td>{}</td></tr>",
cve_ids.join(", "),
));
if let Some(ref purl) = dep.purl {
s.push_str(&format!(
"<tr><td>PURL</td><td><code>{}</code></td></tr>",
html_escape(purl),
));
}
s.push_str("</table>");
s.push_str(&format!(
"<div class=\"code-remediation\">Upgrade <code>{}</code> to the latest patched version to resolve {}.</div>",
html_escape(&dep.name),
html_escape(cve_id),
));
s.push_str("</div>");
sections.push(s);
}
}
if sections.is_empty() {
return String::new();
}
format!(
r#"<div class="code-correlation">
<div class="code-correlation-title">Code-Level Remediation</div>
{}
</div>"#,
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("<script>alert(1)</script>".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("&lt;script&gt;alert(1)&lt;/script&gt;"),
"payload should be HTML-escaped"
);
}
}