Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion dashboard/views/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,38 @@ function layout(title: string, content: string, orgName: string = "", activeNav:
.check-prod-btn:disabled { opacity: 0.5; cursor: wait; }
.check-prod-result { font-size: 11px; margin-left: 8px; color: #aaa; }

/* Export dropdown on each repo card. Filename + format picker lives
here; the actual download is a GET to /export/:owner/:name/:format
with the currently selected branch from the combobox. */
.export-combo { position: relative; display: inline-block; }
.export-btn {
font-family: var(--font-pixel); font-size: 8px; letter-spacing: 1px;
background: #050505; border: 1px solid #00ffff; color: #00ffff;
padding: 7px 12px; cursor: pointer; transition: all 0.15s;
}
.export-btn:hover { background: #00ffff; color: #0a0a0a; }
.export-menu {
position: absolute; right: 0; top: 100%; margin-top: 2px;
list-style: none; background: #050505; border: 1px solid #00ffff;
min-width: 240px; z-index: 50;
box-shadow: 0 0 12px rgba(0,255,255,0.25); display: none;
}
.export-combo.open .export-menu { display: block; }
.export-menu li {
font-family: var(--font-mono); font-size: 12px; color: #aaa;
padding: 7px 12px; cursor: pointer; white-space: nowrap;
}
.export-menu li:hover:not(.export-header) { background: #0a0a0a; color: #00ffff; }
.export-menu li.export-header {
font-family: var(--font-pixel); font-size: 7px; color: #666;
letter-spacing: 2px; padding: 6px 12px 4px; cursor: default;
border-top: 1px solid #1a1a1a;
}
.export-menu li.export-header:first-child { border-top: none; }
/* Org export sits above the repo list, right-aligned. */
.org-export-combo { float: right; margin-top: -58px; margin-bottom: 10px; }
.org-export-combo .export-menu { right: 0; }

/* Tabs */
.tab-bar { display: flex; gap: 0; margin-bottom: 0; border-bottom: 2px solid #333; flex-wrap: wrap; }
.tab {
Expand Down Expand Up @@ -580,6 +612,53 @@ function layout(title: string, content: string, orgName: string = "", activeNav:
btn.disabled = false;
});
}

function toggleExportMenu(repoId) {
// Close every other open export menu first so we don't stack them.
document.querySelectorAll('.export-combo.open').forEach(function(el) {
if (el.querySelector('#export-menu-' + repoId) === null) {
el.classList.remove('open');
}
});
var menu = document.getElementById('export-menu-' + repoId);
if (!menu) return;
menu.parentElement.classList.toggle('open');
}

function downloadExport(evt, owner, name, repoId, format) {
evt.stopPropagation();
// Honor the branch selected in the combobox for this repo card. Falls
// back to no ?branch= so the server picks main/master.
var combo = document.getElementById('branch-' + repoId);
var branch = combo ? combo.getAttribute('data-value') : '';
var url = '/export/' + owner + '/' + name + '/' + format;
if (branch) url += '?branch=' + encodeURIComponent(branch);
// Close the menu immediately so the dropdown doesn't linger while the
// download starts.
var parent = document.getElementById('export-menu-' + repoId);
if (parent) parent.parentElement.classList.remove('open');
window.location.href = url;
}

// Close export menus when clicking anywhere else on the page.
document.addEventListener('click', function(evt) {
if (!evt.target.closest('.export-combo')) {
document.querySelectorAll('.export-combo.open').forEach(function(el) {
el.classList.remove('open');
});
}
});

function toggleOrgExportMenu() {
var el = document.querySelector('.org-export-combo');
if (el) el.classList.toggle('open');
}
function downloadOrgExport(evt, format) {
evt.stopPropagation();
var el = document.querySelector('.org-export-combo');
if (el) el.classList.remove('open');
window.location.href = '/export/all/' + format;
}
</script>
</body>
</html>`;
Expand Down Expand Up @@ -622,7 +701,24 @@ export function renderDashboard(summaries: RepoSummary[], branchesPerRepo: Map<s
<div class="stat-card"><div class="label">Leaks</div><div class="value" style="color:${secretsCount > 0 ? "#ff0040" : "#39ff14"}">${secretsCount}</div></div>
</div>`;

const searchHtml = `<div class="search-bar"><input type="text" id="repo-search" placeholder="> SEARCH REPOS..." oninput="filterRepos()"></div>`;
// Org-level audit-bundle export — main/master only across all repos.
// Deliberately separate from the per-repo dropdown (which respects the
// branch dropdown) because auditors want production state, not WIP.
const orgExportHtml = `
<div class="export-combo org-export-combo" onclick="event.stopPropagation();">
<button class="export-btn" onclick="toggleOrgExportMenu()">ORG EXPORT (MAIN) \u25BE</button>
<ul id="export-menu-org" class="export-menu">
<li class="export-header">MACHINE-READABLE</li>
<li onclick="downloadOrgExport(event,'manifest.json')">JSON (all repos)</li>
<li class="export-header">CSV</li>
<li onclick="downloadOrgExport(event,'nist-csf.csv')">NIST CSF (all repos)</li>
<li onclick="downloadOrgExport(event,'eu-ai-act.csv')">EU AI Act (all repos)</li>
<li onclick="downloadOrgExport(event,'risks.csv')">Risk register (all repos)</li>
<li onclick="downloadOrgExport(event,'vulnerabilities.csv')">Vulnerabilities (all repos)</li>
</ul>
</div>`;

const searchHtml = `<div class="search-bar"><input type="text" id="repo-search" placeholder="> SEARCH REPOS..." oninput="filterRepos()"></div>${orgExportHtml}`;

const reposHtml = summaries.map(r => {
const [owner, name] = r.repo.split("/");
Expand Down Expand Up @@ -687,6 +783,20 @@ export function renderDashboard(summaries: RepoSummary[], branchesPerRepo: Map<s
</ul>
</div>
${hasSiteUrl ? `<button class="check-prod-btn" onclick="event.stopPropagation();checkProduction('${owner}','${name}',this)">CHECK PRODUCTION</button><span class="check-prod-result"></span>` : ""}
<div class="export-combo" onclick="event.stopPropagation();">
<button class="export-btn" onclick="toggleExportMenu('${safeId}')">EXPORT \u25BE</button>
<ul id="export-menu-${safeId}" class="export-menu">
<li class="export-header">MACHINE-READABLE</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','manifest.json')">JSON (full state)</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','findings.sarif')">SARIF (code scanning)</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','assessment.oscal.json')">OSCAL (assessment results)</li>
<li class="export-header">CSV</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','nist-csf.csv')">NIST CSF controls</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','eu-ai-act.csv')">EU AI Act articles</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','risks.csv')">Risk register</li>
<li onclick="downloadExport(event,'${owner}','${name}','${safeId}','vulnerabilities.csv')">Vulnerabilities</li>
</ul>
</div>
</div>
<div class="tab-bar">
<div class="tab active" data-url="/repo/${owner}/${name}" onclick="switchTab('${safeId}','${owner}','${name}','repo',this)">OVERVIEW</div>
Expand Down
169 changes: 169 additions & 0 deletions dashboard/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { parse } from "yaml";
import type { Manifest, } from "../scanner/types.js";
import { evaluateFramework } from "../scanner/generators/framework-report.js";
import { evaluateEUAIAct, calcAIComplianceScore } from "../scanner/frameworks/eu-ai-act.js";
import { assessRisks } from "../scanner/generators/risk-assessment.js";
import { generateJsonExport } from "../scanner/generators/exports/json.js";
import { generateCsvNistCsf, generateCsvEuAiAct, generateCsvRisks, generateCsvVulnerabilities } from "../scanner/generators/exports/csv.js";
import { generateSarifExport } from "../scanner/generators/exports/sarif.js";
import { generateOscalExport } from "../scanner/generators/exports/oscal.js";
import { renderDashboard, renderRepoDetail, renderNistView, renderBranchComparison, renderTrendChart, renderAIComplianceView, renderInventoryView } from "./views/render.js";
import { verifyGitHubOidc, assertRepositoryMatches, AuthError, DEFAULT_AUDIENCE } from "./auth.js";

Expand Down Expand Up @@ -698,4 +703,168 @@ app.get("/api/inventory.csv", async (c) => {
});
});

// ─── Phase 9 Sub-phase A: machine-readable exports ─────────────────────────

/**
* Compute the framework + risk artifacts for one manifest. The scanner itself
* passes authFindings (source-level auth middleware misses) into assessRisks,
* but those aren't stored in the manifest so we call assessRisks with []
* here. The risk set is nearly identical — only file-specific auth misses
* drop out.
*/
function exportPayload(manifest: Manifest, siteUrl: string | undefined) {
const nist = evaluateFramework(manifest);
const eu = evaluateEUAIAct(manifest);
const config = {
siteName: manifest.repo,
siteUrl: siteUrl ?? "",
ownerName: manifest.repo.split("/")[0] ?? "Unknown",
contactEmail: "",
securityContact: "",
logRetentionDays: 90,
jurisdiction: ["gdpr"],
preferredLanguages: ["en"],
outputDir: "docs/policies",
policyUrls: {},
ai: { enabled: false, provider: "anthropic" as const },
aiSystemOverrides: [],
};
const risks = assessRisks(manifest, config, []);
return { nist, eu, risks };
}

function stemFor(manifest: Manifest): string {
const safeBranch = manifest.branch.replace(/[^\w.-]/g, "-");
return `${manifest.repo.replace(/\//g, "-")}-${safeBranch}-${manifest.commit.slice(0, 7)}`;
}

function download(body: string, filename: string, contentType: string): Response {
return new Response(body, {
headers: {
"content-type": contentType,
"content-disposition": `attachment; filename="${filename}"`,
},
});
}

// Per-repo exports — honor the ?branch=<name> query param so consumers can
// download the state of any scanned branch, not just main. Falls back to the
// repo's default (main/master/first-scanned) via findByBranch.
app.get("/export/:owner/:name/:format{.+}", async (c) => {
const repoName = `${c.req.param("owner")}/${c.req.param("name")}`;
const format = c.req.param("format");
const branch = c.req.query("branch") || undefined;
const all = await getManifests(c.env.GRC_KV);
const entry = findByBranch(all.filter(m => m.manifest.repo === repoName), branch);
if (!entry) return c.json({ error: "Repo not found" }, 404);

const { manifest, siteUrl } = entry;
const { nist, eu, risks } = exportPayload(manifest, siteUrl);
const stem = stemFor(manifest);

switch (format) {
case "manifest.json":
return download(generateJsonExport(manifest, nist, eu, risks), `${stem}.json`, "application/json; charset=utf-8");
case "findings.sarif":
return download(generateSarifExport(manifest), `${stem}.sarif`, "application/sarif+json; charset=utf-8");
case "assessment.oscal.json":
return download(generateOscalExport(manifest, nist, eu), `${stem}.oscal.json`, "application/json; charset=utf-8");
case "nist-csf.csv":
return download(generateCsvNistCsf(manifest, nist), `${stem}-nist-csf.csv`, "text/csv; charset=utf-8");
case "eu-ai-act.csv":
return download(generateCsvEuAiAct(manifest, eu), `${stem}-eu-ai-act.csv`, "text/csv; charset=utf-8");
case "risks.csv":
return download(generateCsvRisks(manifest, risks), `${stem}-risks.csv`, "text/csv; charset=utf-8");
case "vulnerabilities.csv":
return download(generateCsvVulnerabilities(manifest), `${stem}-vulnerabilities.csv`, "text/csv; charset=utf-8");
default:
return c.json({ error: `Unknown export format: ${format}. Try manifest.json, findings.sarif, assessment.oscal.json, nist-csf.csv, eu-ai-act.csv, risks.csv, vulnerabilities.csv.` }, 400);
}
});

/**
* Pick the main/master entry for each repo — the org-level export is an
* "audit bundle" so we deliberately leave feature branches out.
*/
function mainEntryPerRepo(all: StoredManifest[]): StoredManifest[] {
const byRepo = new Map<string, StoredManifest>();
for (const entry of all) {
const repo = entry.manifest.repo;
const isMain = entry.manifest.branch === "main" || entry.manifest.branch === "master";
const existing = byRepo.get(repo);
if (!existing) {
// Accept whatever we see first; will be replaced if a main turns up.
byRepo.set(repo, entry);
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict org export selection to main/master branches only

The org export selector stores the first branch encountered for each repo and only replaces it if a main/master entry is later found. For repos that currently lack main/master manifests, this includes an arbitrary non-main branch in /export/all/*, which conflicts with the endpoint’s stated main-only semantics and can leak feature-branch state into audit bundles.

Useful? React with 👍 / 👎.

const existingIsMain = existing.manifest.branch === "main" || existing.manifest.branch === "master";
if (isMain && !existingIsMain) byRepo.set(repo, entry);
}
return [...byRepo.values()];
}

// Org-level aggregate — main/master only across all repos. JSON + 4 CSVs.
// SARIF + OSCAL aggregation is skipped for now; they require careful merge
// semantics (dedup of common rules, UUID stability) and nobody's asked.
app.get("/export/all/:format{.+}", async (c) => {
const all = await getManifests(c.env.GRC_KV);
const entries = mainEntryPerRepo(all);
const format = c.req.param("format");
const dateStem = new Date().toISOString().slice(0, 10);

switch (format) {
case "manifest.json": {
const payload = entries.map(e => {
const { nist, eu, risks } = exportPayload(e.manifest, e.siteUrl);
return JSON.parse(generateJsonExport(e.manifest, nist, eu, risks));
});
return download(JSON.stringify(payload, null, 2) + "\n", `grc-org-${dateStem}.json`, "application/json; charset=utf-8");
}
case "nist-csf.csv": {
const csvs = entries.map(e => {
const { nist } = exportPayload(e.manifest, e.siteUrl);
return generateCsvNistCsf(e.manifest, nist);
});
return download(concatCsv(csvs), `grc-org-${dateStem}-nist-csf.csv`, "text/csv; charset=utf-8");
}
case "eu-ai-act.csv": {
const csvs = entries.map(e => {
const { eu } = exportPayload(e.manifest, e.siteUrl);
return generateCsvEuAiAct(e.manifest, eu);
});
return download(concatCsv(csvs), `grc-org-${dateStem}-eu-ai-act.csv`, "text/csv; charset=utf-8");
}
case "risks.csv": {
const csvs = entries.map(e => {
const { risks } = exportPayload(e.manifest, e.siteUrl);
return generateCsvRisks(e.manifest, risks);
});
return download(concatCsv(csvs), `grc-org-${dateStem}-risks.csv`, "text/csv; charset=utf-8");
}
case "vulnerabilities.csv": {
const csvs = entries.map(e => generateCsvVulnerabilities(e.manifest));
return download(concatCsv(csvs), `grc-org-${dateStem}-vulnerabilities.csv`, "text/csv; charset=utf-8");
}
default:
return c.json({ error: `Unknown org export format: ${format}. Try manifest.json, nist-csf.csv, eu-ai-act.csv, risks.csv, vulnerabilities.csv.` }, 400);
}
});

/** Concatenate multiple per-repo CSVs into one table keeping a single header. */
function concatCsv(csvs: string[]): string {
if (csvs.length === 0) return "";
const firstLines = csvs[0]!.split("\n");
const header = firstLines[0]!;
const rows: string[] = [header];
for (const csv of csvs) {
const lines = csv.split("\n");
// Skip the header on each subsequent file, drop trailing empty line.
for (let i = 1; i < lines.length; i++) {
const line = lines[i]!;
if (line.length > 0) rows.push(line);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve quoted multiline rows when merging org CSVs

The org-level CSV aggregator splits each per-repo CSV on raw \n and reassembles line-by-line, which corrupts valid CSV rows that contain embedded newlines inside quoted fields. The exporters already emit such values (for example, evidence/description fields), so this can produce malformed aggregate CSV files where one logical row becomes multiple broken rows. Merge logic should strip only the first header record from each file without tokenizing by newline.

Useful? React with 👍 / 👎.

}
}
return rows.join("\n") + "\n";
}

export default app;
Loading
Loading