@neurolint/cli
Version:
NeuroLint CLI - Deterministic code fixing for TypeScript, JavaScript, React, and Next.js with 8-layer architecture including Security Forensics, Next.js 16, React Compiler, and Turbopack support
779 lines (682 loc) • 23.1 kB
JavaScript
/**
* Copyright (c) 2025 NeuroLint
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Layer 8: Security Forensics - HTML Reporter
*
* Generates standalone HTML reports with executive summary, findings,
* timeline, and remediation checklist. Uses embedded CSS for portability.
*
* IMPORTANT: Layer 8 is READ-ONLY by default. It detects but does not transform
* unless explicitly requested (quarantine mode). This follows the NeuroLint
* principle of "never break code".
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { LAYER_8_VERSION, SEVERITY_LEVELS, IOC_CATEGORIES } = require('../constants');
const SeverityCalculator = require('../utils/severity-calculator');
class HTMLReporter {
constructor(options = {}) {
this.includeContext = options.includeContext !== false;
this.includeTimeline = options.includeTimeline !== false;
this.includeRemediation = options.includeRemediation !== false;
this.companyName = options.companyName || 'Security Team';
this.reportTitle = options.reportTitle || 'Security Forensics Report';
}
generateReport(scanResult, options = {}) {
const severity = SeverityCalculator.calculateOverallSeverity(scanResult.findings || []);
const timestamp = new Date().toISOString();
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${this.escapeHtml(this.reportTitle)} - ${timestamp.split('T')[0]}</title>
<style>
${this.generateCSS()}
</style>
</head>
<body>
<div class="container">
${this.generateHeader(scanResult, severity, timestamp)}
${this.generateExecutiveSummary(scanResult, severity)}
${this.generateSeverityBreakdown(severity)}
${this.generateFindingsSection(scanResult.findings || [])}
${this.includeTimeline ? this.generateTimelineSection(scanResult) : ''}
${this.includeRemediation ? this.generateRemediationChecklist(scanResult.findings || []) : ''}
${this.generateFooter(timestamp)}
</div>
<script>
${this.generateJS()}
</script>
</body>
</html>`;
}
generateCSS() {
return `
:root {
--color-critical: #dc2626;
--color-high: #ea580c;
--color-medium: #ca8a04;
--color-low: #2563eb;
--color-info: #6b7280;
--color-clean: #16a34a;
--color-bg: #f9fafb;
--color-card: #ffffff;
--color-border: #e5e7eb;
--color-text: #1f2937;
--color-text-secondary: #6b7280;
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
color: white;
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
header h1 {
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
header .subtitle {
opacity: 0.8;
font-size: 0.875rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-weight: 600;
font-size: 0.875rem;
margin-top: 1rem;
}
.status-critical { background: var(--color-critical); }
.status-high { background: var(--color-high); }
.status-medium { background: var(--color-medium); }
.status-low { background: var(--color-low); }
.status-clean { background: var(--color-clean); }
.card {
background: var(--color-card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid var(--color-border);
}
.card h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card h2::before {
content: '';
width: 4px;
height: 1.25rem;
background: #3b82f6;
border-radius: 2px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background: var(--color-bg);
border-radius: 8px;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
letter-spacing: 0.05em;
}
.severity-bar {
display: flex;
height: 12px;
border-radius: 6px;
overflow: hidden;
margin: 1rem 0;
}
.severity-segment {
transition: width 0.3s ease;
}
.severity-legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.finding {
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 1rem;
overflow: hidden;
}
.finding-header {
padding: 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-bg);
transition: background 0.2s;
}
.finding-header:hover {
background: #f3f4f6;
}
.finding-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.severity-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
.severity-critical .severity-indicator { background: var(--color-critical); }
.severity-high .severity-indicator { background: var(--color-high); }
.severity-medium .severity-indicator { background: var(--color-medium); }
.severity-low .severity-indicator { background: var(--color-low); }
.severity-info .severity-indicator { background: var(--color-info); }
.finding-body {
padding: 1rem;
display: none;
border-top: 1px solid var(--color-border);
}
.finding.open .finding-body {
display: block;
}
.finding-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.meta-item {
font-size: 0.875rem;
}
.meta-label {
color: var(--color-text-secondary);
font-weight: 500;
}
.code-block {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
font-family: var(--font-mono);
font-size: 0.875rem;
overflow-x: auto;
margin: 1rem 0;
}
.code-line {
display: flex;
}
.line-number {
color: #64748b;
min-width: 3rem;
text-align: right;
padding-right: 1rem;
user-select: none;
}
.line-content {
flex: 1;
}
.line-highlight {
background: rgba(239, 68, 68, 0.2);
margin: 0 -1rem;
padding: 0 1rem;
}
.remediation-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: var(--color-bg);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.checkbox {
width: 20px;
height: 20px;
border: 2px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.checkbox.checked {
background: var(--color-clean);
border-color: var(--color-clean);
color: white;
}
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: '';
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 2px;
background: var(--color-border);
}
.timeline-item {
position: relative;
padding-bottom: 1.5rem;
}
.timeline-dot {
position: absolute;
left: -2rem;
top: 0.25rem;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--color-card);
border: 2px solid #3b82f6;
}
.timeline-content {
background: var(--color-bg);
padding: 1rem;
border-radius: 8px;
}
.timeline-time {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
footer {
text-align: center;
padding: 2rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.expand-icon {
transition: transform 0.2s;
}
.finding.open .expand-icon {
transform: rotate(180deg);
}
@media print {
.container {
max-width: none;
padding: 0;
}
.finding-body {
display: block !important;
}
.card {
break-inside: avoid;
}
}
@media (max-width: 640px) {
.container {
padding: 1rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}`;
}
generateJS() {
return `
document.querySelectorAll('.finding-header').forEach(header => {
header.addEventListener('click', () => {
header.parentElement.classList.toggle('open');
});
});
document.querySelectorAll('.checkbox').forEach(checkbox => {
checkbox.addEventListener('click', () => {
checkbox.classList.toggle('checked');
if (checkbox.classList.contains('checked')) {
checkbox.innerHTML = '✓';
} else {
checkbox.innerHTML = '';
}
});
});`;
}
generateHeader(scanResult, severity, timestamp) {
const statusClass = severity.breakdown.critical > 0 ? 'critical' :
severity.breakdown.high > 0 ? 'high' :
severity.breakdown.medium > 0 ? 'medium' :
severity.breakdown.low > 0 ? 'low' : 'clean';
const statusText = {
critical: 'CRITICAL - Immediate Action Required',
high: 'HIGH RISK - Review Promptly',
medium: 'MEDIUM RISK - Issues Detected',
low: 'LOW RISK - Minor Issues',
clean: 'CLEAN - No Issues Detected'
}[statusClass];
return `
<header>
<h1>${this.escapeHtml(this.reportTitle)}</h1>
<p class="subtitle">Generated by NeuroLint Layer 8 Security Forensics v${LAYER_8_VERSION}</p>
<p class="subtitle">${this.escapeHtml(this.companyName)} | ${new Date(timestamp).toLocaleString()}</p>
<span class="status-badge status-${statusClass}">
<span>${statusText}</span>
</span>
</header>`;
}
generateExecutiveSummary(scanResult, severity) {
const stats = scanResult.stats || {};
return `
<div class="card">
<h2>Executive Summary</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">${scanResult.findings?.length || 0}</div>
<div class="stat-label">Total Findings</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: var(--color-critical)">${severity.breakdown.critical}</div>
<div class="stat-label">Critical</div>
</div>
<div class="stat-item">
<div class="stat-value" style="color: var(--color-high)">${severity.breakdown.high}</div>
<div class="stat-label">High</div>
</div>
<div class="stat-item">
<div class="stat-value">${stats.filesScanned || 0}</div>
<div class="stat-label">Files Scanned</div>
</div>
<div class="stat-item">
<div class="stat-value">${this.formatDuration(stats.totalExecutionTime)}</div>
<div class="stat-label">Scan Duration</div>
</div>
<div class="stat-item">
<div class="stat-value">${severity.score}</div>
<div class="stat-label">Risk Score</div>
</div>
</div>
</div>`;
}
generateSeverityBreakdown(severity) {
const total = severity.findingsCount || 1;
const criticalPct = (severity.breakdown.critical / total * 100).toFixed(1);
const highPct = (severity.breakdown.high / total * 100).toFixed(1);
const mediumPct = (severity.breakdown.medium / total * 100).toFixed(1);
const lowPct = (severity.breakdown.low / total * 100).toFixed(1);
const infoPct = (severity.breakdown.info / total * 100).toFixed(1);
return `
<div class="card">
<h2>Severity Distribution</h2>
<div class="severity-bar">
<div class="severity-segment" style="width: ${criticalPct}%; background: var(--color-critical)"></div>
<div class="severity-segment" style="width: ${highPct}%; background: var(--color-high)"></div>
<div class="severity-segment" style="width: ${mediumPct}%; background: var(--color-medium)"></div>
<div class="severity-segment" style="width: ${lowPct}%; background: var(--color-low)"></div>
<div class="severity-segment" style="width: ${infoPct}%; background: var(--color-info)"></div>
</div>
<div class="severity-legend">
<div class="legend-item">
<span class="legend-dot" style="background: var(--color-critical)"></span>
<span>Critical (${severity.breakdown.critical})</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: var(--color-high)"></span>
<span>High (${severity.breakdown.high})</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: var(--color-medium)"></span>
<span>Medium (${severity.breakdown.medium})</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: var(--color-low)"></span>
<span>Low (${severity.breakdown.low})</span>
</div>
<div class="legend-item">
<span class="legend-dot" style="background: var(--color-info)"></span>
<span>Info (${severity.breakdown.info})</span>
</div>
</div>
</div>`;
}
generateFindingsSection(findings) {
if (findings.length === 0) {
return `
<div class="card">
<h2>Findings</h2>
<p style="color: var(--color-clean); text-align: center; padding: 2rem;">
No security issues detected. Your codebase appears clean.
</p>
</div>`;
}
const sortedFindings = [...findings].sort((a, b) => {
const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
return (order[a.severity] || 4) - (order[b.severity] || 4);
});
return `
<div class="card">
<h2>Findings (${findings.length})</h2>
${sortedFindings.map((finding, index) => this.generateFindingItem(finding, index)).join('')}
</div>`;
}
generateFindingItem(finding, index) {
const contextHtml = this.includeContext && finding.context
? this.generateCodeBlock(finding.context, finding.line)
: '';
return `
<div class="finding severity-${finding.severity}">
<div class="finding-header">
<div class="finding-title">
<span class="severity-indicator"></span>
<span><strong>#${index + 1}</strong> ${this.escapeHtml(finding.signatureName || finding.signatureId)}</span>
</div>
<span class="expand-icon">▼</span>
</div>
<div class="finding-body">
<div class="finding-meta">
<div class="meta-item">
<span class="meta-label">Severity:</span>
<span style="color: var(--color-${finding.severity}); font-weight: 600">${finding.severity.toUpperCase()}</span>
</div>
<div class="meta-item">
<span class="meta-label">Category:</span>
<span>${this.escapeHtml(finding.category || 'security')}</span>
</div>
<div class="meta-item">
<span class="meta-label">File:</span>
<span style="font-family: var(--font-mono); font-size: 0.875rem">${this.escapeHtml(finding.file || 'Unknown')}</span>
</div>
<div class="meta-item">
<span class="meta-label">Location:</span>
<span>Line ${finding.line || 1}, Column ${finding.column || 1}</span>
</div>
<div class="meta-item">
<span class="meta-label">Signature ID:</span>
<span style="font-family: var(--font-mono)">${this.escapeHtml(finding.signatureId)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Confidence:</span>
<span>${Math.round((finding.confidence || 0.8) * 100)}%</span>
</div>
</div>
<p><strong>Description:</strong> ${this.escapeHtml(finding.description || 'No description available')}</p>
${finding.matchedText ? `<p><strong>Matched Pattern:</strong> <code>${this.escapeHtml(finding.matchedText.substring(0, 200))}</code></p>` : ''}
${contextHtml}
${finding.remediation ? `<p><strong>Remediation:</strong> ${this.escapeHtml(finding.remediation)}</p>` : ''}
${finding.references && finding.references.length > 0 ? `
<p><strong>References:</strong></p>
<ul>
${finding.references.map(ref => `<li>${this.escapeHtml(ref)}</li>`).join('')}
</ul>
` : ''}
</div>
</div>`;
}
generateCodeBlock(context, highlightLine) {
if (!context || context.length === 0) return '';
const lines = context.map(ctx => {
const isHighlight = ctx.isMatch || ctx.lineNumber === highlightLine;
return `
<div class="code-line ${isHighlight ? 'line-highlight' : ''}">
<span class="line-number">${ctx.lineNumber}</span>
<span class="line-content">${this.escapeHtml(ctx.content)}</span>
</div>`;
}).join('');
return `<div class="code-block">${lines}</div>`;
}
generateTimelineSection(scanResult) {
const findings = scanResult.findings || [];
const sortedByTime = [...findings]
.filter(f => f.timestamp)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
if (sortedByTime.length === 0) {
return '';
}
return `
<div class="card">
<h2>Detection Timeline</h2>
<div class="timeline">
${sortedByTime.slice(0, 20).map(finding => `
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="timeline-time">${new Date(finding.timestamp).toLocaleString()}</div>
<strong>${this.escapeHtml(finding.signatureName || finding.signatureId)}</strong>
<p style="font-size: 0.875rem; color: var(--color-text-secondary)">
${this.escapeHtml(finding.file || 'Unknown file')}
</p>
</div>
</div>
`).join('')}
</div>
</div>`;
}
generateRemediationChecklist(findings) {
const remediations = new Map();
for (const finding of findings) {
if (finding.remediation) {
const key = finding.remediation;
if (!remediations.has(key)) {
remediations.set(key, {
action: finding.remediation,
severity: finding.severity,
count: 1,
files: [finding.file]
});
} else {
const existing = remediations.get(key);
existing.count++;
if (!existing.files.includes(finding.file)) {
existing.files.push(finding.file);
}
}
}
}
const sortedRemediations = [...remediations.values()].sort((a, b) => {
const order = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
return (order[a.severity] || 4) - (order[b.severity] || 4);
});
if (sortedRemediations.length === 0) {
return '';
}
return `
<div class="card">
<h2>Remediation Checklist</h2>
<p style="margin-bottom: 1rem; color: var(--color-text-secondary)">
Click checkboxes to track your remediation progress.
</p>
${sortedRemediations.map((item, index) => `
<div class="remediation-item">
<div class="checkbox"></div>
<div>
<strong>${index + 1}. ${this.escapeHtml(item.action)}</strong>
<p style="font-size: 0.875rem; color: var(--color-text-secondary)">
Affects ${item.count} finding(s) in ${item.files.length} file(s)
<span style="color: var(--color-${item.severity})">(${item.severity.toUpperCase()})</span>
</p>
</div>
</div>
`).join('')}
</div>`;
}
generateFooter(timestamp) {
return `
<footer>
<p>Generated by NeuroLint Layer 8 Security Forensics v${LAYER_8_VERSION}</p>
<p>Report generated at ${new Date(timestamp).toLocaleString()}</p>
<p style="margin-top: 0.5rem">
For more information, visit <a href="https://neurolint.dev" style="color: #3b82f6">neurolint.dev</a>
</p>
</footer>`;
}
formatDuration(ms) {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
return `${(ms / 60000).toFixed(2)}m`;
}
escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
writeToFile(scanResult, filePath, options = {}) {
const report = this.generateReport(scanResult, options);
fs.writeFileSync(filePath, report, 'utf8');
return filePath;
}
}
module.exports = HTMLReporter;