diff --git a/dashboard/views/render.ts b/dashboard/views/render.ts index 313930c..461cb06 100644 --- a/dashboard/views/render.ts +++ b/dashboard/views/render.ts @@ -42,7 +42,13 @@ function timeAgo(dateStr: string): string { return `${days}D AGO`; } -function layout(title: string, content: string, orgName: string = "", activeNav: "dashboard" | "inventory" = "dashboard"): string { +function layout( + title: string, + content: string, + orgName: string = "", + activeNav: "dashboard" | "inventory" = "dashboard", + opts: { fullBleed?: boolean } = {}, +): string { const subtitle = orgName ? `${orgName.toUpperCase()} // ` : ""; const navCls = (n: string) => n === activeNav ? "active" : ""; return ` @@ -111,6 +117,214 @@ function layout(title: string, content: string, orgName: string = "", activeNav: .header .header-nav a { color: #666; text-decoration: none; } .header .header-nav a.active { color: #39ff14; text-shadow: 0 0 6px #39ff14; } .header .header-nav a:hover { color: #ff00ff; text-shadow: 0 0 6px #ff00ff; } + + /* ========= V2 DASHBOARD SHELL ========= + Two-pane layout: sidebar repo list (left) + detail pane (right). + Derived from the v2 design mock. Coexists with the v1 styles above; + views that still use v1 classes (NIST tab, Branches, Trends, etc.) + keep working unchanged. */ + .v2-shell { + background: #0a0a0a; color: #c8c8c8; + font-family: var(--font-mono); font-size: 13px; + min-height: calc(100vh - 120px); display: grid; + grid-template-rows: auto 1fr; position: relative; + } + .v2-topbar { + display: flex; align-items: center; gap: 14px; + padding: 12px 20px; border-bottom: 1px solid #1a1a1a; + flex-wrap: wrap; + } + .v2-brand { + font-family: var(--font-pixel); font-size: 12px; + color: #39ff14; letter-spacing: 2px; + } + .v2-org { font-family: var(--font-pixel); font-size: 7px; color: #555; letter-spacing: 2px; } + .v2-stats-spacer { flex: 1; } + .v2-stats { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; } + .v2-stat { + display: inline-flex; align-items: baseline; gap: 6px; + } + .v2-stat .v2-stat-label { + font-family: var(--font-pixel); font-size: 7px; + color: #555; letter-spacing: 1.5px; + } + .v2-stat .v2-stat-val { + font-family: var(--font-pixel); font-size: 11px; + } + + .v2-panes { + display: grid; grid-template-columns: 340px 1fr; + min-height: 0; + } + .v2-sidebar { + border-right: 1px solid #1a1a1a; + display: flex; flex-direction: column; min-width: 0; + } + .v2-sidebar-filter { + padding: 10px 14px; border-bottom: 1px solid #1a1a1a; + display: flex; align-items: center; gap: 8px; + } + .v2-sidebar-filter input { + flex: 1; background: none; border: none; outline: none; + color: #c8c8c8; font-family: var(--font-mono); font-size: 12px; + } + .v2-sidebar-filter input::placeholder { color: #555; } + .v2-repo-row { + padding: 10px 14px; cursor: pointer; + border-left: 2px solid transparent; + display: flex; flex-direction: column; gap: 4px; + text-decoration: none; color: inherit; + } + .v2-repo-row:hover { background: #0f140d; } + .v2-repo-row.active { border-left-color: #39ff14; background: #0f140d; } + .v2-repo-row .v2-repo-head { display: flex; align-items: baseline; gap: 8px; } + .v2-repo-row .v2-repo-name { + font-family: var(--font-pixel); font-size: 9px; + color: #c8c8c8; letter-spacing: 0.5px; + } + .v2-repo-row.active .v2-repo-name { color: #39ff14; } + .v2-repo-row .v2-repo-alert { + font-family: var(--font-pixel); font-size: 6px; + color: #ff0040; letter-spacing: 1px; + } + .v2-repo-row .v2-repo-ai { + font-family: var(--font-pixel); font-size: 6px; + color: #00ffff; letter-spacing: 1px; + } + .v2-repo-row .v2-repo-meta { + display: flex; justify-content: space-between; align-items: center; + font-size: 10px; color: #555; + } + .v2-hp-compact { + font-family: var(--font-mono); font-size: 11px; + display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; + } + + .v2-detail { overflow: auto; min-height: 0; min-width: 0; } + .v2-detail-head { + padding: 18px 24px; border-bottom: 1px solid #1a1a1a; + } + .v2-detail-head-row { + display: flex; align-items: center; gap: 14px; + margin-bottom: 14px; flex-wrap: wrap; + } + .v2-detail-title { + font-family: var(--font-pixel); font-size: 14px; + color: #39ff14; text-shadow: 0 0 8px #39ff14; + letter-spacing: 1px; word-break: break-word; + } + .v2-detail-meta { font-size: 11px; color: #666; } + + .v2-scores { display: flex; gap: 32px; align-items: flex-end; flex-wrap: wrap; } + .v2-big-score { + display: flex; flex-direction: column; gap: 6px; + } + .v2-big-score .v2-big-score-label { + font-family: var(--font-pixel); font-size: 7px; + color: #666; letter-spacing: 2px; + } + .v2-big-score .v2-big-score-val { + font-family: var(--font-pixel); font-size: 26px; + line-height: 1; + } + + .v2-tabs { + display: flex; gap: 0; padding: 0 24px; + border-bottom: 1px solid #1a1a1a; flex-wrap: wrap; + } + .v2-tab { + background: none; border: none; padding: 12px 14px; + cursor: pointer; font-family: var(--font-pixel); + font-size: 8px; letter-spacing: 1.5px; color: #555; + border-bottom: 2px solid transparent; margin-bottom: -1px; + white-space: nowrap; text-decoration: none; display: inline-block; + } + .v2-tab.active { color: #39ff14; border-bottom-color: #39ff14; } + .v2-tab:hover:not(.active) { color: #aaa; } + + .v2-panel-body { padding: 18px 24px; } + .v2-panel-grid { + display: grid; grid-template-columns: repeat(3, 1fr); + gap: 24px; + } + .v2-panel-span-3 { grid-column: span 3; } + .v2-panel-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; border-bottom: 1px solid #1a1a1a; padding-bottom: 6px; + } + .v2-panel-title { + font-family: var(--font-pixel); font-size: 8px; + color: #00ffff; letter-spacing: 2px; + } + .v2-panel-title .v2-panel-count { color: #555; margin-left: 6px; } + .v2-panel-expand { + background: none; border: 1px solid #222; color: #888; + padding: 3px 8px; cursor: pointer; + font-family: var(--font-pixel); font-size: 6px; letter-spacing: 1.5px; + text-decoration: none; + } + .v2-panel-expand:hover { color: #39ff14; border-color: #39ff14; } + .v2-kv-row { + display: flex; justify-content: space-between; + padding: 6px 0; border-bottom: 1px solid #141414; font-size: 12px; + } + .v2-kv-row .v2-kv-k { color: #888; } + .v2-kv-row .v2-kv-v { + font-variant-numeric: tabular-nums; text-transform: uppercase; + } + .v2-kv-row .v2-kv-v.pass, .v2-kv-row .v2-kv-v.present { color: #39ff14; } + .v2-kv-row .v2-kv-v.partial { color: #ffff00; } + .v2-kv-row .v2-kv-v.fail, .v2-kv-row .v2-kv-v.missing { color: #ff0040; } + .v2-kv-row .v2-kv-v.na { color: #555; text-transform: none; } + .v2-kv-row .v2-kv-v.default { color: #c8c8c8; } + + .v2-artifacts-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 22px; } + + .v2-empty-note { + font-size: 11px; color: #555; padding: 6px 0; + } + .v2-empty-state { + padding: 40px 24px; text-align: center; + } + .v2-empty-state p { color: #666; } + + .v2-back-btn { + display: none; + background: none; border: 1px solid #333; color: #aaa; + padding: 6px 10px; cursor: pointer; + font-family: var(--font-pixel); font-size: 7px; letter-spacing: 1.5px; + margin-bottom: 10px; text-decoration: none; + } + + /* Tablet: narrower sidebar, 2-column panel grid */ + @media (max-width: 960px) { + .v2-panes { grid-template-columns: 240px 1fr; } + .v2-panel-grid { grid-template-columns: repeat(2, 1fr); gap: 18px; } + .v2-panel-span-3 { grid-column: span 2; } + .v2-artifacts-grid { grid-template-columns: 1fr 1fr; } + .v2-detail-head { padding: 14px 16px; } + .v2-tabs { padding: 0 16px; } + .v2-panel-body { padding: 14px 16px; } + .v2-detail-title { font-size: 12px; } + } + + /* Mobile: single-pane. Sidebar is the homepage; picking a repo navigates + to /repo/owner/name which renders the detail-only view. */ + @media (max-width: 700px) { + .v2-panes { grid-template-columns: 1fr; } + .v2-sidebar { border-right: none; } + .v2-shell[data-mobile-view="detail"] .v2-sidebar { display: none; } + .v2-shell[data-mobile-view="list"] .v2-detail { display: none; } + .v2-back-btn { display: inline-flex; align-items: center; gap: 6px; } + .v2-panel-grid { grid-template-columns: 1fr; gap: 20px; } + .v2-panel-span-3 { grid-column: span 1; } + .v2-artifacts-grid { grid-template-columns: 1fr; gap: 0; } + .v2-detail-title { font-size: 11px; } + .v2-scores { gap: 18px; } + .v2-topbar { padding: 10px 14px; gap: 10px; } + .v2-stats { gap: 14px; order: 3; width: 100%; padding-top: 6px; border-top: 1px solid #1a1a1a; } + .v2-stats-spacer { display: none; } + } @keyframes flicker { 0%,95%,100%{opacity:1} 96%{opacity:0.8} 97%{opacity:1} 98%{opacity:0.9} } @keyframes blink { 0%,49%{opacity:1} 50%,100%{opacity:0} } @keyframes slideIn { from{transform:translateY(-10px);opacity:0} to{transform:translateY(0);opacity:1} } @@ -399,6 +613,7 @@ function layout(title: string, content: string, orgName: string = "", activeNav: + ${opts.fullBleed ? content : `

GRC OBSERVABILITY

${subtitle}GOVERNANCE RISK COMPLIANCE DASHBOARD
@@ -410,7 +625,7 @@ function layout(title: string, content: string, orgName: string = "", activeNav:
${content}
SYSTEM ACTIVE
-
+
`} `; + + // v2 is full-bleed — it has its own topbar, so we skip layout()'s v1 + // header + the 1100px .container wrapper. Without that, the two-pane + // grid gets clamped to ~1068px and the media query at <=960px fires + // on typical desktop widths. + return layout("GRC OBSERVABILITY", shellHtml, orgName, "dashboard", { fullBleed: true }); } +// V1 expanded-repo-card rendering removed — superseded by v2 two-pane shell. +// Prior layout available in git history before feat/dashboard-v2. + type PolicyServedState = "served" | "unreachable" | "not-configured"; type PolicyServedMap = Partial[OK] SERVED`; - if (state === "unreachable") return `[XX] UNREACHABLE`; - if (state === "not-configured") return `[--] NOT CONFIGURED`; - return `\u2014`; +/** KV row inside one of the v2 overview panels. */ +function v2Kv(k: string, v: string, status?: string): string { + const cls = status ? ` ${status}` : " default"; + return `
${esc(k)}${v}
`; } -export function renderRepoDetail(manifest: Manifest, summary: RepoSummary, served: ServedState = {}): string { - const dc = manifest.dataCollection; - const tp = manifest.thirdPartyServices; - const h = manifest.securityHeaders; - const ac = manifest.accessControls; - - let html = `
`; - html += `
${esc(manifest.branch)} // ${esc(manifest.commit)} // ${timeAgo(manifest.scanDate)}
`; - - html += `

DATA COLLECTION // ${dc.length} POINTS

`; - if (dc.length > 0) { - html += ``; - html += ``; - for (const d of dc) html += ``; - html += `
TYPESOURCELOCATIONFIELDS
${esc(d.type)}${esc(d.source)}${esc(d.location)}${esc(d.fields.join(", "))}
`; - } - - if (tp.length > 0) { - html += `

THIRD-PARTY SERVICES

`; - html += ``; - html += ``; - for (const s of tp) html += ``; - html += `
SERVICEPURPOSEDATA SHAREDDPA
${esc(s.name)}${esc(s.purpose)}${esc(s.dataShared.join(", "))}${s.dpaUrl ? `[LINK]` : 'NONE'}
`; - } - - if (h) { - html += `

SECURITY HEADERS // ${summary.headersPresent}/${summary.headersTotal}

`; - html += ``; - const names: Record = { csp:"Content-Security-Policy", hsts:"Strict-Transport-Security", xFrameOptions:"X-Frame-Options", xContentTypeOptions:"X-Content-Type-Options", referrerPolicy:"Referrer-Policy", permissionsPolicy:"Permissions-Policy" }; - for (const [key, label] of Object.entries(names)) { const val = (h as any)[key] as string; html += ``; } - html += `
HEADERSTATUS
${label}${statusIcon(val)} ${val.toUpperCase()}
`; - } - - if (manifest.https) { - html += `

HTTPS // TLS

`; - html += ``; - html += ``; - html += ``; - html += `
CHECKSTATUS
HTTPS Enforced${manifest.https.enforced ? '[OK] YES' : '[XX] NO'}
Cert Expiry${manifest.https.certExpiry ?? 'UNKNOWN'}
`; - } +function v2Panel(title: string, body: string, opts: { count?: number; span3?: boolean } = {}): string { + const countHtml = opts.count !== undefined ? `· ${opts.count}` : ""; + return ` +
+
+
${title}${countHtml}
+
+
${body}
+
`; +} - if (manifest.dependencies) { - const d = manifest.dependencies; - html += `

DEPENDENCIES

`; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += `
SEVERITYCOUNT
CRITICAL${d.criticalVulnerabilities}
HIGH${d.highVulnerabilities}
MEDIUM${d.mediumVulnerabilities}
LAST AUDIT${d.lastAudit}
`; +function v2BigScore(label: string, pct: number | null): string { + if (pct === null) { + return ` +
+
${label}
+
—
+
`; } + const color = scoreColor(pct); + return ` +
+
${label}
+
${pct}%
+
${hpBar(pct, 14, "")}
+
`; +} - html += `

ACCESS CONTROLS

`; - html += ``; - html += ``; - html += ``; - html += ``; - html += `
CONTROLSTATUS
Branch Protection${ac.branchProtection === true ? '[OK] ENABLED' : ac.branchProtection === false ? '[XX] DISABLED' : 'UNKNOWN'}
Required Reviews${ac.requiredReviews ?? '\u2014'}
Signed Commits${ac.signedCommits === true ? '[OK]' : ac.signedCommits === false ? '[XX]' : '\u2014'}
`; +export type RepoDetailTab = "overview" | "nist" | "ai" | "branches" | "trends"; - // Governance artifacts table shows TWO columns per policy: IN REPO - // (scanner's file-on-disk state) and SERVED (live-URL state from the last - // Check Production click). These are distinct compliance signals — a policy - // file can exist in the repo but not be served at its configured URL, and - // vice versa. - html += `

GOVERNANCE ARTIFACTS

`; - html += ``; - html += ``; - const labels: Record = { privacyPolicy:"Privacy Policy", termsOfService:"Terms of Service", securityTxt:"security.txt", vulnerabilityDisclosure:"Vuln Disclosure", incidentResponsePlan:"Incident Response Plan" }; +/** + * Render the right pane of the v2 two-pane shell. `tab` selects which body + * to show under the detail head — overview keeps the 3-col panel grid, + * the others embed the existing NIST / AI / branches / trends views so + * navigation is a single full-page load rather than HTMX. + */ +export function renderRepoDetail( + manifest: Manifest, + summary: RepoSummary, + served: ServedState = {}, + opts: { + tab?: RepoDetailTab; + functionScores?: FunctionScore[]; + branchSummaries?: RepoSummary[]; + history?: HistoryEntry[]; + } = {}, +): string { + const [owner, name] = manifest.repo.split("/") as [string, string]; + const safeId = (owner + "-" + name).replace(/\./g, "-"); + const h = manifest.securityHeaders; + const ac = manifest.accessControls; + const dc = manifest.dataCollection; + const tp = manifest.thirdPartyServices; + const ai = manifest.aiSystems || []; + const aiCount = ai.length; + const aiScore = summary.aiScore; + const hasSiteUrl = !!summary.siteUrl; + + // --- panels --- + + const dataBody = dc.length === 0 + ? `
No data collection detected.
` + : [ + v2Kv("Forms", String(dc.filter(d => d.source === "form" || d.source === "web-form").length)), + v2Kv("API endpoints", String(dc.filter(d => d.source.startsWith("POST") || d.source === "api-input").length)), + v2Kv("Cookies", String(dc.filter(d => d.type === "cookie").length)), + v2Kv("Trackers", String(dc.filter(d => d.type === "tracking").length)), + ].join(""); + + const transportBody = !h && !manifest.https + ? `
No live site URL configured.
` + : [ + manifest.https + ? v2Kv("HTTPS", manifest.https.enforced ? "enforced" : "NOT ENFORCED", manifest.https.enforced ? "pass" : "fail") + : v2Kv("HTTPS", "not checked", "na"), + manifest.https?.certExpiry + ? v2Kv("Cert expiry", manifest.https.certExpiry) + : "", + h + ? v2Kv("Headers", `${summary.headersPresent}/${summary.headersTotal}`, summary.headersPresent === summary.headersTotal ? "pass" : summary.headersPresent >= 3 ? "partial" : "fail") + : v2Kv("Headers", "not checked", "na"), + ].join(""); + + const depsBody = !manifest.dependencies + ? `
No dependency scan performed.
` + : [ + v2Kv("Critical", String(manifest.dependencies.criticalVulnerabilities), manifest.dependencies.criticalVulnerabilities > 0 ? "fail" : "pass"), + v2Kv("High", String(manifest.dependencies.highVulnerabilities), manifest.dependencies.highVulnerabilities > 0 ? "fail" : "pass"), + v2Kv("Medium", String(manifest.dependencies.mediumVulnerabilities)), + v2Kv("Last audit", manifest.dependencies.lastAudit), + ].join(""); + + const accessBody = [ + v2Kv("Branch protection", + ac.branchProtection === true ? "enabled" : ac.branchProtection === false ? "DISABLED" : "UNKNOWN", + ac.branchProtection === true ? "pass" : ac.branchProtection === false ? "fail" : "na"), + v2Kv("Required reviews", String(ac.requiredReviews ?? "\u2014")), + v2Kv("Signed commits", + ac.signedCommits === true ? "yes" : ac.signedCommits === false ? "no" : "\u2014", + ac.signedCommits === true ? "pass" : ac.signedCommits === false ? "fail" : "na"), + ].join(""); + + const aiBody = aiCount === 0 + ? `
No AI SDKs, training libs, or inference endpoints detected in this repo.
` + : ai.slice(0, 4).map(s => { + const tier = s.riskTier ?? "unknown"; + const status = tier === "high" || tier === "prohibited" ? "fail" : tier === "limited" ? "partial" : "pass"; + return v2Kv(`${s.provider} · ${s.sdk}`, tier.toUpperCase(), status); + }).join("") + (aiCount > 4 ? `
+${aiCount - 4} more — see AI tab
` : ""); + + const tpBody = tp.length === 0 + ? `
No external services detected.
` + : tp.slice(0, 6).map(s => v2Kv(s.name, s.dpaUrl ? "DPA \u2713" : "no DPA", s.dpaUrl ? "pass" : "partial")).join(""); + + // Governance artifacts: IN REPO vs SERVED (v1 logic kept intact, rendered in v2 panel shell) + const artifactRows: string[] = []; + const labels: Record = { + privacyPolicy: "Privacy Policy", + termsOfService: "Terms of Service", + securityTxt: "security.txt", + vulnerabilityDisclosure: "Vuln Disclosure", + incidentResponsePlan: "Incident Response Plan", + }; for (const [key, label] of Object.entries(labels)) { const val = (manifest.artifacts as any)[key] as string; const servedState = (served.policyServed || {})[key as keyof PolicyServedMap]; - html += `
`; + const servedText = servedState === "served" ? "served" + : servedState === "unreachable" ? "UNREACHABLE" + : servedState === "not-configured" ? "not configured" + : "\u2014"; + const servedCls = servedState === "served" ? "pass" + : servedState === "unreachable" ? "fail" + : "na"; + artifactRows.push(` +
${esc(label)}${esc(val).toUpperCase()}
+
└ served${servedText}
+ `); } - html += `
ARTIFACTIN REPOSERVED
${label}${statusIcon(val)} ${val.toUpperCase()}${servedBadge(servedState)}
`; - - // AI-specific policies (Phase 8 Sub-phase D). Only render rows whose - // artifact field is defined and not "not-applicable", so repos without the - // triggering AI scope stay clean. - const aiLabels: Record = { - aiUsagePolicy: "AI Usage Policy", - modelCards: "Model Cards", - fria: "FRIA", - }; - const aiRows: string[] = []; - for (const [key, label] of Object.entries(aiLabels)) { + // AI artifacts — only render rows that aren't N/A + const aiArtifactLabels: Record = { aiUsagePolicy: "AI Usage Policy", modelCards: "Model Cards", fria: "FRIA" }; + for (const [key, label] of Object.entries(aiArtifactLabels)) { const val = (manifest.artifacts as any)[key] as string | undefined; if (val === undefined || val === "not-applicable") continue; - aiRows.push(`${label}${statusIcon(val)} ${val.toUpperCase()}\u2014`); - } - if (aiRows.length > 0) { - html += `

AI ARTIFACTS

`; - html += ``; - html += ``; - html += aiRows.join(""); - html += `
ARTIFACTIN REPOSERVED
`; + artifactRows.push(v2Kv(label, val.toUpperCase(), val === "present" ? "pass" : val === "missing" ? "fail" : "partial")); } + const artifactsBody = `
${artifactRows.join("")}
` + + (served.policyServedCheckedAt + ? `
Served state last checked ${timeAgo(served.policyServedCheckedAt)}. CHECK PRODUCTION refreshes it.
` + : `
Served state never checked. Click CHECK PRODUCTION above to populate — only URLs declared in policy_urls: are verified.
`); + + const panelsHtml = [ + v2Panel(`DATA COLLECTION`, dataBody, { count: dc.length }), + v2Panel(`TRANSPORT`, transportBody), + v2Panel(`DEPENDENCIES`, depsBody), + v2Panel(`ACCESS CONTROLS`, accessBody), + v2Panel(aiCount > 0 ? `AI SYSTEMS` : `AI SYSTEMS · NONE`, aiBody, aiCount > 0 ? { count: aiCount } : {}), + v2Panel(`THIRD-PARTY`, tpBody, tp.length > 0 ? { count: tp.length } : {}), + v2Panel(`GOVERNANCE ARTIFACTS`, artifactsBody, { span3: true }), + ].join("\n"); + + // --- detail head (title + meta + action buttons + score trio + tabs) --- + + // Keep v1 class names (export-combo, branch-combo, check-prod-btn) for + // backward compatibility with the existing JS functions — they still work + // the same way, just styled at new positions. + const exportDropdownHtml = ` +
+ +
    +
  • MACHINE-READABLE
  • +
  • JSON (full state)
  • +
  • SARIF (code scanning)
  • +
  • OSCAL (assessment)
  • +
  • CSV
  • +
  • NIST CSF controls
  • +
  • EU AI Act articles
  • +
  • Risk register
  • +
  • Vulnerabilities
  • +
+
`; - // Freshness footer for the SERVED column. - if (served.policyServedCheckedAt) { - html += `

Served status last checked ${timeAgo(served.policyServedCheckedAt)}. Click CHECK PRODUCTION above to refresh. IN REPO reflects the scanner's view of the file on disk at the last scan; SERVED reflects an HTTP GET against the URL declared in .grc/config.yml under policy_urls:.

`; - } else { - html += `

SERVED status has never been checked for this repo. Click CHECK PRODUCTION above to populate it. Only policies with a URL declared under policy_urls: in .grc/config.yml will be checked; unlisted ones stay as NOT CONFIGURED.

`; + const checkProdBtn = hasSiteUrl + ? `` + : ""; + + const activeTab: RepoDetailTab = opts.tab ?? "overview"; + const tabLabels: Array<[RepoDetailTab, string]> = [ + ["overview", "OVERVIEW"], + ["nist", "NIST"], + ["ai", "AI"], + ["branches", "BRANCHES"], + ["trends", "TRENDS"], + ]; + const repoHref = `/?repo=${encodeURIComponent(manifest.repo)}`; + const tabHref = (tab: RepoDetailTab, label: string) => + `${label}`; + + // Body varies by tab. Non-overview tabs embed the existing specialized + // views — they were originally HTMX fragments but render fine as plain + // content inside the panel body. + let bodyHtml: string; + switch (activeTab) { + case "nist": + bodyHtml = opts.functionScores + ? renderNistView(summary, opts.functionScores) + : `
NIST data unavailable.
`; + break; + case "ai": + bodyHtml = renderAIComplianceView(manifest); + break; + case "branches": + bodyHtml = opts.branchSummaries && opts.branchSummaries.length > 0 + ? renderBranchComparison(opts.branchSummaries) + : `
Only one branch scanned for this repo.
`; + break; + case "trends": + bodyHtml = renderTrendChart(opts.history ?? [], manifest.repo, manifest.branch); + break; + default: + bodyHtml = `
${panelsHtml}
`; } - html += `
`; - return html; + return ` +
+ \u2190 REPOS +
+ ${esc(manifest.repo)} + ${esc(manifest.branch)} · ${esc(manifest.commit)} · ${timeAgo(manifest.scanDate)} +
+ ${exportDropdownHtml} + ${checkProdBtn} +
+
+ ${v2BigScore("COMPLIANCE", summary.complianceScore)} + ${v2BigScore("NIST CSF 2.0", summary.nistScore)} + ${aiCount > 0 ? v2BigScore("EU AI ACT", aiScore) : ""} +
+
+ +
${bodyHtml}
`; } export function renderNistView(summary: RepoSummary, functionScores: FunctionScore[]): string { diff --git a/dashboard/worker.ts b/dashboard/worker.ts index a214a6e..32faf21 100644 --- a/dashboard/worker.ts +++ b/dashboard/worker.ts @@ -572,13 +572,50 @@ app.get("/", async (c) => { branchesPerRepo.set(repo, branches); } - const summaries = [...byRepo.values()].map(m => summarize(m.manifest, m.siteUrl)); + const entries = [...byRepo.values()]; + const summaries = entries.map(m => summarize(m.manifest, m.siteUrl)); // Sort by most recent scan summaries.sort((a, b) => new Date(b.scanDate).getTime() - new Date(a.scanDate).getTime()); + // v2 two-pane: server-render the right detail pane for the selected repo + // (from ?repo=owner/name). Defaults to the freshest repo so users land on + // something useful instead of an empty state. `?tab=` picks the content + // inside the detail pane. + const repoQuery = parseRepoQuery(c.req.query("repo")); + const selectedRepo = (repoQuery && summaries.find(s => s.repo === repoQuery)) + ? repoQuery + : summaries[0]?.repo; + const selectedEntry = selectedRepo ? entries.find(e => e.manifest.repo === selectedRepo) : undefined; + + const validTabs = new Set(["overview", "nist", "ai", "branches", "trends"]); + const rawTab = c.req.query("tab"); + const tab: "overview" | "nist" | "ai" | "branches" | "trends" = + rawTab && validTabs.has(rawTab) ? (rawTab as any) : "overview"; + + let repoDetailHtml: string | undefined; + if (selectedEntry && selectedRepo) { + const detailSummary = summarize(selectedEntry.manifest, selectedEntry.siteUrl); + const detailOpts: Parameters[3] = { tab }; + if (tab === "nist") { + detailOpts.functionScores = getNistFunctionScores(detailSummary.nistResults); + } else if (tab === "branches") { + detailOpts.branchSummaries = all + .filter(e => e.manifest.repo === selectedRepo) + .map(e => summarize(e.manifest, e.siteUrl)); + } else if (tab === "trends") { + detailOpts.history = await getHistory(c.env.GRC_KV, selectedRepo, selectedEntry.manifest.branch); + } + repoDetailHtml = renderRepoDetail( + selectedEntry.manifest, + detailSummary, + { policyServed: selectedEntry.policyServed, policyServedCheckedAt: selectedEntry.policyServedCheckedAt }, + detailOpts, + ); + } + const orgName = c.env.ORG_NAME || ""; - return c.html(renderDashboard(summaries, branchesPerRepo, orgName)); + return c.html(renderDashboard(summaries, branchesPerRepo, orgName, { selectedRepo, repoDetailHtml })); }); app.get("/repo/:owner/:name", async (c) => { diff --git a/scripts/seed-local.ts b/scripts/seed-local.ts new file mode 100644 index 0000000..d5a3bbf --- /dev/null +++ b/scripts/seed-local.ts @@ -0,0 +1,150 @@ +/** + * Seed the local wrangler dev KV with a handful of fixture manifests so + * the v2 dashboard has realistic content to display. + * + * Run with: GRC_AUTH_BYPASS=1 npx wrangler dev (in another terminal), then + * tsx scripts/seed-local.ts + */ + +import type { Manifest } from "../scanner/types.js"; + +const BASE = process.env.DASH_URL || "http://127.0.0.1:8787"; + +function artifactsClean(): Manifest["artifacts"] { + return { + privacyPolicy: "generated", + termsOfService: "generated", + securityTxt: "present", + vulnerabilityDisclosure: "present", + incidentResponsePlan: "present", + aiUsagePolicy: "present", + modelCards: "not-applicable", + fria: "not-applicable", + }; +} + +function fixtures(): Array<{ manifest: Manifest; siteUrl: string }> { + return [ + { + manifest: { + repo: "acme/storefront", + scanDate: new Date().toISOString(), + branch: "main", + commit: "a1b2c3d", + dataCollection: [ + { type: "pii", field: "email", source: "form", file: "app/signup.tsx" }, + { type: "pii", field: "name", source: "form", file: "app/signup.tsx" }, + { type: "cookie", field: "session_id", source: "api-input", file: "lib/session.ts" }, + ] as any, + thirdPartyServices: [ + { name: "Stripe", category: "payments", dpaUrl: "https://stripe.com/dpa" }, + { name: "Sentry", category: "monitoring", dpaUrl: "https://sentry.io/legal/dpa/" }, + { name: "PostHog", category: "analytics" }, + ] as any, + securityHeaders: { + csp: "present", hsts: "present", xFrameOptions: "present", + xContentTypeOptions: "present", referrerPolicy: "present", permissionsPolicy: "missing", + }, + https: { enforced: true, certExpiry: "2026-11-30" }, + dependencies: { + total: 412, criticalVulnerabilities: 0, highVulnerabilities: 1, + mediumVulnerabilities: 4, lastAudit: new Date().toISOString().slice(0, 10), + } as any, + secretsScan: { detected: false, findings: [] }, + artifacts: artifactsClean(), + accessControls: { branchProtection: true, requiredReviews: 2, signedCommits: true }, + aiSystems: [ + { + provider: "OpenAI", sdk: "openai", location: "package.json", + category: "inference", dataFlows: [], riskTier: "limited", + riskTierSource: "heuristic", euMarket: true, + usageLocations: ["app/search/suggest.ts"], + } as any, + ], + }, + siteUrl: "https://acme-storefront.example.com", + }, + { + manifest: { + repo: "acme/hiring-portal", + scanDate: new Date(Date.now() - 3_600_000).toISOString(), + branch: "main", + commit: "e4f5g6h", + dataCollection: [ + { type: "pii", field: "resume", source: "form", file: "app/apply.tsx" }, + ] as any, + thirdPartyServices: [ + { name: "Greenhouse", category: "ats" }, + ] as any, + securityHeaders: { + csp: "missing", hsts: "present", xFrameOptions: "present", + xContentTypeOptions: "present", referrerPolicy: "missing", permissionsPolicy: "missing", + }, + https: { enforced: true, certExpiry: "2026-08-15" }, + dependencies: { + total: 203, criticalVulnerabilities: 1, highVulnerabilities: 3, + mediumVulnerabilities: 7, lastAudit: new Date().toISOString().slice(0, 10), + } as any, + secretsScan: { detected: false, findings: [] }, + artifacts: { ...artifactsClean(), modelCards: "missing", fria: "missing" }, + accessControls: { branchProtection: true, requiredReviews: 1, signedCommits: false }, + aiSystems: [ + { + provider: "OpenAI", sdk: "openai", location: "package.json", + category: "inference", dataFlows: [], riskTier: "high", + riskTierSource: "heuristic", euMarket: true, + usageLocations: ["app/hiring/screen-resume.ts"], + riskReasoning: "Employment decision — Annex III high-risk use case.", + } as any, + ], + }, + siteUrl: "https://jobs.acme.example.com", + }, + { + manifest: { + repo: "acme/internal-docs", + scanDate: new Date(Date.now() - 86_400_000).toISOString(), + branch: "main", + commit: "i7j8k9l", + dataCollection: [], + thirdPartyServices: [], + securityHeaders: null, + https: null, + dependencies: { + total: 89, criticalVulnerabilities: 0, highVulnerabilities: 0, + mediumVulnerabilities: 0, lastAudit: new Date().toISOString().slice(0, 10), + } as any, + secretsScan: { detected: true, findings: ["Stripe live key found in scripts/deploy.sh:14"] }, + artifacts: { + privacyPolicy: "missing", termsOfService: "missing", + securityTxt: "missing", vulnerabilityDisclosure: "missing", + incidentResponsePlan: "missing", + }, + accessControls: { branchProtection: false, requiredReviews: 0, signedCommits: false }, + aiSystems: [], + }, + siteUrl: "", + }, + ]; +} + +async function main() { + let posted = 0; + for (const { manifest, siteUrl } of fixtures()) { + const url = `${BASE}/api/report${siteUrl ? `?site_url=${encodeURIComponent(siteUrl)}` : ""}`; + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(manifest), + }); + if (!res.ok) { + console.error(`FAIL ${manifest.repo}: ${res.status} ${await res.text()}`); + process.exit(1); + } + console.log(`ok ${manifest.repo}`); + posted++; + } + console.log(`\nSeeded ${posted} fixture repos. Open ${BASE}/`); +} + +main();