Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
159 changes: 159 additions & 0 deletions dashboard/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ 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 { concatCsv } from "../scanner/generators/exports/concat.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 +704,157 @@ 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.
*
* If a repo has no main/master manifest in KV (e.g. only feature-branch
* scans have landed so far), it is excluded entirely rather than silently
* substituting an arbitrary branch. The endpoint's documented semantics
* are "main/master only"; the audit bundle must honor that literally or
* it leaks WIP state into what auditors believe is production evidence.
*/
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";
if (!isMain) continue;
const existing = byRepo.get(repo);
// Prefer "main" over "master" if a repo somehow has both. Otherwise
// just keep whichever we saw first; main and master aren't both
// expected in the same repo.
if (!existing || entry.manifest.branch === "main") {
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);
}
});

export default app;
23 changes: 12 additions & 11 deletions docs/implementation-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,18 +365,19 @@ Recommended build order:

**Design principle: no new action runtime.** We don't want to bloat the action's scan time. Integration work happens via (a) exports the user downloads or (b) on-demand dashboard actions. Automatic push-on-scan is deliberately deferred.

### Sub-phase A: Standard export formats — MVP
- [ ] **SARIF-format output** for security findings (format compatibility only — producing a conformant SARIF file; this does NOT by itself populate GitHub's PR Security tab)
- [ ] **SARIF upload step** in the action — use `github/codeql-action/upload-sarif@v3` (or POST to `/repos/:owner/:repo/code-scanning/sarifs`) so findings actually appear in GitHub's code scanning UI. Requires `security-events: write` in the consuming workflow's permissions block. Must be documented alongside the existing `contents: write` requirement.
- [ ] **OSCAL export** (NIST SP 800-53 / Open Security Controls Assessment Language) — JSON/YAML/XML standard that Drata, Hyperproof, and an increasing number of GRC platforms can ingest
- [ ] **Enhanced JSON export** — structured scan data with all findings, for custom ingestion or scripting
- [ ] **CSV export** — flat finding list for spreadsheet ingestion (audit workpapers, remediation tracking)
- [ ] **Export dropdown on each repo card** — choose format, download file
- [ ] **Org-level export** — aggregated across all scanned repos in one bundle
- [ ] Exports land in `.grc/exports/` when run via CLI; in-browser download from the dashboard
- [ ] No credentials required for downloads; SARIF upload uses `GITHUB_TOKEN` only (no vendor keys)
### Sub-phase A: Standard export formats — MVP — DONE
- [x] **SARIF-format output** — conformant 2.1.0 with per-secret, per-CVE, per-AI-system results. Vulnerabilities map CVSS severity onto SARIF level (critical → error, moderate → warning, low → note). Fingerprints populated so the Security tab can dedupe across runs.
- [ ] **SARIF upload step** in the action — deferred. Would require consumers to add `security-events: write` to their workflow permissions block; want to gather signal on whether anyone actually wants the Security-tab integration before adding another required permission.
- [x] **OSCAL Assessment Results export** — OSCAL v1.1.2 JSON with one result per framework (NIST CSF always; EU AI Act when evaluated). Each evaluated control becomes an observation; non-pass non-NA results also produce findings. Cross-refs preserved as OSCAL props.
- [x] **Enhanced JSON export** — `GRCExport` envelope: manifest + NIST CSF evaluation + EU AI Act evaluation + full risk register in one blob. Schema-versioned (`schema: grc-export`, `schemaVersion: 1.0`).
- [x] **CSV export** — four flat tables (NIST CSF controls, EU AI Act articles, risk register, dependency vulnerabilities) with RFC 4180 quoting. Repo/branch/commit/scan_date columns included on every row.
- [x] **Export dropdown on each repo card** — respects the branch combobox. Per-format filename like `<owner>-<repo>-<branch>-<commit>.sarif` so org-level bundles don't collide.
- [x] **Org-level export** — main/master only across all repos. JSON + 4 CSVs. SARIF/OSCAL org aggregation deliberately deferred (they need careful merge semantics; no demand yet).
- [x] **Per-CVE capture in the scanner** — new `DependencyVulnerability[]` field on the manifest carrying advisory id, package, severity, CVSS score, range, fix availability, paths. Stable severity-then-name sort. Required for meaningful SARIF / CSV vuln output.
- [x] Exports land in `.grc/exports/` when run via CLI; `/export/:owner/:name/:format` and `/export/all/:format` routes on the dashboard for in-browser download.
- [x] No credentials required for downloads.
- **GRC concept:** Structured evidence formats; OSCAL as the emerging interchange standard; SARIF as the de-facto security findings format
- **Known gotcha:** SARIF-on-disk is not the same as findings in the Security tab. Producing the file and uploading it are two separate pieces of work — ship both.
- **Known gotcha:** SARIF-on-disk is not the same as findings in the Security tab. The export is conformant; the upload step is still open.

### Sub-phase B: Auditor evidence packaging (was Phase 5 Tier 3)
- [ ] Generate PDF/ZIP evidence package per framework
Expand Down
Loading
Loading