corbel/src/study/annotator.rs

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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_escape_basic() {
assert_eq!(html_escape("<script>alert('xss')</script>"), "&lt;script&gt;alert('xss')&lt;/script&gt;");
}
#[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:"));
}
}