151 lines
4.9 KiB
Markdown
151 lines
4.9 KiB
Markdown
|
|
---
|
||
|
|
title: SBOM
|
||
|
|
---
|
||
|
|
|
||
|
|
```js
|
||
|
|
const API = "http://127.0.0.1:8000";
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Fetch SBOM data on load
|
||
|
|
let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [];
|
||
|
|
try {
|
||
|
|
[_entries, _report, _repos] = 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() : []),
|
||
|
|
]);
|
||
|
|
} catch {}
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
const entries = _entries ?? [];
|
||
|
|
const report = _report ?? {groups: [], copyleft_direct_count: 0};
|
||
|
|
const repos = _repos ?? [];
|
||
|
|
const groups = report.groups ?? [];
|
||
|
|
const riskCount = report.copyleft_direct_count ?? 0;
|
||
|
|
```
|
||
|
|
|
||
|
|
# SBOM
|
||
|
|
|
||
|
|
## Licence Risk
|
||
|
|
|
||
|
|
```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 ${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>`);
|
||
|
|
```
|
||
|
|
|
||
|
|
## 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. Run <code>make ingest-sbom REPO=<slug></code>.</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>`);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Package Table
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Filters
|
||
|
|
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">
|
||
|
|
${ecoFilter}${directOnly}${prodOnly}
|
||
|
|
</div>`);
|
||
|
|
```
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Build repo_id → slug lookup
|
||
|
|
const repoById = Object.fromEntries(_repos.map(r => [r.id, r.slug]));
|
||
|
|
|
||
|
|
const filteredEntries = entries.filter(e =>
|
||
|
|
(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 ?? "—",
|
||
|
|
Repo: repoById[e.repo_id] ?? e.repo_id?.slice(0, 8) ?? "—",
|
||
|
|
Direct: e.is_direct ? "✓" : "",
|
||
|
|
Dev: e.is_dev ? "✓" : "",
|
||
|
|
})), {maxWidth: 900}));
|
||
|
|
```
|
||
|
|
|
||
|
|
<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(180px, 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; }
|
||
|
|
</style>
|