generated from nhcarrigan/template
feat: security report generation
This commit is contained in:
@@ -17,3 +17,6 @@ AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
|
|||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
GITEA_TOKEN="op://Private/Gitea/token"
|
GITEA_TOKEN="op://Private/Gitea/token"
|
||||||
|
|
||||||
|
# DefectDojo
|
||||||
|
DOJO_TOKEN="op://Private/DefectDojo/token"
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<Finding>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectStats {
|
||||||
|
Critical: number;
|
||||||
|
High: number;
|
||||||
|
Medium: number;
|
||||||
|
Low: number;
|
||||||
|
Info: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Product, Finding, DojoResponse, ProjectStats };
|
||||||
@@ -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<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}`);
|
||||||
Reference in New Issue
Block a user