266 lines
10 KiB
Rust
Executable File
266 lines
10 KiB
Rust
Executable File
//! Annotated HTML builder for study mode.
|
|
//!
|
|
//! Takes the original document bytes and the scan report, and produces
|
|
//! a self-contained HTML file with inline annotations around findings.
|
|
//!
|
|
//! For formats where we have structured content (PDF, EPUB, DOCX),
|
|
//! we extract text with location info and wrap findings in `<span>`.
|
|
//! For Markdown, we parse the source directly and inject spans.
|
|
|
|
use crate::core::types::{DocumentFormat, ScanReport, ThreatClassification, Finding};
|
|
|
|
/// Build the complete study-mode HTML document.
|
|
pub fn build_study_html(
|
|
raw_bytes: &[u8],
|
|
scan_report: &ScanReport,
|
|
sha256: &str,
|
|
source_path: &std::path::Path,
|
|
) -> String {
|
|
let format = scan_report.format;
|
|
|
|
// Build a mapping from location string to findings for O(1) lookup.
|
|
let mut location_findings: std::collections::HashMap<String, Vec<&Finding>> =
|
|
std::collections::HashMap::new();
|
|
for finding in &scan_report.findings {
|
|
location_findings
|
|
.entry(finding.location.to_string())
|
|
.or_default()
|
|
.push(finding);
|
|
}
|
|
|
|
let body_content = match format {
|
|
DocumentFormat::Markdown => build_markdown_study(raw_bytes, &location_findings),
|
|
_ => build_generic_study(raw_bytes, format, &location_findings),
|
|
};
|
|
|
|
let filename = source_path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("unknown");
|
|
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>CorbelPurge Study: {filename}</title>
|
|
<style>
|
|
body {{ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; max-width: 80ch; margin: 2em auto; padding: 0 1em; background: #0d0d0d; color: #e0e0e0; line-height: 1.6; }}
|
|
h1 {{ color: #d4af37; border-bottom: 2px solid #d4af37; padding-bottom: 0.5em; }}
|
|
.meta {{ color: #888; font-size: 0.85em; margin-bottom: 2em; }}
|
|
.meta dt {{ color: #aaa; }}
|
|
.meta dd {{ margin-left: 1em; margin-bottom: 0.5em; }}
|
|
.corbel-malicious {{ background: #5c1a1a; color: #ff6b6b; padding: 2px 4px; border-radius: 3px; border: 1px solid #ff6b6b; }}
|
|
.corbel-suspicious {{ background: #5c4a1a; color: #ffa94d; padding: 2px 4px; border-radius: 3px; border: 1px solid #ffa94d; }}
|
|
.corbel-educational {{ background: #1a4a2a; color: #69db7c; padding: 2px 4px; border-radius: 3px; border: 1px solid #69db7c; }}
|
|
.corbel-finding {{ margin: 0.5em 0; padding: 0.75em; border-left: 3px solid; font-size: 0.9em; }}
|
|
.corbel-finding.malicious {{ border-color: #ff6b6b; background: rgba(255,107,107,0.05); }}
|
|
.corbel-finding.suspicious {{ border-color: #ffa94d; background: rgba(255,169,77,0.05); }}
|
|
.corbel-finding.educational {{ border-color: #69db7c; background: rgba(105,219,124,0.05); }}
|
|
.finding-label {{ font-weight: bold; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; }}
|
|
.finding-label.malicious {{ color: #ff6b6b; }}
|
|
.finding-label.suspicious {{ color: #ffa94d; }}
|
|
.finding-label.educational {{ color: #69db7c; }}
|
|
.finding-location {{ color: #888; font-size: 0.8em; }}
|
|
.finding-notes {{ color: #aaa; font-size: 0.8em; margin-top: 0.25em; }}
|
|
.finding-payload {{ background: #1a1a1a; padding: 0.5em; border-radius: 3px; font-size: 0.8em; white-space: pre-wrap; overflow-x: auto; max-height: 200px; overflow-y: auto; margin-top: 0.5em; }}
|
|
.summary {{ background: #1a1a1a; padding: 1em; border-radius: 5px; margin-bottom: 2em; }}
|
|
.summary .stat {{ display: inline-block; margin-right: 2em; }}
|
|
.summary .stat-label {{ color: #888; }}
|
|
.summary .stat-value {{ color: #e0e0e0; font-weight: bold; }}
|
|
.line-num {{ color: #555; user-select: none; display: inline-block; width: 4ch; text-align: right; margin-right: 1em; }}
|
|
pre {{ background: #1a1a1a; padding: 1em; border-radius: 5px; overflow-x: auto; }}
|
|
code {{ font-family: inherit; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>CorbelPurge Study Mode</h1>
|
|
<div class="meta">
|
|
<dl>
|
|
<dt>File</dt><dd>{filename}</dd>
|
|
<dt>SHA-256</dt><dd><code>{sha256}</code></dd>
|
|
<dt>Format</dt><dd>{format}</dd>
|
|
<dt>Text nodes</dt><dd>{text_nodes}</dd>
|
|
<dt>Vectors</dt><dd>{vectors}</dd>
|
|
<dt>Findings</dt><dd>{total}</dd>
|
|
</dl>
|
|
</div>
|
|
<div class="summary">
|
|
<span class="stat"><span class="stat-label">Malicious:</span> <span class="stat-value">{malicious}</span></span>
|
|
<span class="stat"><span class="stat-label">Suspicious:</span> <span class="stat-value">{suspicious}</span></span>
|
|
<span class="stat"><span class="stat-label">Educational:</span> <span class="stat-value">{educational}</span></span>
|
|
</div>
|
|
{body_content}
|
|
</body>
|
|
</html>"#,
|
|
filename = html_escape(filename),
|
|
sha256 = sha256,
|
|
format = format,
|
|
text_nodes = scan_report.text_nodes_scanned,
|
|
vectors = scan_report.vectors_scanned,
|
|
total = scan_report.findings.len(),
|
|
malicious = scan_report.malicious_count(),
|
|
suspicious = scan_report.findings.iter().filter(|f| matches!(f.classification, ThreatClassification::Suspicious)).count(),
|
|
educational = scan_report.educational_count(),
|
|
body_content = body_content,
|
|
)
|
|
}
|
|
|
|
/// Build study HTML for Markdown source files.
|
|
///
|
|
/// Parses the Markdown line-by-line and wraps matching content in spans.
|
|
fn build_markdown_study(
|
|
raw: &[u8],
|
|
location_findings: &std::collections::HashMap<String, Vec<&Finding>>,
|
|
) -> String {
|
|
let text = String::from_utf8_lossy(raw);
|
|
let mut out = String::new();
|
|
out.push_str("<pre><code>\n");
|
|
|
|
for (idx, line) in text.lines().enumerate() {
|
|
let line_num = idx + 1;
|
|
let location_key = format!("md:{}:0", line_num);
|
|
|
|
let mut line_html = html_escape(line);
|
|
|
|
if let Some(findings) = location_findings.get(&location_key) {
|
|
for finding in findings {
|
|
let class = classification_class(&finding.classification);
|
|
line_html = format!(
|
|
"<span class=\"corbel-{}\">{}<span class=\"finding-label\"> {}</span></span>",
|
|
class, line_html, classification_label(&finding.classification)
|
|
);
|
|
}
|
|
}
|
|
|
|
out.push_str(&format!(
|
|
"<span class=\"line-num\">{line_num:>4}</span>{}\n",
|
|
line_html
|
|
));
|
|
}
|
|
|
|
out.push_str("</code></pre>\n");
|
|
out
|
|
}
|
|
|
|
/// Build study HTML for non-Markdown formats (PDF, EPUB, DOCX).
|
|
///
|
|
/// Since we can't reliably reconstruct the original rendering, we show
|
|
/// the findings in a structured list with their payload previews.
|
|
///
|
|
/// The `raw` bytes and `format` are accepted for symmetry with
|
|
/// [`build_markdown_study`] and for future per-format rendering hooks,
|
|
/// but are not currently consumed by this implementation.
|
|
#[allow(unused_variables)]
|
|
fn build_generic_study(
|
|
raw: &[u8],
|
|
format: DocumentFormat,
|
|
location_findings: &std::collections::HashMap<String, Vec<&Finding>>,
|
|
) -> String {
|
|
let mut out = String::new();
|
|
|
|
// Group findings by location for ordered display.
|
|
let mut seen = std::collections::HashSet::new();
|
|
for finding in location_findings
|
|
.values()
|
|
.flatten()
|
|
.collect::<Vec<_>>()
|
|
{
|
|
if !seen.insert(finding.location.to_string()) {
|
|
continue;
|
|
}
|
|
let class = classification_class(&finding.classification);
|
|
out.push_str(&format!(
|
|
"<div class=\"corbel-finding {}\">\n",
|
|
class
|
|
));
|
|
out.push_str(&format!(
|
|
" <div class=\"finding-label {}\">{}</div>\n",
|
|
class,
|
|
classification_label(&finding.classification)
|
|
));
|
|
out.push_str(&format!(
|
|
" <div class=\"finding-location\">{}</div>\n",
|
|
finding.location
|
|
));
|
|
out.push_str(&format!(
|
|
" <div class=\"finding-notes\">{}</div>\n",
|
|
html_escape(&finding.context_notes)
|
|
));
|
|
if !finding.payload_preview.is_empty() {
|
|
out.push_str(&format!(
|
|
" <div class=\"finding-payload\">{}</div>\n",
|
|
html_escape(&finding.payload_preview)
|
|
));
|
|
}
|
|
out.push_str("</div>\n");
|
|
}
|
|
|
|
// If no findings, show raw content in a code block.
|
|
if location_findings.is_empty() {
|
|
out.push_str("<p><em>No findings. Document passed all checks.</em></p>\n");
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn classification_class(c: &ThreatClassification) -> &'static str {
|
|
match c {
|
|
ThreatClassification::Benign => "benign",
|
|
ThreatClassification::Suspicious => "suspicious",
|
|
ThreatClassification::EducationalContent => "educational",
|
|
ThreatClassification::Malicious(_) => "malicious",
|
|
}
|
|
}
|
|
|
|
fn classification_label(c: &ThreatClassification) -> &'static str {
|
|
match c {
|
|
ThreatClassification::Benign => "benign",
|
|
ThreatClassification::Suspicious => "suspicious",
|
|
ThreatClassification::EducationalContent => "educational",
|
|
ThreatClassification::Malicious(_) => "malicious",
|
|
}
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn html_escape_basic() {
|
|
assert_eq!(html_escape("<script>alert('xss')</script>"), "<script>alert('xss')</script>");
|
|
}
|
|
|
|
#[test]
|
|
fn build_study_html_has_structure() {
|
|
use crate::core::types::*;
|
|
let report = ScanReport {
|
|
source_sha256: "abc".to_string(),
|
|
format: DocumentFormat::Markdown,
|
|
scanned_at: "x".to_string(),
|
|
findings: vec![Finding {
|
|
classification: ThreatClassification::Malicious(MaliciousType::ActiveJavaScriptInjection),
|
|
location: Location::MarkdownLine { line: 3, col: 0 },
|
|
vector_type: None,
|
|
payload_preview: "eval(1)".to_string(),
|
|
context_notes: "found evil".to_string(),
|
|
recommendation: Recommendation::QuarantineAndCleanse,
|
|
}],
|
|
text_nodes_scanned: 5,
|
|
vectors_scanned: 1,
|
|
};
|
|
let html = build_study_html(b"hello\nworld\nevil", &report, "abc123", std::path::Path::new("test.md"));
|
|
assert!(html.contains("<span class=\"corbel-malicious\">"));
|
|
assert!(html.contains("<span class=\"line-num\"> 3</span>"));
|
|
assert!(html.contains("CorbelPurge Study Mode"));
|
|
assert!(html.contains("Malicious:"));
|
|
}
|
|
} |