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.
Files changed (145) hide show
  1. {bompage-1.2.0 → bompage-1.2.2}/.gitlab-ci.yml +4 -0
  2. {bompage-1.2.0 → bompage-1.2.2}/PKG-INFO +1 -1
  3. bompage-1.2.2/docs/dashboard.md +49 -0
  4. {bompage-1.2.0 → bompage-1.2.2}/docs/index.md +1 -1
  5. {bompage-1.2.0 → bompage-1.2.2}/mkdocs.yml +1 -0
  6. {bompage-1.2.0 → bompage-1.2.2}/pyproject.toml +1 -1
  7. {bompage-1.2.0 → bompage-1.2.2}/scripts/gen-demo-data.py +3 -0
  8. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/bompage.py +17 -1
  9. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/build.py +9 -0
  10. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/metadata.py +74 -0
  11. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/push.py +153 -66
  12. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/data.py +11 -1
  13. bompage-1.2.2/src/bompage/web/assets/index-CBZgukme.css +1 -0
  14. 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
  15. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/index.html +2 -2
  16. {bompage-1.2.0 → bompage-1.2.2}/tests/render/test_data.py +34 -0
  17. {bompage-1.2.0 → bompage-1.2.2}/tests/test_build.py +53 -0
  18. bompage-1.2.2/tests/test_metadata.py +316 -0
  19. {bompage-1.2.0 → bompage-1.2.2}/tests/test_push_report.py +248 -0
  20. bompage-1.2.2/web-src/app.test.ts +206 -0
  21. {bompage-1.2.0 → bompage-1.2.2}/web-src/app.ts +55 -19
  22. {bompage-1.2.0 → bompage-1.2.2}/web-src/lib.test.ts +104 -0
  23. {bompage-1.2.0 → bompage-1.2.2}/web-src/lib.ts +80 -3
  24. {bompage-1.2.0 → bompage-1.2.2}/web-src/theme.css +33 -1
  25. bompage-1.2.0/src/bompage/web/assets/index-Dj5N8UqO.css +0 -1
  26. bompage-1.2.0/tests/test_metadata.py +0 -125
  27. bompage-1.2.0/web-src/app.test.ts +0 -154
  28. {bompage-1.2.0 → bompage-1.2.2}/.gitignore +0 -0
  29. {bompage-1.2.0 → bompage-1.2.2}/.gitleaks.toml +0 -0
  30. {bompage-1.2.0 → bompage-1.2.2}/.markdownlint.yaml +0 -0
  31. {bompage-1.2.0 → bompage-1.2.2}/.node-version +0 -0
  32. {bompage-1.2.0 → bompage-1.2.2}/.pre-commit-config.yaml +0 -0
  33. {bompage-1.2.0 → bompage-1.2.2}/.python-version +0 -0
  34. {bompage-1.2.0 → bompage-1.2.2}/.releaserc +0 -0
  35. {bompage-1.2.0 → bompage-1.2.2}/.yamllint +0 -0
  36. {bompage-1.2.0 → bompage-1.2.2}/CONTRIBUTING.md +0 -0
  37. {bompage-1.2.0 → bompage-1.2.2}/LICENSE +0 -0
  38. {bompage-1.2.0 → bompage-1.2.2}/README.md +0 -0
  39. {bompage-1.2.0 → bompage-1.2.2}/SECURITY.md +0 -0
  40. {bompage-1.2.0 → bompage-1.2.2}/Taskfile.project.yml +0 -0
  41. {bompage-1.2.0 → bompage-1.2.2}/Taskfile.yml +0 -0
  42. {bompage-1.2.0 → bompage-1.2.2}/biome.json +0 -0
  43. {bompage-1.2.0 → bompage-1.2.2}/deploy/.gitlab-ci.yml +0 -0
  44. {bompage-1.2.0 → bompage-1.2.2}/deploy/README.md +0 -0
  45. {bompage-1.2.0 → bompage-1.2.2}/deploy/reports/example-component/.gitkeep +0 -0
  46. {bompage-1.2.0 → bompage-1.2.2}/deploy/trust/allowed_signers +0 -0
  47. {bompage-1.2.0 → bompage-1.2.2}/deploy/web/.gitkeep +0 -0
  48. {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/drift.md +0 -0
  49. {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/freshness.md +0 -0
  50. {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/inventory.md +0 -0
  51. {bompage-1.2.0 → bompage-1.2.2}/docs/analysis/timeline.md +0 -0
  52. {bompage-1.2.0 → bompage-1.2.2}/docs/bompage.md +0 -0
  53. {bompage-1.2.0 → bompage-1.2.2}/docs/build.md +0 -0
  54. {bompage-1.2.0 → bompage-1.2.2}/docs/cli.md +0 -0
  55. {bompage-1.2.0 → bompage-1.2.2}/docs/discovery.md +0 -0
  56. {bompage-1.2.0 → bompage-1.2.2}/docs/metadata.md +0 -0
  57. {bompage-1.2.0 → bompage-1.2.2}/docs/models.md +0 -0
  58. {bompage-1.2.0 → bompage-1.2.2}/docs/parsers/base.md +0 -0
  59. {bompage-1.2.0 → bompage-1.2.2}/docs/parsers/cyclonedx.md +0 -0
  60. {bompage-1.2.0 → bompage-1.2.2}/docs/parsers/spdx.md +0 -0
  61. {bompage-1.2.0 → bompage-1.2.2}/docs/prune.md +0 -0
  62. {bompage-1.2.0 → bompage-1.2.2}/docs/push.md +0 -0
  63. {bompage-1.2.0 → bompage-1.2.2}/docs/render/api.md +0 -0
  64. {bompage-1.2.0 → bompage-1.2.2}/docs/render/data.md +0 -0
  65. {bompage-1.2.0 → bompage-1.2.2}/docs/render/web.md +0 -0
  66. {bompage-1.2.0 → bompage-1.2.2}/docs/verify.md +0 -0
  67. {bompage-1.2.0 → bompage-1.2.2}/lychee.toml +0 -0
  68. {bompage-1.2.0 → bompage-1.2.2}/package.json +0 -0
  69. {bompage-1.2.0 → bompage-1.2.2}/pnpm-lock.yaml +0 -0
  70. {bompage-1.2.0 → bompage-1.2.2}/pnpm-workspace.yaml +0 -0
  71. {bompage-1.2.0 → bompage-1.2.2}/renovate.json +0 -0
  72. {bompage-1.2.0 → bompage-1.2.2}/scripts/merge_sbom_licenses.py +0 -0
  73. {bompage-1.2.0 → bompage-1.2.2}/scripts/subset-icons.mjs +0 -0
  74. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/__init__.py +0 -0
  75. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/_git.py +0 -0
  76. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/_time.py +0 -0
  77. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/_version.py +0 -0
  78. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/__init__.py +0 -0
  79. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/drift.py +0 -0
  80. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/freshness.py +0 -0
  81. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/inventory.py +0 -0
  82. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/analysis/timeline.py +0 -0
  83. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/attest.py +0 -0
  84. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/discovery.py +0 -0
  85. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/models.py +0 -0
  86. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/__init__.py +0 -0
  87. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/base.py +0 -0
  88. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/cyclonedx.py +0 -0
  89. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/parsers/spdx.py +0 -0
  90. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/prune.py +0 -0
  91. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/py.typed +0 -0
  92. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/__init__.py +0 -0
  93. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/api.py +0 -0
  94. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/render/web.py +0 -0
  95. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/android-chrome-192x192.png +0 -0
  96. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/android-chrome-512x512.png +0 -0
  97. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/apple-touch-icon.png +0 -0
  98. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/bompage-D8Wv-XTb.png +0 -0
  99. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/material-symbols-outlined-CQbRTMbb.woff2 +0 -0
  100. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/roboto-latin-ext-wght-normal-DYIxWhlt.woff2 +0 -0
  101. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/assets/roboto-latin-wght-normal-ccAYIvAh.woff2 +0 -0
  102. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/favicon-16x16.png +0 -0
  103. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/favicon-32x32.png +0 -0
  104. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/favicon.ico +0 -0
  105. {bompage-1.2.0 → bompage-1.2.2}/src/bompage/web/site.webmanifest +0 -0
  106. {bompage-1.2.0 → bompage-1.2.2}/templates/push-report.yml +0 -0
  107. {bompage-1.2.0 → bompage-1.2.2}/tests/__init__.py +0 -0
  108. {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/__init__.py +0 -0
  109. {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_drift.py +0 -0
  110. {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_freshness.py +0 -0
  111. {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_inventory.py +0 -0
  112. {bompage-1.2.0 → bompage-1.2.2}/tests/analysis/test_timeline.py +0 -0
  113. {bompage-1.2.0 → bompage-1.2.2}/tests/conftest.py +0 -0
  114. {bompage-1.2.0 → bompage-1.2.2}/tests/files/cyclonedx/simple.cdx.json +0 -0
  115. {bompage-1.2.0 → bompage-1.2.2}/tests/files/reports/app-api/sbom-20260115.json +0 -0
  116. {bompage-1.2.0 → bompage-1.2.2}/tests/files/spdx/simple.spdx.json +0 -0
  117. {bompage-1.2.0 → bompage-1.2.2}/tests/files/spdx/web-frontend.spdx.json +0 -0
  118. {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/__init__.py +0 -0
  119. {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/test_base.py +0 -0
  120. {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/test_cyclonedx.py +0 -0
  121. {bompage-1.2.0 → bompage-1.2.2}/tests/parsers/test_spdx.py +0 -0
  122. {bompage-1.2.0 → bompage-1.2.2}/tests/render/__init__.py +0 -0
  123. {bompage-1.2.0 → bompage-1.2.2}/tests/render/test_web.py +0 -0
  124. {bompage-1.2.0 → bompage-1.2.2}/tests/scripts/test_merge_sbom_licenses.py +0 -0
  125. {bompage-1.2.0 → bompage-1.2.2}/tests/test_attest.py +0 -0
  126. {bompage-1.2.0 → bompage-1.2.2}/tests/test_bompage.py +0 -0
  127. {bompage-1.2.0 → bompage-1.2.2}/tests/test_dashboard_e2e.py +0 -0
  128. {bompage-1.2.0 → bompage-1.2.2}/tests/test_discovery.py +0 -0
  129. {bompage-1.2.0 → bompage-1.2.2}/tests/test_models.py +0 -0
  130. {bompage-1.2.0 → bompage-1.2.2}/tests/test_prune.py +0 -0
  131. {bompage-1.2.0 → bompage-1.2.2}/tests/test_prune_report.py +0 -0
  132. {bompage-1.2.0 → bompage-1.2.2}/tests/test_time.py +0 -0
  133. {bompage-1.2.0 → bompage-1.2.2}/tsconfig.json +0 -0
  134. {bompage-1.2.0 → bompage-1.2.2}/uv.lock +0 -0
  135. {bompage-1.2.0 → bompage-1.2.2}/vite.config.ts +0 -0
  136. {bompage-1.2.0 → bompage-1.2.2}/vitest.config.ts +0 -0
  137. {bompage-1.2.0 → bompage-1.2.2}/web-src/bompage.png +0 -0
  138. {bompage-1.2.0 → bompage-1.2.2}/web-src/index.html +0 -0
  139. {bompage-1.2.0 → bompage-1.2.2}/web-src/public/android-chrome-192x192.png +0 -0
  140. {bompage-1.2.0 → bompage-1.2.2}/web-src/public/android-chrome-512x512.png +0 -0
  141. {bompage-1.2.0 → bompage-1.2.2}/web-src/public/apple-touch-icon.png +0 -0
  142. {bompage-1.2.0 → bompage-1.2.2}/web-src/public/favicon-16x16.png +0 -0
  143. {bompage-1.2.0 → bompage-1.2.2}/web-src/public/favicon-32x32.png +0 -0
  144. {bompage-1.2.0 → bompage-1.2.2}/web-src/public/favicon.ico +0 -0
  145. {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.0
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.
@@ -1,6 +1,6 @@
1
1
  # Overview
2
2
 
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
 
5
5
  A generic, server-less system to centralise the SBOM history of **N components**
6
6
  in a dedicated Git repository and publish a static
@@ -18,6 +18,7 @@ markdown_extensions:
18
18
 
19
19
  nav:
20
20
  - 'index.md'
21
+ - 'dashboard.md'
21
22
  - 'cli.md'
22
23
  - 'bompage.md'
23
24
  - 'build.md'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bompage"
3
- version = "1.2.0"
3
+ version = "1.2.2"
4
4
  description = "A generic, server-less system to track SBOM history of N components and publish a static dashboard"
5
5
  authors = [
6
6
  { name = "FX Soubirou", email = "soubirou@yahoo.fr" }
@@ -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
- if previous is not None and previous.read_bytes() == sbom.read_bytes():
162
- return "unchanged" # identical to the last snapshot -> anti-noise
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
- target.parent.mkdir(parents=True, exist_ok=True)
166
- shutil.copyfile(sbom, target)
167
- paths = [rel_path]
168
-
169
- if sign_key:
170
- # Support both raw OpenSSH private keys and base64-encoded keys (e.g.
171
- # for GitLab CI Masked variables, which forbid whitespace and newlines).
172
- # Command to generate the single-line base64 key:
173
- # macOS: base64 -i bompage-push | tr -d '\n'
174
- # Linux: base64 -w 0 bompage-push
175
- key_content = sign_key.strip()
176
- if not key_content.startswith("-----BEGIN"):
177
- with contextlib.suppress(ValueError, UnicodeDecodeError):
178
- key_content = base64.b64decode(key_content).decode("utf-8").strip()
179
-
180
- # 0600 inside the TemporaryDirectory already open, gone with it. The
181
- # key material is passed as CONTENT and written here: it must never
182
- # reach argv, where `ps` on a shared runner would show it
183
- # (SECURITY.md, same rule as the token).
184
- key_file = Path(tmp) / "sign_key"
185
- key_file.write_text(
186
- key_content if key_content.endswith("\n") else key_content + "\n"
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
- key_file.chmod(0o600)
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
- message = f"chore(sbom): {component} {Path(rel_path).name}"
241
- if sbom_format:
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}