generated from coulomb/repo-seed
EP catalogue (all domains): - EP-RAIL-001 ep_id patched (schema fix: add ep_id to EPUpdate) - EP-RAIL-003 (git bare-repo mirrors) and EP-RAIL-004 (offsite secondary backup) registered from railiance-cluster/docs/backup-restore.md - EP-CUST-003..007 ep_ids assigned to existing custodian EPs - EP-CUST-008 (State Hub API auth) and EP-CUST-009 (update_workstream MCP tool) registered as new custodian extension points TD catalogue (railiance — first 5 items): - TD-RAIL-001: backup cron runs as root without audit trail (high/security) - TD-RAIL-002: k3s kubeconfig world-readable mode 644 (medium/security) - TD-RAIL-003: no Ansible role unit tests (medium/test) - TD-RAIL-004: age key extracted via awk — fragile (medium/impl) - TD-RAIL-005: etcd snapshot retention uncoordinated (low/impl) Dashboard (T08 + T10): - Extract API URL and POLL to src/components/config.js; all 15 pages now import from the shared module (contributions/goals keep custom POLL) - Shared .kpi-infobox, .filter-bar, .filter-search/.filter-owner CSS moved to observablehq.config.js head <style> block; removed from 9 pages - Build: 0 errors, 0 warnings API (T09): - progress.py: limit param now Query(100, le=1000) — prevents unbounded list requests; closes TD-CUST-004 for the only endpoint that had limit CUST-WP-0004 marked completed (all 10 tasks done). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
13 KiB
Markdown
333 lines
13 KiB
Markdown
---
|
|
title: SBOM
|
|
---
|
|
|
|
```js
|
|
import {API} from "./components/config.js";
|
|
```
|
|
|
|
```js
|
|
// Fetch SBOM data on load
|
|
let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [], _domains = [], _snapshots = [];
|
|
try {
|
|
[_entries, _report, _repos, _domains, _snapshots] = await Promise.all([
|
|
fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []),
|
|
fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}),
|
|
fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []),
|
|
fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []),
|
|
fetch(`${API}/sbom/snapshots/`).then(r => r.ok ? r.json() : []),
|
|
]);
|
|
} catch {}
|
|
```
|
|
|
|
```js
|
|
const entries = _entries ?? [];
|
|
const report = _report ?? {groups: [], copyleft_direct_count: 0};
|
|
const repos = _repos ?? [];
|
|
const domains = _domains ?? [];
|
|
const snapshots = _snapshots ?? [];
|
|
const groups = report.groups ?? [];
|
|
const riskCount = report.copyleft_direct_count ?? 0;
|
|
|
|
// Domain + repo lookups
|
|
const domainById = Object.fromEntries(domains.map(d => [d.id, d]));
|
|
const repoById = Object.fromEntries(repos.map(r => [r.id, r]));
|
|
const repoDomain = Object.fromEntries(repos.map(r => [r.id, domainById[r.domain_id]?.slug ?? "—"]));
|
|
const domainSlugs = [...new Set(repos.map(r => repoDomain[r.id]).filter(s => s !== "—"))].sort();
|
|
|
|
// Copyleft detector (mirrors server-side logic)
|
|
const COPYLEFT_KW = ["GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"];
|
|
const isCopyleft = spdx => spdx && COPYLEFT_KW.some(k => spdx.toUpperCase().includes(k));
|
|
```
|
|
|
|
# SBOM
|
|
|
|
```js
|
|
import {withDocHelp} from "./components/doc-overlay.js";
|
|
const _h1 = document.querySelector("#observablehq-main h1");
|
|
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/sbom"); }
|
|
```
|
|
|
|
## Overview
|
|
|
|
```js
|
|
const riskBadge = riskCount === 0
|
|
? html`<span class="risk-ok">✓ No copyleft in direct prod deps</span>`
|
|
: html`<span class="risk-warn">⚠ ${riskCount} direct prod dep(s) with copyleft licence</span>`;
|
|
display(html`<div class="kpi-row">
|
|
<div class="card">
|
|
<h3>Total Packages</h3>
|
|
<p class="big-num">${entries.length}</p>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Repos Scanned</h3>
|
|
<p class="big-num">${new Set(entries.map(e => e.repo_id)).size}</p>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Domains Covered</h3>
|
|
<p class="big-num">${domainSlugs.length || new Set(Object.values(repoDomain).filter(s => s !== "—")).size}</p>
|
|
</div>
|
|
<div class="card ${riskCount > 0 ? 'card-warn' : ''}">
|
|
<h3>Licence Risk</h3>
|
|
<p class="big-num">${riskCount}</p>
|
|
<small>${riskBadge}</small>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Unique Licences</h3>
|
|
<p class="big-num">${groups.length}</p>
|
|
</div>
|
|
</div>`);
|
|
```
|
|
|
|
## By Domain
|
|
|
|
```js
|
|
if (entries.length === 0) {
|
|
display(html`<p style="color:gray">No SBOM data ingested yet. Run <code>make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=<path></code>.</p>`);
|
|
} else {
|
|
// Group entries by domain
|
|
const byDomain = {};
|
|
for (const e of entries) {
|
|
const slug = repoDomain[e.repo_id] ?? "—";
|
|
(byDomain[slug] = byDomain[slug] ?? []).push(e);
|
|
}
|
|
|
|
const domainTableRows = Object.entries(byDomain).map(([slug, es]) => {
|
|
const dom = domains.find(d => d.slug === slug);
|
|
const repoCount = new Set(es.map(e => e.repo_id)).size;
|
|
const directProd = es.filter(e => e.is_direct && !e.is_dev);
|
|
const copyleftRisk = directProd.filter(e => isCopyleft(e.license_spdx)).length;
|
|
const ecosystems = [...new Set(es.map(e => e.ecosystem))].sort().join(", ");
|
|
return {
|
|
domain: dom?.name ?? slug,
|
|
repos: repoCount,
|
|
packages: es.length,
|
|
direct: directProd.length,
|
|
copyleft: copyleftRisk,
|
|
ecosystems,
|
|
};
|
|
}).sort((a, b) => a.domain.localeCompare(b.domain));
|
|
|
|
display(Inputs.table(domainTableRows, {
|
|
columns: ["domain", "repos", "packages", "direct", "copyleft", "ecosystems"],
|
|
header: {domain: "Domain", repos: "Repos", packages: "All Pkgs", direct: "Direct Prod", copyleft: "Copyleft ⚠", ecosystems: "Ecosystems"},
|
|
maxWidth: 900,
|
|
}));
|
|
}
|
|
```
|
|
|
|
## Licence Distribution
|
|
|
|
```js
|
|
import * as Plot from "npm:@observablehq/plot";
|
|
|
|
if (groups.length === 0) {
|
|
display(html`<p style="color:gray">No SBOM data ingested yet.</p>`);
|
|
} else {
|
|
const plotData = groups.slice(0, 15).map(g => ({
|
|
licence: g.license_spdx ?? "(unknown)",
|
|
count: g.count,
|
|
copyleft: g.is_copyleft,
|
|
}));
|
|
display(Plot.plot({
|
|
x: {label: "Packages"},
|
|
y: {label: null, domain: plotData.map(d => d.licence)},
|
|
color: {domain: [false, true], range: ["steelblue", "#e53935"], legend: true, tickFormat: d => d ? "Copyleft" : "Permissive"},
|
|
marks: [
|
|
Plot.barX(plotData, {y: "licence", x: "count", fill: "copyleft", tip: true}),
|
|
Plot.ruleX([0]),
|
|
],
|
|
marginLeft: 130,
|
|
height: Math.max(80, plotData.length * 30 + 50),
|
|
width: 600,
|
|
}));
|
|
}
|
|
```
|
|
|
|
## Copyleft Risk Detail
|
|
|
|
```js
|
|
const copyleftGroups = groups.filter(g => g.is_copyleft);
|
|
if (copyleftGroups.length === 0) {
|
|
display(html`<p style="color:green">✓ No copyleft packages found.</p>`);
|
|
} else {
|
|
display(html`<div class="copyleft-section">
|
|
${copyleftGroups.map(g => html`
|
|
<div class="copyleft-card">
|
|
<span class="copyleft-badge">${g.license_spdx ?? "unknown"}</span>
|
|
<span class="copyleft-count">${g.count} package(s)</span>
|
|
<span class="copyleft-repos">${g.repos.join(", ")}</span>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
<p style="font-size:0.8rem;color:gray">Note: dual-licensed packages (e.g. "MIT OR GPL-3.0") are flagged conservatively. Review if the non-copyleft variant is used.</p>`);
|
|
}
|
|
```
|
|
|
|
## By Repo
|
|
|
|
```js
|
|
// Group entries by repo, sorted by domain then repo name
|
|
const byRepo = {};
|
|
for (const e of entries) {
|
|
(byRepo[e.repo_id] = byRepo[e.repo_id] ?? []).push(e);
|
|
}
|
|
|
|
const repoSections = Object.entries(byRepo)
|
|
.map(([repoId, es]) => {
|
|
const repo = repoById[repoId];
|
|
const domSlug = repoDomain[repoId] ?? "—";
|
|
const dom = domains.find(d => d.slug === domSlug);
|
|
const directProd = es.filter(e => e.is_direct && !e.is_dev);
|
|
const copyleftRisk = directProd.filter(e => isCopyleft(e.license_spdx)).length;
|
|
const ecosystems = [...new Set(es.map(e => e.ecosystem))].sort();
|
|
return { repoId, repo, dom, domSlug, es, directProd, copyleftRisk, ecosystems };
|
|
})
|
|
.sort((a, b) => (a.domSlug + a.repo?.slug).localeCompare(b.domSlug + b.repo?.slug));
|
|
|
|
if (repoSections.length === 0) {
|
|
display(html`<p style="color:gray">No repo data.</p>`);
|
|
} else {
|
|
display(html`<div class="repo-list">
|
|
${repoSections.map(({repoId, repo, dom, domSlug, es, directProd, copyleftRisk, ecosystems}) => html`
|
|
<details class="repo-details">
|
|
<summary class="repo-summary">
|
|
<span class="repo-domain-tag">${dom?.name ?? domSlug}</span>
|
|
<span class="repo-name">${repo?.slug ?? repoId.slice(0,8)}</span>
|
|
<span class="repo-meta">${es.length} pkgs · ${ecosystems.join(" + ")} · ${directProd.length} direct</span>
|
|
${copyleftRisk > 0 ? html`<span class="repo-risk-badge">⚠ ${copyleftRisk} copyleft</span>` : ""}
|
|
</summary>
|
|
<div class="repo-pkg-table">
|
|
${Inputs.table(es.slice(0, 200).map(e => ({
|
|
Package: e.package_name,
|
|
Version: e.package_version ?? "—",
|
|
Ecosystem: e.ecosystem,
|
|
Licence: e.license_spdx ?? "—",
|
|
Direct: e.is_direct ? "✓" : "",
|
|
Dev: e.is_dev ? "✓" : "",
|
|
})), {maxWidth: 860})}
|
|
${es.length > 200 ? html`<p style="font-size:0.8rem;color:gray">Showing first 200 of ${es.length}</p>` : ""}
|
|
</div>
|
|
</details>
|
|
`)}
|
|
</div>`);
|
|
}
|
|
```
|
|
|
|
## Snapshot History
|
|
|
|
```js
|
|
if (snapshots.length === 0) {
|
|
display(html`<p style="color:gray">No snapshots recorded yet.</p>`);
|
|
} else {
|
|
// Group by repo, sort newest first within each group
|
|
const snapByRepo = {};
|
|
for (const s of snapshots) {
|
|
(snapByRepo[s.repo_id] = snapByRepo[s.repo_id] ?? []).push(s);
|
|
}
|
|
|
|
const repoOrder = Object.keys(snapByRepo).sort((a, b) => {
|
|
const ra = repos.find(r => r.id === a);
|
|
const rb = repos.find(r => r.id === b);
|
|
return (ra?.slug ?? a).localeCompare(rb?.slug ?? b);
|
|
});
|
|
|
|
display(html`<div class="snap-list">
|
|
${repoOrder.map(repoId => {
|
|
const repo = repos.find(r => r.id === repoId);
|
|
const domSlug = repo ? domains.find(d => d.id === repo.domain_id)?.slug ?? "—" : "—";
|
|
const snaps = snapByRepo[repoId]; // already sorted newest-first by API
|
|
return html`<details class="snap-repo-block">
|
|
<summary class="snap-repo-summary">
|
|
<span class="repo-domain-tag">${domSlug}</span>
|
|
<span class="snap-repo-name">${repo?.slug ?? repoId.slice(0,8)}</span>
|
|
<span class="snap-meta">${snaps.length} snapshot${snaps.length !== 1 ? "s" : ""}</span>
|
|
</summary>
|
|
<div class="snap-table-wrap">
|
|
${Inputs.table(snaps.map(s => ({
|
|
"Snapshot At": new Date(s.snapshot_at).toLocaleString(),
|
|
Packages: s.entry_count,
|
|
Source: s.source ?? "—",
|
|
ID: s.id.slice(0, 8) + "…",
|
|
})), {maxWidth: 700})}
|
|
</div>
|
|
</details>`;
|
|
})}
|
|
</div>`);
|
|
}
|
|
```
|
|
|
|
## Package Table
|
|
|
|
```js
|
|
// Filters
|
|
const domainOpts = ["all", ...domainSlugs];
|
|
const domainFilter = Inputs.select(domainOpts, {label: "Domain", value: "all"});
|
|
const ecoFilter = Inputs.select(["all", "python", "node", "rust", "go", "java", "other"], {label: "Ecosystem", value: "all"});
|
|
const directOnly = Inputs.toggle({label: "Direct deps only", value: false});
|
|
const prodOnly = Inputs.toggle({label: "Prod deps only (no dev)", value: false});
|
|
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
|
|
${domainFilter}${ecoFilter}${directOnly}${prodOnly}
|
|
</div>`);
|
|
```
|
|
|
|
```js
|
|
const filteredEntries = entries.filter(e =>
|
|
(domainFilter.value === "all" || repoDomain[e.repo_id] === domainFilter.value) &&
|
|
(ecoFilter.value === "all" || e.ecosystem === ecoFilter.value) &&
|
|
(!directOnly.value || e.is_direct) &&
|
|
(!prodOnly.value || !e.is_dev)
|
|
);
|
|
|
|
display(Inputs.table(filteredEntries.map(e => ({
|
|
Package: e.package_name,
|
|
Version: e.package_version ?? "—",
|
|
Ecosystem: e.ecosystem,
|
|
Licence: e.license_spdx ?? "—",
|
|
Domain: repoDomain[e.repo_id] ?? "—",
|
|
Repo: repoById[e.repo_id]?.slug ?? e.repo_id?.slice(0, 8) ?? "—",
|
|
Direct: e.is_direct ? "✓" : "",
|
|
Dev: e.is_dev ? "✓" : "",
|
|
})), {maxWidth: 960}));
|
|
```
|
|
|
|
<style>
|
|
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
|
.card-warn { border: 2px solid #e53935; }
|
|
.kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
|
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
|
|
.risk-ok { color: #2e7d32; font-weight: 600; }
|
|
.risk-warn { color: #e53935; font-weight: 600; }
|
|
|
|
.copyleft-section { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
|
|
.copyleft-card { background: #fde8e8; border-left: 4px solid #e53935; border-radius: 6px; padding: 0.5rem 0.9rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
|
|
.copyleft-badge { font-weight: 700; font-size: 0.85rem; color: #c62828; }
|
|
.copyleft-count { font-size: 0.82rem; color: #555; }
|
|
.copyleft-repos { font-size: 0.8rem; color: gray; font-family: monospace; }
|
|
|
|
.repo-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; }
|
|
.repo-details { background: var(--theme-background-alt); border-radius: 8px; }
|
|
.repo-details[open] { border: 1px solid var(--theme-foreground-faint); }
|
|
.repo-summary { cursor: pointer; padding: 0.65rem 0.9rem; display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; list-style: none; }
|
|
.repo-summary::-webkit-details-marker { display: none; }
|
|
.repo-summary::before { content: "▶"; font-size: 0.7rem; color: gray; flex-shrink: 0; }
|
|
details[open] > .repo-summary::before { content: "▼"; }
|
|
.repo-domain-tag { font-size: 0.7rem; font-weight: 600; background: var(--theme-background); border: 1px solid var(--theme-foreground-faint); border-radius: 10px; padding: 0.1rem 0.45rem; color: steelblue; }
|
|
.repo-name { font-weight: 600; font-size: 0.9rem; font-family: monospace; }
|
|
.repo-meta { font-size: 0.78rem; color: gray; }
|
|
.repo-risk-badge { font-size: 0.75rem; font-weight: 600; color: #c62828; background: #fde8e8; border-radius: 4px; padding: 0.1rem 0.4rem; }
|
|
.repo-pkg-table { padding: 0.5rem 0.75rem 0.75rem; }
|
|
|
|
/* ── Snapshot history ─────────────────────────────────────────────────────── */
|
|
.snap-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; }
|
|
.snap-repo-block { background: var(--theme-background-alt); border-radius: 8px; }
|
|
.snap-repo-block[open] { border: 1px solid var(--theme-foreground-faint); }
|
|
.snap-repo-summary { cursor: pointer; padding: 0.65rem 0.9rem; display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; list-style: none; }
|
|
.snap-repo-summary::-webkit-details-marker { display: none; }
|
|
.snap-repo-summary::before { content: "▶"; font-size: 0.7rem; color: gray; flex-shrink: 0; }
|
|
details[open] > .snap-repo-summary::before { content: "▼"; }
|
|
.snap-repo-name { font-weight: 600; font-size: 0.9rem; font-family: monospace; }
|
|
.snap-meta { font-size: 0.78rem; color: gray; }
|
|
.snap-table-wrap { padding: 0.5rem 0.75rem 0.75rem; }
|
|
</style>
|