bompage 1.2.0__tar.gz → 1.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bompage-1.2.0 → bompage-1.2.2}/.gitlab-ci.yml +4 -0
- {bompage-1.2.0 → bompage-1.2.2}/PKG-INFO +1 -1
- bompage-1.2.2/docs/dashboard.md +49 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/index.md +1 -1
- {bompage-1.2.0 → bompage-1.2.2}/mkdocs.yml +1 -0
- {bompage-1.2.0 → bompage-1.2.2}/pyproject.toml +1 -1
- {bompage-1.2.0 → bompage-1.2.2}/scripts/gen-demo-data.py +3 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/bompage.py +17 -1
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/build.py +9 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/metadata.py +74 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/push.py +153 -66
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/data.py +11 -1
- bompage-1.2.2/src/bompage/web/assets/index-CBZgukme.css +1 -0
- bompage-1.2.0/src/bompage/web/assets/index-EGYiANsj.js → bompage-1.2.2/src/bompage/web/assets/index-D_clWxBW.js +195 -187
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/index.html +2 -2
- {bompage-1.2.0 → bompage-1.2.2}/tests/render/test_data.py +34 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_build.py +53 -0
- bompage-1.2.2/tests/test_metadata.py +316 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_push_report.py +248 -0
- bompage-1.2.2/web-src/app.test.ts +206 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/app.ts +55 -19
- {bompage-1.2.0 → bompage-1.2.2}/web-src/lib.test.ts +104 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/lib.ts +80 -3
- {bompage-1.2.0 → bompage-1.2.2}/web-src/theme.css +33 -1
- bompage-1.2.0/src/bompage/web/assets/index-Dj5N8UqO.css +0 -1
- bompage-1.2.0/tests/test_metadata.py +0 -125
- bompage-1.2.0/web-src/app.test.ts +0 -154
- {bompage-1.2.0 → bompage-1.2.2}/.gitignore +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.gitleaks.toml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.markdownlint.yaml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.node-version +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.pre-commit-config.yaml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.python-version +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.releaserc +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/.yamllint +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/CONTRIBUTING.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/LICENSE +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/README.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/SECURITY.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/Taskfile.project.yml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/Taskfile.yml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/biome.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/deploy/.gitlab-ci.yml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/deploy/README.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/deploy/reports/example-component/.gitkeep +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/deploy/trust/allowed_signers +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/deploy/web/.gitkeep +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/drift.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/freshness.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/inventory.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/timeline.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/bompage.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/build.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/cli.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/discovery.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/metadata.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/models.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/parsers/base.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/parsers/cyclonedx.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/parsers/spdx.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/prune.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/push.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/render/api.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/render/data.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/render/web.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/docs/verify.md +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/lychee.toml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/package.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/pnpm-lock.yaml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/pnpm-workspace.yaml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/renovate.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/scripts/merge_sbom_licenses.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/scripts/subset-icons.mjs +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/_git.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/_time.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/_version.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/drift.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/freshness.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/inventory.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/timeline.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/attest.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/discovery.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/models.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/base.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/cyclonedx.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/spdx.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/prune.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/py.typed +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/api.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/web.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/android-chrome-192x192.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/android-chrome-512x512.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/apple-touch-icon.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/bompage-D8Wv-XTb.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/material-symbols-outlined-CQbRTMbb.woff2 +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/roboto-latin-ext-wght-normal-DYIxWhlt.woff2 +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/roboto-latin-wght-normal-ccAYIvAh.woff2 +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/favicon-16x16.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/favicon-32x32.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/favicon.ico +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/site.webmanifest +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/templates/push-report.yml +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_drift.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_freshness.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_inventory.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_timeline.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/conftest.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/files/cyclonedx/simple.cdx.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/files/reports/app-api/sbom-20260115.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/files/spdx/simple.spdx.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/files/spdx/web-frontend.spdx.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/test_base.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/test_cyclonedx.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/test_spdx.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/render/__init__.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/render/test_web.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/scripts/test_merge_sbom_licenses.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_attest.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_bompage.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_dashboard_e2e.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_discovery.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_models.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_prune.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_prune_report.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tests/test_time.py +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/tsconfig.json +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/uv.lock +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/vite.config.ts +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/vitest.config.ts +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/bompage.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/index.html +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/android-chrome-192x192.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/android-chrome-512x512.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/apple-touch-icon.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/favicon-16x16.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/favicon-32x32.png +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/favicon.ico +0 -0
- {bompage-1.2.0 → bompage-1.2.2}/web-src/public/site.webmanifest +0 -0
|
@@ -517,6 +517,10 @@ push-sbom:
|
|
|
517
517
|
--branch main
|
|
518
518
|
--sbom-version "${CI_COMMIT_TAG#v}"
|
|
519
519
|
--sbom-format cyclonedx
|
|
520
|
+
--source-project "$CI_PROJECT_URL"
|
|
521
|
+
--source-commit "$CI_COMMIT_SHA"
|
|
522
|
+
--source-ref "$CI_COMMIT_TAG"
|
|
523
|
+
--ci-job-url "$CI_JOB_URL"
|
|
520
524
|
# No $DISABLE_JOBS guard: the tag pipeline sets DISABLE_JOBS to switch off
|
|
521
525
|
# lint/test (see `workflow:`), and the SBOM push must still run.
|
|
522
526
|
rules:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bompage
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: A generic, server-less system to track SBOM history of N components and publish a static dashboard
|
|
5
5
|
Project-URL: Homepage, https://gitlab.com/op_so/pages/bompage
|
|
6
6
|
Project-URL: Documentation, https://op_so.gitlab.io/pages/bompage/
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Reading the dashboard
|
|
2
|
+
|
|
3
|
+
This page is for anyone browsing the published dashboard — not for `bompage`
|
|
4
|
+
maintainers reading the source (see the other pages for that). It explains
|
|
5
|
+
what each badge next to a component means.
|
|
6
|
+
|
|
7
|
+
## The "Verified" badge
|
|
8
|
+
|
|
9
|
+
Every push can carry a cryptographic attestation: an SSH signature proving
|
|
10
|
+
the SBOM was produced and pushed by a trusted pipeline (see
|
|
11
|
+
[`bompage verify`](verify.md)). The dashboard turns that into a "Verified"
|
|
12
|
+
badge, shown both as a compact icon in the tab strip and as a clickable
|
|
13
|
+
button on the component panel (click it to see the signer and public key).
|
|
14
|
+
|
|
15
|
+
The badge has three states:
|
|
16
|
+
|
|
17
|
+
| State | Color | Meaning |
|
|
18
|
+
| --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
19
|
+
| **Full** | 🟢 Green | The latest push is signed **and** every snapshot kept for this component (including any pruned-but-still-attested one) has always been signed. The whole visible history is trustworthy. |
|
|
20
|
+
| **Partial** | 🟠 Orange | The latest push is signed and verified, but something earlier in this component's history is not — usually because those snapshots predate the attestation feature, or a past verification could not run (e.g. a temporary tooling issue). Click the badge for the exact reason. |
|
|
21
|
+
| *(no badge)* | — | Either the latest push itself isn't signed, or a real problem was found (a forged signature, a broken provenance chain, or an unusually slow push) — that case is flagged separately by the red attestation-problem badge instead, next to the same component. |
|
|
22
|
+
|
|
23
|
+
A few things worth knowing:
|
|
24
|
+
|
|
25
|
+
- **Orange is not a warning about the current push.** The snapshot you're
|
|
26
|
+
looking at right now is genuinely signed and verified; orange only means
|
|
27
|
+
the component's *older* history isn't fully covered. This is expected and
|
|
28
|
+
permanent for any component adopting signing partway through its life —
|
|
29
|
+
see the next point for when it clears.
|
|
30
|
+
- **Orange clears on its own.** Once the old, never-signed snapshots age out
|
|
31
|
+
through the repository's normal retention/pruning, and every snapshot that
|
|
32
|
+
remains is properly signed, the badge turns green on the next dashboard
|
|
33
|
+
build — no action needed.
|
|
34
|
+
- **A real problem (forged signature, broken chain) never self-clears this
|
|
35
|
+
way.** If a signed snapshot's payload is later found forged, that signal is
|
|
36
|
+
kept even after its raw SBOM is pruned, specifically so it can't be waited
|
|
37
|
+
out.
|
|
38
|
+
|
|
39
|
+
### Provenance
|
|
40
|
+
|
|
41
|
+
The component's metadata table can also show where its latest push came
|
|
42
|
+
from — source repository, commit, ref, CI job, SBOM format/tool — when the
|
|
43
|
+
pipeline that pushed it declared them (`bompage push --source-project`,
|
|
44
|
+
`--source-commit`, `--source-ref`, `--ci-job-url`). It renders exactly like
|
|
45
|
+
any other metadata row: URLs as clickable links, everything else as plain
|
|
46
|
+
text — and it shows up whether or not the push ended up "Verified", so it's
|
|
47
|
+
still there to help trace a signature problem back to its pipeline. It is
|
|
48
|
+
never itself checked — a forged push could claim any origin it likes — so
|
|
49
|
+
treat it as a hint, not as a second layer of verification.
|
|
@@ -739,6 +739,8 @@ def _sign_snapshot(
|
|
|
739
739
|
minutes later, so ``lag`` reads ``ok``.
|
|
740
740
|
"""
|
|
741
741
|
generated_at = datetime(scan.year, scan.month, scan.day, 4, 12, tzinfo=UTC)
|
|
742
|
+
rng = random.Random(f"{SEED}:commit:{component}:{scan.isoformat()}")
|
|
743
|
+
commit = "".join(rng.choices("0123456789abcdef", k=40))
|
|
742
744
|
att, _sig = write_attestation(
|
|
743
745
|
target,
|
|
744
746
|
component=component,
|
|
@@ -746,6 +748,7 @@ def _sign_snapshot(
|
|
|
746
748
|
generated_at=generated_at,
|
|
747
749
|
provenance={
|
|
748
750
|
"source_project": f"https://gitlab.example/acme/{component}",
|
|
751
|
+
"source_commit": commit,
|
|
749
752
|
"source_ref": "main",
|
|
750
753
|
"ci_job_url": f"https://gitlab.example/acme/{component}/-/jobs/{scan:%Y%m%d}",
|
|
751
754
|
},
|
|
@@ -37,7 +37,7 @@ from bompage.build import (
|
|
|
37
37
|
UnsafeOutputError,
|
|
38
38
|
build_site,
|
|
39
39
|
)
|
|
40
|
-
from bompage.metadata import parse_component, parse_global
|
|
40
|
+
from bompage.metadata import parse_component, parse_global, parse_push
|
|
41
41
|
from bompage.prune import prune_report
|
|
42
42
|
from bompage.push import PushError, push_report
|
|
43
43
|
|
|
@@ -293,6 +293,17 @@ def push(
|
|
|
293
293
|
ci_job_url: Annotated[
|
|
294
294
|
str | None, typer.Option(help="CI job URL, recorded in the attestation.")
|
|
295
295
|
] = None,
|
|
296
|
+
metadata: Annotated[
|
|
297
|
+
list[str] | None,
|
|
298
|
+
typer.Option(
|
|
299
|
+
"--metadata",
|
|
300
|
+
help='Metadata row "key=value" for this component, written to its own '
|
|
301
|
+
"metadata.json (read back by build, overridable with "
|
|
302
|
+
"--metadata-component); repeatable. Omit to leave any existing "
|
|
303
|
+
"metadata untouched -- each call that passes it replaces the "
|
|
304
|
+
"file's whole content.",
|
|
305
|
+
),
|
|
306
|
+
] = None,
|
|
296
307
|
) -> None:
|
|
297
308
|
"""Publish one SBOM into the central repository.
|
|
298
309
|
|
|
@@ -309,6 +320,10 @@ def push(
|
|
|
309
320
|
"ci_job_url": ci_job_url or "",
|
|
310
321
|
"sbom_format": sbom_format or "",
|
|
311
322
|
}
|
|
323
|
+
try:
|
|
324
|
+
metadata_rows = parse_push(metadata) if metadata is not None else None
|
|
325
|
+
except ValueError as err:
|
|
326
|
+
raise typer.BadParameter(str(err)) from err
|
|
312
327
|
try:
|
|
313
328
|
outcome = push_report(
|
|
314
329
|
component=component,
|
|
@@ -322,6 +337,7 @@ def push(
|
|
|
322
337
|
git_email=git_email,
|
|
323
338
|
sign_key=sign_key or None,
|
|
324
339
|
provenance=provenance,
|
|
340
|
+
metadata=metadata_rows,
|
|
325
341
|
)
|
|
326
342
|
except PushError as err:
|
|
327
343
|
typer.echo(f"push: {err}", err=True)
|
|
@@ -248,6 +248,15 @@ def _component_report(
|
|
|
248
248
|
# dashboard showed a partial inventory with no sign a piece was missing.
|
|
249
249
|
excluded = [(file_name, "undated name") for file_name in files.undated]
|
|
250
250
|
|
|
251
|
+
# The component's own pushed metadata (`bompage push --metadata`), merged
|
|
252
|
+
# with `--metadata-component` -- the CLI wins per key so the central
|
|
253
|
+
# operator keeps the final word without coordinating with every
|
|
254
|
+
# component's own pipeline (see `metadata.merge_component`).
|
|
255
|
+
pushed_metadata = metadata_mod.read_component_file(
|
|
256
|
+
dated[0][0].parent / "metadata.json"
|
|
257
|
+
)
|
|
258
|
+
metadata = metadata_mod.merge_component(pushed_metadata, metadata)
|
|
259
|
+
|
|
251
260
|
# One call per component (attestation spec section 8.1): every dated
|
|
252
261
|
# snapshot's verdict is looked up from it by file name below, and its
|
|
253
262
|
# `orphans` feed the component aggregate alongside the retained snapshots'
|
|
@@ -20,7 +20,9 @@ rejected rather than rendered twice.
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import json
|
|
23
24
|
import logging
|
|
25
|
+
from pathlib import Path
|
|
24
26
|
|
|
25
27
|
from bompage.models import MetadataEntry
|
|
26
28
|
|
|
@@ -99,3 +101,75 @@ def parse_component(raw: list[str]) -> dict[str, list[MetadataEntry]]:
|
|
|
99
101
|
_reject_reserved(key, reserved=_COMPONENT_RESERVED, table="component")
|
|
100
102
|
grouped.setdefault(component, []).append((key, value))
|
|
101
103
|
return {name: _collapse(pairs) for name, pairs in grouped.items()}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_push(raw: list[str]) -> list[MetadataEntry]:
|
|
107
|
+
"""Ordered metadata rows for ``bompage push --metadata`` option strings.
|
|
108
|
+
|
|
109
|
+
One component's own rows, no ``component:`` prefix -- ``push`` already
|
|
110
|
+
knows which component it is writing for (spec section 4). Written to that
|
|
111
|
+
component's ``metadata.json`` sidecar, read back by :func:`build` via
|
|
112
|
+
:func:`read_component_file`.
|
|
113
|
+
"""
|
|
114
|
+
pairs = [_split_pair(item) for item in raw]
|
|
115
|
+
for key, _value in pairs:
|
|
116
|
+
_reject_reserved(key, reserved=_COMPONENT_RESERVED, table="component")
|
|
117
|
+
return _collapse(pairs)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_component_file(path: Path) -> list[MetadataEntry]:
|
|
121
|
+
"""Ordered metadata rows from a pushed ``reports/<component>/metadata.json``.
|
|
122
|
+
|
|
123
|
+
Tolerant like every other read of data a *different* pipeline wrote
|
|
124
|
+
(parsers, attestation verification -- spec sections 6.2 / 11): a missing
|
|
125
|
+
file is the common case (most components never push metadata) and is not
|
|
126
|
+
even worth a warning; an unreadable or malformed one degrades to no rows
|
|
127
|
+
with a warning, never raises. A single bad entry (wrong value type, a
|
|
128
|
+
reserved key) is dropped with its own warning rather than voiding the
|
|
129
|
+
whole file -- one bad key must not hide every other key a component
|
|
130
|
+
declared.
|
|
131
|
+
"""
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
return []
|
|
134
|
+
try:
|
|
135
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
136
|
+
except (OSError, ValueError, UnicodeDecodeError) as exc:
|
|
137
|
+
logger.warning("Unreadable metadata file %s: %s", path, exc)
|
|
138
|
+
return []
|
|
139
|
+
if not isinstance(raw, dict):
|
|
140
|
+
logger.warning("Ignoring %s: not a JSON object", path)
|
|
141
|
+
return []
|
|
142
|
+
entries: list[MetadataEntry] = []
|
|
143
|
+
for raw_key, value in raw.items():
|
|
144
|
+
if not isinstance(raw_key, str) or not isinstance(value, str):
|
|
145
|
+
logger.warning(
|
|
146
|
+
"Ignoring non-string metadata entry in %s: %r", path, raw_key
|
|
147
|
+
)
|
|
148
|
+
continue
|
|
149
|
+
key = raw_key.strip()
|
|
150
|
+
if not key:
|
|
151
|
+
logger.warning("Ignoring empty metadata key in %s", path)
|
|
152
|
+
continue
|
|
153
|
+
if key in _COMPONENT_RESERVED:
|
|
154
|
+
logger.warning("Ignoring reserved metadata key %r in %s", key, path)
|
|
155
|
+
continue
|
|
156
|
+
entries.append(MetadataEntry(key=key, value=value.strip()))
|
|
157
|
+
return entries
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def merge_component(
|
|
161
|
+
file_entries: list[MetadataEntry], cli_entries: list[MetadataEntry]
|
|
162
|
+
) -> list[MetadataEntry]:
|
|
163
|
+
"""Merge a component's pushed ``metadata.json`` with ``--metadata-component``.
|
|
164
|
+
|
|
165
|
+
The CLI wins per key -- the central operator keeps the final word without
|
|
166
|
+
coordinating with every component's own pipeline. A key only the file
|
|
167
|
+
declares keeps the file's position; a key only the CLI declares is
|
|
168
|
+
appended after every file-declared key (plain dict re-assignment already
|
|
169
|
+
has exactly this ordering behaviour: updating an existing key does not
|
|
170
|
+
move it, a new key is appended).
|
|
171
|
+
"""
|
|
172
|
+
merged: dict[str, str] = {entry.key: entry.value for entry in file_entries}
|
|
173
|
+
for entry in cli_entries:
|
|
174
|
+
merged[entry.key] = entry.value
|
|
175
|
+
return [MetadataEntry(key=key, value=value) for key, value in merged.items()]
|
|
@@ -9,6 +9,14 @@ filter effective across days, since the date-stamped file name changes daily.
|
|
|
9
9
|
It shells out to the ``git`` binary (plumbing shared with :mod:`bompage.prune`
|
|
10
10
|
via :mod:`bompage._git`); no VCS library is pulled in.
|
|
11
11
|
|
|
12
|
+
Optionally also writes ``reports/<component>/metadata.json`` (``--metadata``,
|
|
13
|
+
one component's own declared rows -- owner, tier, whatever a team wants on its
|
|
14
|
+
dashboard entry), read back by ``build`` and merged with
|
|
15
|
+
``--metadata-component`` (see :mod:`bompage.metadata`). Independent of the
|
|
16
|
+
SBOM: a metadata-only change commits on its own, without fabricating a new
|
|
17
|
+
dated snapshot to carry it, and omitting ``--metadata`` leaves any existing
|
|
18
|
+
file untouched.
|
|
19
|
+
|
|
12
20
|
The scoped token (spec section 5.3) is handed to git through a one-shot
|
|
13
21
|
``credential.helper`` that reads it from the child environment, so it never
|
|
14
22
|
lands on a command line (visible to ``ps`` on a shared runner) nor in the
|
|
@@ -26,6 +34,7 @@ from __future__ import annotations
|
|
|
26
34
|
|
|
27
35
|
import base64
|
|
28
36
|
import contextlib
|
|
37
|
+
import json
|
|
29
38
|
import re
|
|
30
39
|
import shutil
|
|
31
40
|
import subprocess
|
|
@@ -46,6 +55,7 @@ from bompage._git import (
|
|
|
46
55
|
from bompage._time import as_utc
|
|
47
56
|
from bompage.attest import AttestError, att_path, write_attestation
|
|
48
57
|
from bompage.discovery import snapshot_date
|
|
58
|
+
from bompage.models import MetadataEntry
|
|
49
59
|
from bompage.parsers.base import document_timestamp
|
|
50
60
|
|
|
51
61
|
# This module historically raised ``PushError``; keep the name as an alias so
|
|
@@ -76,6 +86,92 @@ def _file_name(*, stamp: str, sbom_version: str | None) -> str:
|
|
|
76
86
|
return f"sbom-{stamp}.json"
|
|
77
87
|
|
|
78
88
|
|
|
89
|
+
def _metadata_bytes(entries: list[MetadataEntry]) -> bytes:
|
|
90
|
+
"""Canonical ``metadata.json`` content: a flat ``{key: value}`` object.
|
|
91
|
+
|
|
92
|
+
``sort_keys`` (matching ``write_attestation`` / ``render.data``) keeps the
|
|
93
|
+
byte comparison in :func:`push_report` stable regardless of the order
|
|
94
|
+
``--metadata`` happened to be repeated in.
|
|
95
|
+
"""
|
|
96
|
+
payload = {entry.key: entry.value for entry in entries}
|
|
97
|
+
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _write_attestation_sidecar(
|
|
101
|
+
*,
|
|
102
|
+
target: Path,
|
|
103
|
+
work: Path,
|
|
104
|
+
tmp: str,
|
|
105
|
+
component: str,
|
|
106
|
+
previous: Path | None,
|
|
107
|
+
sign_key: str,
|
|
108
|
+
provenance: dict[str, str] | None,
|
|
109
|
+
sbom: Path,
|
|
110
|
+
now: datetime | None,
|
|
111
|
+
) -> tuple[Path, list[str]]:
|
|
112
|
+
"""Sign the just-written ``target`` SBOM; return the key file and the two
|
|
113
|
+
new relative paths (``.att`` / ``.att.sig``) for the caller to stage.
|
|
114
|
+
|
|
115
|
+
Split out of :func:`push_report` only to keep that function's own branch
|
|
116
|
+
count under the project's pylint limit -- no behaviour lives here that
|
|
117
|
+
callers should call directly.
|
|
118
|
+
"""
|
|
119
|
+
# Support both raw OpenSSH private keys and base64-encoded keys (e.g. for
|
|
120
|
+
# GitLab CI Masked variables, which forbid whitespace and newlines).
|
|
121
|
+
# Command to generate the single-line base64 key:
|
|
122
|
+
# macOS: base64 -i bompage-push | tr -d '\n'
|
|
123
|
+
# Linux: base64 -w 0 bompage-push
|
|
124
|
+
key_content = sign_key.strip()
|
|
125
|
+
if not key_content.startswith("-----BEGIN"):
|
|
126
|
+
with contextlib.suppress(ValueError, UnicodeDecodeError):
|
|
127
|
+
key_content = base64.b64decode(key_content).decode("utf-8").strip()
|
|
128
|
+
|
|
129
|
+
# 0600 inside the TemporaryDirectory already open, gone with it. The key
|
|
130
|
+
# material is passed as CONTENT and written here: it must never reach
|
|
131
|
+
# argv, where `ps` on a shared runner would show it (SECURITY.md, same
|
|
132
|
+
# rule as the token).
|
|
133
|
+
key_file = Path(tmp) / "sign_key"
|
|
134
|
+
key_file.write_text(
|
|
135
|
+
key_content if key_content.endswith("\n") else key_content + "\n"
|
|
136
|
+
)
|
|
137
|
+
key_file.chmod(0o600)
|
|
138
|
+
# A same-day re-push (no --sbom-version) overwrites `target` in place, so
|
|
139
|
+
# `previous` (found before the copy) is `target` itself: chaining onto it
|
|
140
|
+
# would make the new attestation's `prev` self-reference the file it is
|
|
141
|
+
# replacing, permanently reading as a broken chain. Exclude it and chain
|
|
142
|
+
# onto whatever this component's snapshot before it was -- i.e. what the
|
|
143
|
+
# entry being replaced already chained onto.
|
|
144
|
+
chain_previous = previous
|
|
145
|
+
if chain_previous is not None and chain_previous == target:
|
|
146
|
+
chain_previous = _latest_snapshot(target.parent, exclude=target)
|
|
147
|
+
prev_att = None
|
|
148
|
+
if chain_previous is not None and att_path(chain_previous).is_file():
|
|
149
|
+
prev_att = att_path(chain_previous)
|
|
150
|
+
try:
|
|
151
|
+
att, sig = write_attestation(
|
|
152
|
+
target,
|
|
153
|
+
component=component,
|
|
154
|
+
prev_att=prev_att,
|
|
155
|
+
generated_at=document_timestamp(sbom),
|
|
156
|
+
provenance=provenance or {},
|
|
157
|
+
key_file=key_file,
|
|
158
|
+
now=as_utc(now),
|
|
159
|
+
)
|
|
160
|
+
except AttestError as exc:
|
|
161
|
+
# This block is the one place that runs with the key MATERIAL in
|
|
162
|
+
# scope. `exc`'s own message cannot carry it (`write_attestation` only
|
|
163
|
+
# ever hands `ssh-keygen` a file path -- see attest.py), but re-raise
|
|
164
|
+
# with `from None` regardless: no exception chain (`__cause__` /
|
|
165
|
+
# `__context__`) is left standing that a caller printing a full
|
|
166
|
+
# traceback, not just `str(err)`, could still surface the key
|
|
167
|
+
# material through (SECURITY.md, same rule as the token).
|
|
168
|
+
raise PushError(f"signing failed: {exc}") from None
|
|
169
|
+
return key_file, [
|
|
170
|
+
att.relative_to(work).as_posix(),
|
|
171
|
+
sig.relative_to(work).as_posix(),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
79
175
|
def _latest_snapshot(
|
|
80
176
|
component_dir: Path, *, exclude: Path | None = None
|
|
81
177
|
) -> Path | None:
|
|
@@ -119,6 +215,7 @@ def push_report(
|
|
|
119
215
|
git_email: str = DEFAULT_GIT_EMAIL,
|
|
120
216
|
sign_key: str | None = None,
|
|
121
217
|
provenance: dict[str, str] | None = None,
|
|
218
|
+
metadata: list[MetadataEntry] | None = None,
|
|
122
219
|
now: datetime | None = None,
|
|
123
220
|
) -> str:
|
|
124
221
|
"""Clone, drop the SBOM, commit iff changed, push.
|
|
@@ -130,6 +227,13 @@ def push_report(
|
|
|
130
227
|
so it never has to appear on any command line (SECURITY.md, same rule as
|
|
131
228
|
``token``).
|
|
132
229
|
|
|
230
|
+
``metadata`` is likewise opt-in and independent of the SBOM: ``None`` (the
|
|
231
|
+
default) leaves any existing ``reports/<component>/metadata.json``
|
|
232
|
+
untouched; given, it *replaces* that file's whole content (read back by
|
|
233
|
+
``build`` -- see ``bompage.metadata``). A metadata-only change commits on
|
|
234
|
+
its own, without fabricating a new dated SBOM snapshot to carry it, and
|
|
235
|
+
is never covered by ``sign_key`` -- there is no new SBOM to attest.
|
|
236
|
+
|
|
133
237
|
Returns ``"committed"`` or ``"unchanged"``; raises :class:`PushError` on any
|
|
134
238
|
git failure or a missing SBOM.
|
|
135
239
|
"""
|
|
@@ -145,6 +249,7 @@ def push_report(
|
|
|
145
249
|
rel_path = (
|
|
146
250
|
f"reports/{component}/{_file_name(stamp=stamp, sbom_version=sbom_version)}"
|
|
147
251
|
)
|
|
252
|
+
metadata_rel_path = f"reports/{component}/metadata.json"
|
|
148
253
|
|
|
149
254
|
# Declared before the `if sign_key:` block below so it is always bound --
|
|
150
255
|
# an unsigned push (the default, every existing deployment) must not raise
|
|
@@ -158,73 +263,48 @@ def push_report(
|
|
|
158
263
|
)
|
|
159
264
|
|
|
160
265
|
previous = _latest_snapshot(work / "reports" / component)
|
|
161
|
-
|
|
162
|
-
|
|
266
|
+
sbom_unchanged = (
|
|
267
|
+
previous is not None and previous.read_bytes() == sbom.read_bytes()
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
metadata_path = work / metadata_rel_path
|
|
271
|
+
new_metadata_bytes: bytes | None = None
|
|
272
|
+
metadata_changed = False
|
|
273
|
+
if metadata is not None:
|
|
274
|
+
new_metadata_bytes = _metadata_bytes(metadata)
|
|
275
|
+
existing_metadata = (
|
|
276
|
+
metadata_path.read_bytes() if metadata_path.is_file() else None
|
|
277
|
+
)
|
|
278
|
+
metadata_changed = existing_metadata != new_metadata_bytes
|
|
279
|
+
|
|
280
|
+
if sbom_unchanged and not metadata_changed:
|
|
281
|
+
return "unchanged" # nothing this push carries actually differs
|
|
163
282
|
|
|
164
283
|
target = work / rel_path
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
284
|
+
paths: list[str] = []
|
|
285
|
+
if not sbom_unchanged:
|
|
286
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
shutil.copyfile(sbom, target)
|
|
288
|
+
paths.append(rel_path)
|
|
289
|
+
|
|
290
|
+
if metadata_changed and new_metadata_bytes is not None:
|
|
291
|
+
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
metadata_path.write_bytes(new_metadata_bytes)
|
|
293
|
+
paths.append(metadata_rel_path)
|
|
294
|
+
|
|
295
|
+
if sign_key and not sbom_unchanged:
|
|
296
|
+
key_file, sidecar_paths = _write_attestation_sidecar(
|
|
297
|
+
target=target,
|
|
298
|
+
work=work,
|
|
299
|
+
tmp=tmp,
|
|
300
|
+
component=component,
|
|
301
|
+
previous=previous,
|
|
302
|
+
sign_key=sign_key,
|
|
303
|
+
provenance=provenance,
|
|
304
|
+
sbom=sbom,
|
|
305
|
+
now=now,
|
|
187
306
|
)
|
|
188
|
-
|
|
189
|
-
# A same-day re-push (no --sbom-version) overwrites `target` in
|
|
190
|
-
# place, so `previous` (found before the copy above) is `target`
|
|
191
|
-
# itself: chaining onto it would make the new attestation's `prev`
|
|
192
|
-
# self-reference the file it is replacing, permanently reading as
|
|
193
|
-
# a broken chain. Exclude it and chain onto whatever this
|
|
194
|
-
# component's snapshot before it was -- i.e. what the entry being
|
|
195
|
-
# replaced already chained onto.
|
|
196
|
-
chain_previous = previous
|
|
197
|
-
if chain_previous is not None and chain_previous == target:
|
|
198
|
-
chain_previous = _latest_snapshot(
|
|
199
|
-
work / "reports" / component, exclude=target
|
|
200
|
-
)
|
|
201
|
-
prev_att = None
|
|
202
|
-
if chain_previous is not None and att_path(chain_previous).is_file():
|
|
203
|
-
prev_att = att_path(chain_previous)
|
|
204
|
-
try:
|
|
205
|
-
att, sig = write_attestation(
|
|
206
|
-
target,
|
|
207
|
-
component=component,
|
|
208
|
-
prev_att=prev_att,
|
|
209
|
-
generated_at=document_timestamp(sbom),
|
|
210
|
-
provenance=provenance or {},
|
|
211
|
-
key_file=key_file,
|
|
212
|
-
now=as_utc(now),
|
|
213
|
-
)
|
|
214
|
-
except AttestError as exc:
|
|
215
|
-
# This block is the one place that runs with the key MATERIAL
|
|
216
|
-
# in scope. `exc`'s own message cannot carry it (`write_attestation`
|
|
217
|
-
# only ever hands `ssh-keygen` a file path -- see attest.py), but
|
|
218
|
-
# re-raise with `from None` regardless: no exception chain
|
|
219
|
-
# (`__cause__`/`__context__`) is left standing that a caller
|
|
220
|
-
# printing a full traceback, not just `str(err)`, could still
|
|
221
|
-
# surface the key material through (SECURITY.md, same rule as
|
|
222
|
-
# the token).
|
|
223
|
-
raise PushError(f"signing failed: {exc}") from None
|
|
224
|
-
paths += [
|
|
225
|
-
att.relative_to(work).as_posix(),
|
|
226
|
-
sig.relative_to(work).as_posix(),
|
|
227
|
-
]
|
|
307
|
+
paths += sidecar_paths
|
|
228
308
|
|
|
229
309
|
_run_git(["add", *paths], cwd=work, token=token)
|
|
230
310
|
|
|
@@ -237,8 +317,15 @@ def push_report(
|
|
|
237
317
|
if staged.returncode == 0: # same-named file, unchanged -> anti-noise
|
|
238
318
|
return "unchanged"
|
|
239
319
|
|
|
240
|
-
|
|
241
|
-
|
|
320
|
+
# Named after what this push actually carries -- a metadata-only
|
|
321
|
+
# update (sbom unchanged) must not claim an SBOM change happened.
|
|
322
|
+
parts = []
|
|
323
|
+
if not sbom_unchanged:
|
|
324
|
+
parts.append(Path(rel_path).name)
|
|
325
|
+
if metadata_changed:
|
|
326
|
+
parts.append("metadata")
|
|
327
|
+
message = f"chore(sbom): {component} {' + '.join(parts)}"
|
|
328
|
+
if sbom_format and not sbom_unchanged:
|
|
242
329
|
message += f" [{sbom_format}]"
|
|
243
330
|
_run_git(
|
|
244
331
|
[
|
|
@@ -8,7 +8,8 @@ Layout under ``<output>/data/``::
|
|
|
8
8
|
index.json generated_at + bompage version + global metadata
|
|
9
9
|
table + global attestation aggregate + per component
|
|
10
10
|
{name, last_scan_date, package_count, is_stale,
|
|
11
|
-
days_since_scan, drift_count, attestation
|
|
11
|
+
days_since_scan, drift_count, attestation,
|
|
12
|
+
latest_attestation}
|
|
12
13
|
components/<name>.json one component in full, including `snapshots`
|
|
13
14
|
packages-index.json inverted index name -> occurrences (spec section 7.3)
|
|
14
15
|
drifts.json active cross-component drifts (spec section 7.4)
|
|
@@ -69,6 +70,15 @@ def write_data(data: DashboardData, output_dir: Path) -> list[Path]:
|
|
|
69
70
|
"is_stale": component["freshness"]["is_stale"],
|
|
70
71
|
"days_since_scan": component["freshness"]["days_since_scan"],
|
|
71
72
|
"drift_count": drift_counts.get(component["name"], 0),
|
|
73
|
+
# Distinct from the worst-of-history `attestation` aggregate:
|
|
74
|
+
# the tab-strip icon needs the latest snapshot's own verdict
|
|
75
|
+
# to tell "signed now, gap earlier" apart from "always signed"
|
|
76
|
+
# (design doc section 9.3).
|
|
77
|
+
"latest_attestation": (
|
|
78
|
+
component["snapshots"][-1]["attestation"]
|
|
79
|
+
if component["snapshots"]
|
|
80
|
+
else None
|
|
81
|
+
),
|
|
72
82
|
}
|
|
73
83
|
for component in dumped["components"]
|
|
74
84
|
],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@font-face{font-family:Roboto Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./roboto-latin-ext-wght-normal-DYIxWhlt.woff2)format("woff2-variations");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Roboto Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url(./roboto-latin-wght-normal-ccAYIvAh.woff2)format("woff2-variations");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Material Symbols Outlined;font-style:normal;font-weight:100 700;font-display:block;src:url(./material-symbols-outlined-CQbRTMbb.woff2)format("woff2")}.material-symbols-outlined{letter-spacing:normal;text-transform:none;white-space:nowrap;word-wrap:normal;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizelegibility;font-feature-settings:"liga";direction:ltr;font-family:Material Symbols Outlined;font-size:24px;font-style:normal;font-weight:400;line-height:1;display:inline-block}:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;--md-sys-color-primary:#4b5bd7;--md-sys-color-on-primary:#fff;--md-sys-color-primary-container:#e0e1ff;--md-sys-color-on-primary-container:#00006e;--md-sys-color-surface:#fdfbff;--md-sys-color-surface-container:#f2f0f7;--md-sys-color-surface-container-high:#eceaf2;--md-sys-color-on-surface:#1b1b1f;--md-sys-color-on-surface-variant:#46464f;--md-sys-color-outline:#777680;--md-sys-color-outline-variant:#c7c5d0;--md-sys-color-error:#ba1a1a;--md-sys-color-error-container:#ffdad6;--md-sys-color-on-error-container:#410002;--md-sys-color-tertiary-container:#ffdea8;--md-sys-color-on-tertiary-container:#291800;--chip-added-bg:#c9f2d0;--chip-added-fg:#0a3d18;--row-copyleft-bg:#ece6f6;--row-copyleft-fg:#1d192b;--badge-copyleft-bg:#d0bcff;--badge-copyleft-fg:#1d192b;--badge-verified-bg:#dafbe1;--badge-verified-fg:#1a7f37;--badge-verified-border:#4ac26b;--badge-verified-partial-bg:#fff2d6;--badge-verified-partial-fg:#9a5b00;--badge-verified-partial-border:#e0a530;--md-ref-typeface-plain:"Roboto Variable", "Roboto", system-ui, sans-serif;--md-ref-typeface-brand:"Roboto Variable", "Roboto", system-ui, sans-serif}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial;--md-sys-color-primary:#bfc2ff;--md-sys-color-on-primary:#10218a;--md-sys-color-primary-container:#313aa8;--md-sys-color-on-primary-container:#e0e1ff;--md-sys-color-surface:#121316;--md-sys-color-surface-container:#1e1f23;--md-sys-color-surface-container-high:#292a2e;--md-sys-color-on-surface:#e5e1e6;--md-sys-color-on-surface-variant:#c7c5d0;--md-sys-color-outline:#918f9a;--md-sys-color-outline-variant:#46464f;--md-sys-color-error:#ffb4ab;--md-sys-color-error-container:#93000a;--md-sys-color-on-error-container:#ffdad6;--md-sys-color-tertiary-container:#5f4100;--md-sys-color-on-tertiary-container:#ffdea8;--chip-added-bg:#123f1c;--chip-added-fg:#b7f0bf;--row-copyleft-bg:#2b2930;--row-copyleft-fg:#e6e0e9;--badge-copyleft-bg:#4f378b;--badge-copyleft-fg:#eaddff;--badge-verified-bg:#112d18;--badge-verified-fg:#3fb950;--badge-verified-border:#238636;--badge-verified-partial-bg:#3a2a00;--badge-verified-partial-fg:#e3a53c;--badge-verified-partial-border:#9a6a10}}body{max-width:80rem;font-family:var(--md-ref-typeface-plain);background:var(--md-sys-color-surface-container);color:var(--md-sys-color-on-surface);margin:0 auto;padding:1.5rem clamp(1rem,4vw,2.5rem);line-height:1.5}a{color:var(--md-sys-color-primary)}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.app-header,#tabs,#panel{background:var(--md-sys-color-surface);border:1px solid var(--md-sys-color-outline-variant);box-shadow:0 1px 2px #0000000f,0 1px 3px #00000014}.app-header{border-radius:16px;flex-wrap:wrap;align-items:center;gap:1rem 2rem;margin-bottom:1rem;padding:1rem 1.5rem;display:flex}.app-header .brand{margin-right:auto}.app-title{margin:0;line-height:0}.app-title a{border-radius:4px;display:inline-block}.app-title a:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:3px}.app-title img{width:auto;height:2.25rem;display:block}.app-subtitle{color:var(--md-sys-color-on-surface-variant);margin:.35rem 0 0;font-size:.875rem}.project-meta,.component-meta{color:var(--md-sys-color-on-surface-variant);grid-template-columns:auto 1fr;gap:.1rem .75rem;margin:.35rem 0 0;font-size:.875rem;display:grid}.component-meta{margin:0 0 .75rem}.project-meta:empty,.component-meta:empty{display:none}.project-meta .meta-row,.component-meta .meta-row{display:contents}.project-meta dt,.component-meta dt{font-weight:500}.project-meta dd,.component-meta dd{overflow-wrap:anywhere;min-width:0;margin:0}#global-search{flex:22rem;max-width:34rem;margin:0}#search-results{border:1px solid var(--md-sys-color-outline-variant);background:var(--md-sys-color-surface-container-high);border-radius:.5rem;flex-basis:100%;order:3;max-width:40rem;margin:0;padding:.5rem .75rem}#search-results ul{margin:.25rem 0;padding-left:1.1rem}#search-results[hidden]{display:none}#tabs{--md-primary-tab-container-color:transparent;--md-primary-tab-active-indicator-color:var(--md-sys-color-primary);--md-primary-tab-label-text-color:var(--md-sys-color-on-surface-variant);--md-primary-tab-active-label-text-color:var(--md-sys-color-primary);border-bottom:0;border-radius:16px 16px 0 0;padding:0 .75rem}#panel{border-top:0;border-radius:0 0 16px 16px;min-height:40vh;padding:.75rem 1.5rem 1.5rem}#panel:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.visually-hidden{clip-path:inset(50%);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.muted{color:var(--md-sys-color-on-surface-variant);font-size:.875rem}.meta{flex-wrap:wrap;align-items:center;gap:1rem;margin:1rem 0 .5rem;display:flex}.meta .component-name{font-weight:500}.controls{flex-wrap:wrap;align-items:center;gap:1rem;margin-bottom:.75rem;display:flex}.recent-changes,.snapshot-diff{margin:0 0 .75rem}.recent-changes summary,.snapshot-diff summary{cursor:pointer;color:var(--md-sys-color-on-surface-variant)}.snapshot-diff .controls{margin:.5rem 0}ul.snapshot-diff-list{margin:.5rem 0;padding-left:0;list-style:none}ul.snapshot-diff-list li{margin-bottom:.3rem}ul.recent,ul.drifts{margin:.5rem 0;padding-left:1.2rem}ul.recent li,ul.drifts>li{margin-bottom:.3rem}.timeline-date{color:var(--md-sys-color-on-surface-variant);font-size:.8rem}ol.timeline{margin:0;padding-left:1.2rem}ol.timeline li{margin-bottom:.3rem}.controls .count{color:var(--md-sys-color-on-surface-variant);font-size:.875rem}.controls .export-csv{margin-left:auto}.table-scroll{overflow-x:auto}table.inventory{border-collapse:collapse;width:100%;font-size:.9rem}table.inventory th,table.inventory td{text-align:left;border-bottom:1px solid var(--md-sys-color-outline-variant);padding:.5rem .75rem}table.inventory th{color:var(--md-sys-color-on-surface-variant);font-weight:500}button.th-sort{font:inherit;color:inherit;cursor:pointer;background:0 0;border:0;align-items:baseline;gap:.25rem;padding:0;font-weight:500;display:inline-flex}button.th-sort:hover{color:var(--md-sys-color-on-surface)}button.th-sort:focus-visible{outline:2px solid var(--md-sys-color-primary);outline-offset:2px}.sort-caret{color:var(--md-sys-color-primary);min-width:.7rem;font-size:.7rem}table.inventory tr.strong-copyleft td{background:var(--row-copyleft-bg);color:var(--row-copyleft-fg)}button.pkg-name{font:inherit;color:var(--md-sys-color-primary);cursor:pointer;background:0 0;border:0;padding:0;text-decoration:underline}.badge,.chip{white-space:nowrap;border-radius:.4rem;align-items:center;gap:.15rem;padding:0 .4rem;font-size:.75rem;display:inline-flex}.badge md-icon,.chip md-icon{font-size:1rem}.tab-label{white-space:nowrap;align-items:center;gap:.25rem;display:inline-flex}.tab-icon{color:var(--md-sys-color-error);font-size:1rem}.tab-icon.tab-icon-verified{color:var(--badge-verified-fg)}.tab-icon.tab-icon-verified-partial{color:var(--badge-verified-partial-fg)}.badge-popover-anchor{display:inline-flex;position:relative}.badge-verified{background:var(--badge-verified-bg);color:var(--badge-verified-fg);border:1px solid var(--badge-verified-border);cursor:pointer;border-radius:999px;gap:.35rem;padding:.15rem .55rem;font-family:inherit;font-weight:500;line-height:1.4;transition:filter .15s}.badge-verified:hover{filter:brightness(.95)}.badge-verified-partial{background:var(--badge-verified-partial-bg);color:var(--badge-verified-partial-fg);border-color:var(--badge-verified-partial-border)}.verified-popover{z-index:100;background:var(--md-sys-color-surface-container-high);min-width:280px;max-width:360px;color:var(--md-sys-color-on-surface);border:1px solid var(--md-sys-color-outline-variant);text-align:left;border-radius:8px;padding:.85rem 1rem;font-size:.8125rem;line-height:1.5;display:none;position:absolute;top:calc(100% + 6px);left:0;box-shadow:0 8px 24px #00000026}.verified-popover.open{display:block}.verified-popover-header{color:var(--badge-verified-fg);align-items:center;gap:.4rem;margin-bottom:.4rem;font-size:.875rem;font-weight:600;display:flex}.verified-popover.partial .verified-popover-header{color:var(--badge-verified-partial-fg)}.verified-popover-header md-icon{font-size:1.15rem}.verified-popover-desc{color:var(--md-sys-color-on-surface-variant);margin:0 0 .6rem;font-size:.775rem}.verified-popover-details{border-top:1px solid var(--md-sys-color-outline-variant);grid-template-columns:auto 1fr;gap:.25rem .6rem;margin:0;padding-top:.5rem;font-size:.75rem;display:grid}.verified-popover-details dt{color:var(--md-sys-color-on-surface-variant);font-weight:500}.verified-popover-details dd{overflow-wrap:anywhere;margin:0}.verified-popover-details code{background:var(--md-sys-color-surface-container);border-radius:4px;padding:.1rem .3rem;font-family:monospace}.badge-copyleft{background:var(--badge-copyleft-bg);color:var(--badge-copyleft-fg)}.badge-stale,.badge-attestation{background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.badge-drift{background:var(--md-sys-color-tertiary-container);color:var(--md-sys-color-on-tertiary-container)}.chip-added{background:var(--chip-added-bg);color:var(--chip-added-fg)}.chip-removed{background:var(--md-sys-color-error-container);color:var(--md-sys-color-on-error-container)}.chip-changed{background:var(--md-sys-color-primary-container);color:var(--md-sys-color-on-primary-container)}.license-cell{align-items:center;gap:.35rem;display:inline-flex}#error{background:var(--md-sys-color-error);color:var(--md-sys-color-on-primary);border-radius:.5rem;margin-top:1rem;padding:.75rem 1rem}#error[hidden]{display:none}#site-footer{text-align:center;color:var(--md-sys-color-on-surface-variant);margin:1.5rem 0 .5rem;font-size:.8125rem}
|