Files
ephemere/src/security/generateReport.ts
T
naomi ba46ada4c0
Node.js CI / Lint and Test (push) Failing after 24s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m21s
feat: security report generation
2025-12-17 20:00:06 -08:00

434 lines
13 KiB
TypeScript

/* eslint-disable max-lines -- Necessary for all of the HTML templating. */
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type {
DojoResponse,
Finding,
ProjectStats,
} from "../interfaces/dojo.js";
// --- Configuration ---
const dojoUrl = "https://security.nhcarrigan.com";
const dojoToken = process.env.DOJO_TOKEN ?? "YOUR_API_TOKEN_HERE";
const outputFile = join(process.cwd(), "data", "public_security_report.html");
// --- Logic ---
console.log(`🔍 Fetching data from ${dojoUrl}...`);
const headers = {
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
"Authorization": `Token ${dojoToken}`,
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
"Content-Type": "application/json",
};
/**
* Fetch active and verified findings only.
* Handles pagination by following the 'next' URL until all pages are fetched.
*/
const allFindings: Array<Finding> = [];
let nextUrl: string | null = `${dojoUrl}/api/v2/findings/?active=true&verified=true&limit=1000`;
let pageCount = 0;
while (nextUrl !== null) {
pageCount = pageCount + 1;
console.log(`📄 Fetching findings page ${pageCount.toString()}...`);
const response = await fetch(nextUrl, { headers });
if (!response.ok) {
throw new Error(`API Error: ${response.status.toString()} - ${response.statusText}`);
}
const data: DojoResponse = await response.json();
allFindings.push(...data.results);
console.log(` Retrieved ${data.results.length.toString()} findings (total: ${allFindings.length.toString()})`);
nextUrl = data.next;
}
console.log(`✅ Retrieved ${allFindings.length.toString()} active findings across ${pageCount.toString()} page(s).`);
/**
* Build a map of finding ID to product name by fetching products.
* Products include a findings_list array with finding IDs.
*/
console.log("🔍 Building finding-to-product lookup map...");
// Fetch all products (paginated)
const findingToProductMap = new Map<number, string>();
let nextProductUrl: string | null = `${dojoUrl}/api/v2/products/?limit=1000`;
let productPageCount = 0;
while (nextProductUrl !== null) {
productPageCount = productPageCount + 1;
console.log(`📄 Fetching products page ${productPageCount.toString()}...`);
const productResponse = await fetch(nextProductUrl, { headers });
if (!productResponse.ok) {
throw new Error(`API Error: ${productResponse.status.toString()} - ${productResponse.statusText}`);
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- API response structure.
const productData = await productResponse.json() as {
count: number;
next: string | null;
previous: string | null;
results: Array<{
id: number;
name: string;
// eslint-disable-next-line @typescript-eslint/naming-convention -- API uses snake_case.
findings_list: Array<number>;
}>;
};
// Debug: Log first product structure
if (productPageCount === 1 && productData.results.length > 0) {
console.log(` 🔍 Sample product structure:`, JSON.stringify(productData.results[0], null, 2));
}
// Map each finding ID to its product name
for (const product of productData.results) {
const { findings_list: findingsList, name } = product;
for (const findingId of findingsList) {
findingToProductMap.set(findingId, name);
}
}
console.log(` Processed ${productData.results.length.toString()} products (total mappings: ${findingToProductMap.size.toString()})`);
nextProductUrl = productData.next;
}
console.log(`✅ Built lookup map with ${findingToProductMap.size.toString()} finding-to-product mappings across ${productPageCount.toString()} page(s).`);
const data = allFindings;
/**
* Extracts the product name from a finding.
* Tries finding.product.name first, then looks up via finding ID.
* @param finding - The finding to extract the product name from.
* @param productMap - Map of finding ID to product name.
* @returns The product name, or undefined if not found.
*/
const getProductName = (
finding: Finding,
productMap: Map<number, string>,
): string | undefined => {
// Try direct product field first
const productName = finding.product?.name;
if (productName !== undefined && productName.length > 0) {
return productName;
}
// Look up via finding ID
return productMap.get(finding.id);
};
// Aggregate Data
const stats: Record<string, ProjectStats> = {};
for (const finding of data) {
const projectName = getProductName(finding, findingToProductMap);
// Skip findings without a product assigned
if (projectName === undefined || projectName.length === 0) {
console.warn(`⚠️ Skipping finding ${finding.id.toString()} - no product found`);
continue;
}
const { severity } = finding;
// Initialize project entry if missing
if (!stats[projectName]) {
// eslint-disable-next-line @typescript-eslint/naming-convention -- matches the API response.
stats[projectName] = { Critical: 0, High: 0, Info: 0, Low: 0, Medium: 0 };
}
const projectStats = stats[projectName];
// Increment count if severity matches our tracking keys
if (severity in projectStats) {
projectStats[severity] = projectStats[severity] + 1;
}
}
/**
* Formats a project name by removing org prefix and converting to title case.
* @param projectName - The project name (e.g., "nhcarrigan/website-headers").
* @returns Formatted name (e.g., "Website Headers").
*/
const formatProjectName = (projectName: string): string => {
// Remove org prefix (everything before and including the last "/")
const parts = projectName.split("/");
const nameOnly = parts.at(-1) ?? projectName;
// Replace hyphens with spaces and title case each word
return nameOnly.
split("-").
map((word) => {
if (word.length === 0) {
return word;
}
const [ firstChar, ...rest ] = word;
if (firstChar === undefined) {
return word;
}
return firstChar.toUpperCase() + rest.join("").toLowerCase();
}).
join(" ");
};
// Generate HTML - sort alphabetically by formatted name
const htmlRows = Object.entries(stats).
sort(([ projectA ], [ projectB ]) => {
const formattedA = formatProjectName(projectA);
const formattedB = formatProjectName(projectB);
return formattedA.localeCompare(formattedB);
}).
map(([ project, counts ]) => {
const critClass = counts.Critical > 0
? "crit"
: "info";
const highClass = counts.High > 0
? "high"
: "info";
const mediumClass = counts.Medium > 0
? "medium"
: "info";
const lowClass = counts.Low > 0
? "low"
: "info";
const formattedProject = formatProjectName(project);
return `
<tr>
<td><strong>${formattedProject}</strong></td>
<td class="${critClass}">${counts.Critical.toString()}</td>
<td class="${highClass}">${counts.High.toString()}</td>
<td class="${mediumClass}">${counts.Medium.toString()}</td>
<td class="${lowClass}">${counts.Low.toString()}</td>
</tr>
`;
}).
join("");
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Public Security Transparency Report</title>
</head>
<body>
<header>
<h1>Security Transparency Dashboard</h1>
<div class="meta">
Status: <strong>Active Monitoring</strong> |
Generated: ${new Date().toISOString().
split("T")[0] ?? "Unknown"} |
Source: Automated Pipeline
</div>
</header>
<main>
<p>This report provides an aggregated view of the current security posture across our public projects. We practice continuous remediation; findings listed here are actively being addressed.</p>
<table>
<thead>
<tr>
<th>Project Scope</th>
<th>Critical</th>
<th>High</th>
<th>Medium</th>
<th>Low</th>
</tr>
</thead>
<tbody>
${htmlRows}
</tbody>
</table>
</main>
<script src="https://cdn.nhcarrigan.com/headers/index.js"></script>
<style>
/* Complement global styles - use existing CSS variables */
:root {
--crit-light: #d32f2f;
--high-light: #f57c00;
--medium-light: rgb(255, 251, 0);
--low-light: rgb(36, 200, 3);
--info-light: rgb(146, 146, 146);
/* Dark mode colors - lighter versions for dark background */
--crit-dark: #ff6b6b;
--high-dark: #ffa94d;
--medium-dark: #ffeb3b;
--low-dark: #81c784;
--info-dark: #b0b0b0;
/* Hover colors - for when background is var(--foreground) */
/* Light mode hover: background is #8F2447 (dark pink) */
--crit-hover-light: #ff9999;
--high-hover-light: #ffb366;
--medium-hover-light: #fff266;
--low-hover-light: #99d699;
--info-hover-light: #d0d0d0;
/* Dark mode hover: background is #E1F6F9 (light cyan) */
--crit-hover-dark: #b71c1c;
--high-hover-dark: #e65100;
--medium-hover-dark: #f57f17;
--low-hover-dark: #1b5e20;
--info-hover-dark: #424242;
}
header {
margin-bottom: 2rem;
border-bottom: 2px solid var(--foreground);
padding-bottom: 1rem;
}
h1 {
margin: 0;
color: var(--foreground);
}
.meta {
color: var(--foreground);
opacity: 0.8;
font-size: 0.9rem;
margin-top: 0.5rem;
}
.crit {
color: var(--crit-light);
}
.high {
color: var(--high-light);
}
.medium {
color: var(--medium-light);
}
.low {
color: var(--low-light);
}
.info {
color: var(--info-light);
}
/* Dark mode severity colors */
.is-dark .crit {
color: var(--crit-dark);
}
.is-dark .high {
color: var(--high-dark);
}
.is-dark .medium {
color: var(--medium-dark);
}
.is-dark .low {
color: var(--low-dark);
}
.is-dark .info {
color: var(--info-dark);
}
/* Hover colors for severity classes */
tr:hover .crit {
color: var(--crit-hover-light);
}
tr:hover .high {
color: var(--high-hover-light);
}
tr:hover .medium {
color: var(--medium-hover-light);
}
tr:hover .low {
color: var(--low-hover-light);
}
tr:hover .info {
color: var(--info-hover-light);
}
/* Dark mode hover colors */
.is-dark tr:hover .crit {
color: var(--crit-hover-dark);
}
.is-dark tr:hover .high {
color: var(--high-hover-dark);
}
.is-dark tr:hover .medium {
color: var(--medium-hover-dark);
}
.is-dark tr:hover .low {
color: var(--low-hover-dark);
}
.is-dark tr:hover .info {
color: var(--info-hover-dark);
}
table {
width: 100%;
border-collapse: collapse;
background-color: var(--background);
border: 1px solid var(--foreground);
border-radius: 8px;
overflow: hidden;
margin: 1rem 0;
}
th, td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--foreground);
opacity: 0.7;
}
th {
background-color: var(--foreground);
color: var(--background);
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.05em;
opacity: 1;
}
tr:last-child td {
border-bottom: none;
}
tr:hover {
background-color: var(--foreground);
}
tr:hover td:not(.crit, .high, .medium, .low, .info) {
color: var(--background);
}
.footer {
margin-top: 2rem;
text-align: center;
font-size: 0.8rem;
color: var(--foreground);
opacity: 0.7;
}
/* Dark mode support */
.is-dark table {
border-color: var(--foreground);
}
.is-dark th {
background-color: var(--foreground);
color: var(--background);
}
.is-dark tr:hover {
background-color: var(--foreground);
opacity: 0.2;
}
</style>
</body>
</html>
`;
// Write to Disk
await writeFile(outputFile, htmlContent, "utf-8");
console.log(`🚀 Report generated successfully: ${outputFile}`);