/* 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 = []; 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(); 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; }>; }; // 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, ): 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 = {}; 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 ` ${formattedProject} ${counts.Critical.toString()} ${counts.High.toString()} ${counts.Medium.toString()} ${counts.Low.toString()} `; }). join(""); const htmlContent = ` Public Security Transparency Report

Security Transparency Dashboard

Status: Active Monitoring | Generated: ${new Date().toISOString(). split("T")[0] ?? "Unknown"} | Source: Automated Pipeline

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.

${htmlRows}
Project Scope Critical High Medium Low
`; // Write to Disk await writeFile(outputFile, htmlContent, "utf-8"); console.log(`🚀 Report generated successfully: ${outputFile}`);