From ba46ada4c048e87b226d6f6c388b66dbcba25e42 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Wed, 17 Dec 2025 19:30:08 -0800 Subject: [PATCH] feat: security report generation --- prod.env | 5 +- src/interfaces/dojo.ts | 37 +++ src/security/generateReport.ts | 433 +++++++++++++++++++++++++++++++++ 3 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 src/interfaces/dojo.ts create mode 100644 src/security/generateReport.ts diff --git a/prod.env b/prod.env index 01918a3..701b720 100644 --- a/prod.env +++ b/prod.env @@ -16,4 +16,7 @@ AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID" AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key" # Gitea -GITEA_TOKEN="op://Private/Gitea/token" \ No newline at end of file +GITEA_TOKEN="op://Private/Gitea/token" + +# DefectDojo +DOJO_TOKEN="op://Private/DefectDojo/token" \ No newline at end of file diff --git a/src/interfaces/dojo.ts b/src/interfaces/dojo.ts new file mode 100644 index 0000000..2d1bbf0 --- /dev/null +++ b/src/interfaces/dojo.ts @@ -0,0 +1,37 @@ +/* eslint-disable @typescript-eslint/naming-convention -- interface properties match API responses. */ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface Product { + id: number; + name: string; +} + +interface Finding extends Record { + id: number; + title: string; + severity: "Critical" | "High" | "Medium" | "Low" | "Info"; + active: boolean; + verified: boolean; + product?: Product; +} + +interface DojoResponse { + count: number; + next: string | null; + previous: string | null; + results: Array; +} + +interface ProjectStats { + Critical: number; + High: number; + Medium: number; + Low: number; + Info: number; +} + +export type { Product, Finding, DojoResponse, ProjectStats }; diff --git a/src/security/generateReport.ts b/src/security/generateReport.ts new file mode 100644 index 0000000..f5aa469 --- /dev/null +++ b/src/security/generateReport.ts @@ -0,0 +1,433 @@ +/* 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 ScopeCriticalHighMediumLow
+
+ + + + + +`; + +// Write to Disk +await writeFile(outputFile, htmlContent, "utf-8"); + +console.log(`🚀 Report generated successfully: ${outputFile}`);