Files
the-custodian/state-hub/dashboard/src/sbom.md
tegwick 7df0152ca7 docs(sbom): add SBOM reference page + withDocHelp on SBOM dashboard
- docs/sbom.md: what SBOM is, lockfile semantics, 5-level maturity standard,
  gap types A–E, per-ecosystem guidance, Syft OSS tooling, inter-repo task
  communication convention, ingest commands, compliance check commands
- sbom.md: wire withDocHelp(h1, "/docs/sbom") — ? button on page title
- observablehq.config.js: add SBOM entry to Reference nav section

EP-CUST-002 registered: Syft-based comprehensive SBOM generation
Task 5f8cade5 created: [repo:railiance-bootstrap] Add Ansible lockfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 19:29:20 +01:00

11 KiB

title
title
SBOM
const API = "http://127.0.0.1:8000";
// Fetch SBOM data on load
let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [], _domains = [];
try {
  [_entries, _report, _repos, _domains] = 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() : []),
  ]);
} catch {}
const entries  = _entries ?? [];
const report   = _report  ?? {groups: [], copyleft_direct_count: 0};
const repos    = _repos   ?? [];
const domains  = _domains ?? [];
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

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

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

if (entries.length === 0) {
  display(html`<p style="color:gray">No SBOM data ingested yet. Run <code>make ingest-sbom REPO=&lt;slug&gt; SCAN=1 REPO_PATH=&lt;path&gt;</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

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

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

// 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>`);
}

Package Table

// 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>`);
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; } </style>