generated from nhcarrigan/template
feat: add multi-lang support and cohort scripts (#1)
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 3s
CI / typescript (push) Successful in 9m38s
CI / python (push) Successful in 9m23s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
CI / dependency-pin-check-typescript (push) Successful in 4s
CI / dependency-pin-check-python (push) Successful in 3s
CI / typescript (push) Successful in 9m38s
CI / python (push) Successful in 9m23s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m6s
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #1 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -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