All checks were successful
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
523 lines
19 KiB
Rust
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>{} — {}</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("<script>alert(1)</script>"),
|
|
"payload should be HTML-escaped"
|
|
);
|
|
}
|
|
}
|