bompage 1.0.0__py3-none-any.whl
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/__init__.py +0 -0
- bompage/_git.py +72 -0
- bompage/_time.py +20 -0
- bompage/analysis/__init__.py +0 -0
- bompage/analysis/drift.py +71 -0
- bompage/analysis/freshness.py +30 -0
- bompage/analysis/inventory.py +85 -0
- bompage/analysis/timeline.py +120 -0
- bompage/bompage.py +276 -0
- bompage/build.py +254 -0
- bompage/discovery.py +48 -0
- bompage/metadata.py +64 -0
- bompage/models.py +122 -0
- bompage/parsers/__init__.py +0 -0
- bompage/parsers/base.py +66 -0
- bompage/parsers/cyclonedx.py +88 -0
- bompage/parsers/spdx.py +64 -0
- bompage/prune.py +137 -0
- bompage/push.py +224 -0
- bompage/py.typed +0 -0
- bompage/render/__init__.py +0 -0
- bompage/render/api.py +8 -0
- bompage/render/data.py +98 -0
- bompage/render/web.py +47 -0
- bompage-1.0.0.dist-info/METADATA +241 -0
- bompage-1.0.0.dist-info/RECORD +29 -0
- bompage-1.0.0.dist-info/WHEEL +4 -0
- bompage-1.0.0.dist-info/entry_points.txt +2 -0
- bompage-1.0.0.dist-info/licenses/LICENSE +21 -0
bompage/__init__.py
ADDED
|
File without changes
|
bompage/_git.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared helpers for shelling out to ``git`` against the central bompage
|
|
2
|
+
repository (spec sections 5 and 5.4).
|
|
3
|
+
|
|
4
|
+
Both :mod:`bompage.push` and :mod:`bompage.prune` clone the central repo with a
|
|
5
|
+
scoped token, modify ``reports/`` and push. The token is handed to git through a
|
|
6
|
+
one-shot ``credential.helper`` that reads it from the child environment, so it
|
|
7
|
+
never lands on a command line (visible to ``ps`` on a shared runner) nor in the
|
|
8
|
+
clone's ``.git/config``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
DEFAULT_BRANCH = "main"
|
|
18
|
+
DEFAULT_GIT_USER = "bompage-ci"
|
|
19
|
+
DEFAULT_GIT_EMAIL = "bompage-ci@localhost"
|
|
20
|
+
|
|
21
|
+
_TOKEN_ENV = "BOMPAGE_GIT_TOKEN"
|
|
22
|
+
# git runs this with the credential operation as $1 and reads the answer from
|
|
23
|
+
# stdout; the password comes from the environment, never from argv.
|
|
24
|
+
_CREDENTIAL_HELPER = (
|
|
25
|
+
f'!f() {{ echo "username=x-access-token"; echo "password=${_TOKEN_ENV}"; }}; f'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GitRepoError(RuntimeError):
|
|
30
|
+
"""A git step (clone, rm, commit, push) failed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _redact(text: str, token: str | None) -> str:
|
|
34
|
+
"""Blank out the token wherever git may have echoed it back."""
|
|
35
|
+
return text.replace(token, "***") if token else text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _git_env(token: str | None) -> dict[str, str]:
|
|
39
|
+
"""Child environment for git: never prompt, carry the token out of argv."""
|
|
40
|
+
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
|
|
41
|
+
if token:
|
|
42
|
+
env[_TOKEN_ENV] = token
|
|
43
|
+
return env
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_git(
|
|
47
|
+
args: list[str], *, cwd: Path | None = None, token: str | None = None
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Run ``git <args>``; raise :class:`GitRepoError` (token redacted) on failure.
|
|
50
|
+
|
|
51
|
+
Every call resets any inherited ``credential.helper`` and installs the
|
|
52
|
+
env-backed one, so clone and push authenticate without the token ever
|
|
53
|
+
reaching the command line.
|
|
54
|
+
"""
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
[
|
|
57
|
+
"git",
|
|
58
|
+
"-c",
|
|
59
|
+
"credential.helper=",
|
|
60
|
+
"-c",
|
|
61
|
+
f"credential.helper={_CREDENTIAL_HELPER}",
|
|
62
|
+
*args,
|
|
63
|
+
],
|
|
64
|
+
cwd=cwd,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
check=False,
|
|
68
|
+
env=_git_env(token),
|
|
69
|
+
)
|
|
70
|
+
if proc.returncode != 0:
|
|
71
|
+
detail = _redact((proc.stderr or proc.stdout).strip(), token)
|
|
72
|
+
raise GitRepoError(f"git {args[0]} failed: {detail}")
|
bompage/_time.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Clock helper shared by the commands that derive a calendar day from ``now``.
|
|
2
|
+
|
|
3
|
+
``build`` (freshness verdict and the recent-changes window), ``push`` (the
|
|
4
|
+
``sbom-YYYYMMDD`` file stamp) and ``prune`` (the retention cutoff) all turn an
|
|
5
|
+
instant into a UTC calendar day. Routing every one through :func:`as_utc` keeps
|
|
6
|
+
that day independent of the caller's timezone -- a naive datetime is assumed to
|
|
7
|
+
already be UTC, an aware one is converted.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def as_utc(now: datetime | None) -> datetime:
|
|
16
|
+
"""Resolve ``now`` (or the current instant) to an aware UTC datetime."""
|
|
17
|
+
resolved = now or datetime.now(tz=UTC)
|
|
18
|
+
if resolved.tzinfo is None:
|
|
19
|
+
return resolved.replace(tzinfo=UTC)
|
|
20
|
+
return resolved.astimezone(UTC)
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cross-component inverted index and version-drift detection.
|
|
2
|
+
|
|
3
|
+
``build_package_index`` maps every package name to the list of components that
|
|
4
|
+
carry it, with the version and ecosystem seen on each (spec section 7.3). That
|
|
5
|
+
index powers the transverse search and, via ``active_drifts``, the list of
|
|
6
|
+
dependencies that sit at diverging versions across components (spec section
|
|
7
|
+
7.4), each dated by when the divergence last widened so the dashboard can sort
|
|
8
|
+
by how long it has stood.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import date
|
|
14
|
+
|
|
15
|
+
from bompage.models import ComponentReport, Drift, PackageOccurrence
|
|
16
|
+
|
|
17
|
+
# A drift needs the package on at least this many components, at this many
|
|
18
|
+
# distinct versions.
|
|
19
|
+
_MIN_FOR_DRIFT = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_package_index(
|
|
23
|
+
components: list[ComponentReport],
|
|
24
|
+
) -> dict[str, list[PackageOccurrence]]:
|
|
25
|
+
"""Map each package name to one occurrence per component that carries it.
|
|
26
|
+
|
|
27
|
+
A component may list the same name at several versions (spec section 7.3
|
|
28
|
+
speaks of "the version detected on each", singular); keep the highest so a
|
|
29
|
+
within-component duplicate is not mistaken for a cross-component drift.
|
|
30
|
+
"""
|
|
31
|
+
index: dict[str, list[PackageOccurrence]] = {}
|
|
32
|
+
for component in sorted(components, key=lambda c: c.name):
|
|
33
|
+
best: dict[str, PackageOccurrence] = {}
|
|
34
|
+
for pkg in component.packages:
|
|
35
|
+
current = best.get(pkg.name)
|
|
36
|
+
if current is None or (pkg.version or "") > (current.version or ""):
|
|
37
|
+
best[pkg.name] = PackageOccurrence(
|
|
38
|
+
component=component.name,
|
|
39
|
+
version=pkg.version,
|
|
40
|
+
ecosystem=pkg.ecosystem,
|
|
41
|
+
)
|
|
42
|
+
for name, occurrence in best.items():
|
|
43
|
+
index.setdefault(name, []).append(occurrence)
|
|
44
|
+
return index
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _current_version_date(component: ComponentReport, name: str) -> date:
|
|
48
|
+
history = component.timeline.get(name)
|
|
49
|
+
if history:
|
|
50
|
+
return history[-1].first_seen
|
|
51
|
+
return component.last_scan_date # pragma: no cover - guard for inconsistent input
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def active_drifts(
|
|
55
|
+
index: dict[str, list[PackageOccurrence]],
|
|
56
|
+
components: list[ComponentReport],
|
|
57
|
+
) -> list[Drift]:
|
|
58
|
+
"""Packages present on 2+ components at 2+ distinct versions."""
|
|
59
|
+
by_name = {component.name: component for component in components}
|
|
60
|
+
drifts: list[Drift] = []
|
|
61
|
+
for name, occurrences in index.items():
|
|
62
|
+
if len(occurrences) < _MIN_FOR_DRIFT:
|
|
63
|
+
continue
|
|
64
|
+
if len({occ.version for occ in occurrences}) < _MIN_FOR_DRIFT:
|
|
65
|
+
continue
|
|
66
|
+
since = max(
|
|
67
|
+
_current_version_date(by_name[occ.component], name) for occ in occurrences
|
|
68
|
+
)
|
|
69
|
+
drifts.append(Drift(name=name, entries=list(occurrences), since=since))
|
|
70
|
+
drifts.sort(key=lambda d: (d.since, d.name))
|
|
71
|
+
return drifts
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Data-freshness evaluation for a component (spec section 7.5).
|
|
2
|
+
|
|
3
|
+
Turns a component's most recent scan date plus a staleness threshold into a
|
|
4
|
+
``Freshness`` verdict, so the dashboard can tell "no dependency change" apart
|
|
5
|
+
from "the source pipeline stopped pushing".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, date, datetime
|
|
11
|
+
|
|
12
|
+
from bompage.models import Freshness
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def evaluate(last_scan: date, *, now: datetime, stale_after: int) -> Freshness:
|
|
16
|
+
"""Days since the last report, and whether that exceeds ``stale_after``.
|
|
17
|
+
|
|
18
|
+
``now`` is taken in UTC (an aware datetime in any zone is converted first, a
|
|
19
|
+
naive one is assumed to already be UTC), so the day boundary is fixed
|
|
20
|
+
regardless of the caller's local time and a scan stamped "today" never reads
|
|
21
|
+
as one day stale near midnight.
|
|
22
|
+
"""
|
|
23
|
+
now_utc = now if now.tzinfo is not None else now.replace(tzinfo=UTC)
|
|
24
|
+
# Clamp a future-dated scan (source clock ahead) to 0 rather than show "-3d".
|
|
25
|
+
days_since_scan = max(0, (now_utc.astimezone(UTC).date() - last_scan).days)
|
|
26
|
+
return Freshness(
|
|
27
|
+
last_scan_date=last_scan,
|
|
28
|
+
days_since_scan=days_since_scan,
|
|
29
|
+
is_stale=days_since_scan > stale_after,
|
|
30
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Current-inventory computation for a component.
|
|
2
|
+
|
|
3
|
+
Builds ``current_inventory`` from the latest snapshot: package name, version,
|
|
4
|
+
ecosystem inferred from the ``purl``, and licence, flagging strict copyleft
|
|
5
|
+
licences for the dashboard (spec sections 6.1 point 4 and 7.1).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from functools import cache
|
|
12
|
+
|
|
13
|
+
from bompage.models import Package
|
|
14
|
+
|
|
15
|
+
# purl type -> human label shown in the dashboard filter (spec section 7.1).
|
|
16
|
+
_ECOSYSTEMS = {
|
|
17
|
+
"pypi": "PyPI",
|
|
18
|
+
"npm": "npm",
|
|
19
|
+
"deb": "Deb",
|
|
20
|
+
"apk": "Alpine",
|
|
21
|
+
"rpm": "RPM",
|
|
22
|
+
"cargo": "Cargo",
|
|
23
|
+
"gem": "RubyGems",
|
|
24
|
+
"golang": "Go",
|
|
25
|
+
"maven": "Maven",
|
|
26
|
+
"nuget": "NuGet",
|
|
27
|
+
"composer": "Composer",
|
|
28
|
+
"hex": "Hex",
|
|
29
|
+
"oci": "OCI",
|
|
30
|
+
"cran": "CRAN",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_PURL_TYPE_RE = re.compile(r"^pkg:(?P<type>[^/]+)/")
|
|
34
|
+
|
|
35
|
+
# Strong ("strict") copyleft families flagged in the dashboard (spec section 7.1).
|
|
36
|
+
# GPL/AGPL are handled separately so the LGPL exclusion stays correct.
|
|
37
|
+
_STRONG_COPYLEFT = ("EUPL", "OSL", "CECILL", "SSPL", "RPL", "CPAL")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ecosystem(purl: str | None) -> str | None:
|
|
41
|
+
if not purl:
|
|
42
|
+
return None
|
|
43
|
+
match = _PURL_TYPE_RE.match(purl)
|
|
44
|
+
if match is None:
|
|
45
|
+
return None
|
|
46
|
+
purl_type = match["type"].lower()
|
|
47
|
+
# Known types get a curated label; any other valid purl type is still shown
|
|
48
|
+
# and filterable under its own name rather than dropped.
|
|
49
|
+
return _ECOSYSTEMS.get(purl_type, purl_type.title())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Memoised: pure, and called once per package on the latest snapshot of every
|
|
53
|
+
# component with a very small set of distinct licence strings (``MIT``,
|
|
54
|
+
# ``Apache-2.0``, …). The normalisation below (upper + regex strip + scan) is
|
|
55
|
+
# the priciest bit of inventory enrichment; the process is short-lived so an
|
|
56
|
+
# unbounded cache is safe.
|
|
57
|
+
@cache
|
|
58
|
+
def _is_strong_copyleft(license_id: str | None) -> bool:
|
|
59
|
+
if not license_id:
|
|
60
|
+
return False
|
|
61
|
+
norm = re.sub(r"[^A-Z]", "", license_id.upper())
|
|
62
|
+
# CeCILL-B is BSD-style permissive and CeCILL-C is LGPL-style weak copyleft;
|
|
63
|
+
# only plain CeCILL (v1 / v2 / v2.1) is strong. Drop the weak variants
|
|
64
|
+
# before the scan, the same way LGPL is excluded from the GPL test below.
|
|
65
|
+
norm = norm.replace("CECILLB", "").replace("CECILLC", "")
|
|
66
|
+
if any(token in norm for token in _STRONG_COPYLEFT):
|
|
67
|
+
return True
|
|
68
|
+
# GPL / AGPL but not (only) LGPL: drop LGPL runs before looking for GPL.
|
|
69
|
+
return "GPL" in norm.replace("LGPL", "")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_inventory(packages: list[Package]) -> list[Package]:
|
|
73
|
+
"""Return the deduplicated, ecosystem-enriched inventory, sorted by name."""
|
|
74
|
+
seen: dict[tuple[str, str | None, str | None], Package] = {}
|
|
75
|
+
for pkg in packages:
|
|
76
|
+
key = (pkg.name, pkg.version, pkg.purl)
|
|
77
|
+
if key in seen:
|
|
78
|
+
continue
|
|
79
|
+
seen[key] = pkg.model_copy(
|
|
80
|
+
update={
|
|
81
|
+
"ecosystem": _ecosystem(pkg.purl),
|
|
82
|
+
"is_strong_copyleft": _is_strong_copyleft(pkg.license),
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
return sorted(seen.values(), key=lambda p: (p.name.lower(), p.version or ""))
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Version history derived from a component's dated snapshots.
|
|
2
|
+
|
|
3
|
+
- ``build_timeline`` : per package, the sequence of versions it held, a new
|
|
4
|
+
entry only when the version differs from the last one recorded (spec section
|
|
5
|
+
6.1 point 4).
|
|
6
|
+
- ``diff_last_two`` : the explicit ``added`` / ``removed`` lists between the two
|
|
7
|
+
most recent snapshots.
|
|
8
|
+
- ``recent_changes`` : every add / remove / version change whose date falls in a
|
|
9
|
+
sliding window (spec section 7.2 "changements récents").
|
|
10
|
+
|
|
11
|
+
All three key off the same per-snapshot ``{name: version}`` view
|
|
12
|
+
(:func:`snapshot_versions`). The build passes that view in precomputed via the
|
|
13
|
+
``versions`` keyword so it is built once per snapshot rather than once per
|
|
14
|
+
function; callers that omit it get it computed on the fly.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from datetime import date
|
|
20
|
+
from itertools import pairwise
|
|
21
|
+
|
|
22
|
+
from bompage.models import Change, Package, Snapshot, TimelineEntry
|
|
23
|
+
|
|
24
|
+
_MIN_SNAPSHOTS_FOR_DIFF = 2
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def snapshot_versions(snapshot: Snapshot) -> dict[str, str | None]:
|
|
28
|
+
"""Map each package name to its version in this snapshot.
|
|
29
|
+
|
|
30
|
+
A name may legitimately appear more than once in one snapshot (e.g. an npm
|
|
31
|
+
tree that carries two versions of the same package). Keep the highest
|
|
32
|
+
version -- same ``version or ""`` ordering the inventory sorts by -- so the
|
|
33
|
+
timeline and the added/removed deltas stay deterministic instead of
|
|
34
|
+
depending on package order.
|
|
35
|
+
"""
|
|
36
|
+
versions: dict[str, str | None] = {}
|
|
37
|
+
for pkg in snapshot.packages:
|
|
38
|
+
if pkg.name not in versions or (pkg.version or "") > (versions[pkg.name] or ""):
|
|
39
|
+
versions[pkg.name] = pkg.version
|
|
40
|
+
return versions
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve(
|
|
44
|
+
snapshots: list[Snapshot], versions: list[dict[str, str | None]] | None
|
|
45
|
+
) -> list[dict[str, str | None]]:
|
|
46
|
+
if versions is None:
|
|
47
|
+
return [snapshot_versions(snapshot) for snapshot in snapshots]
|
|
48
|
+
if len(versions) != len(snapshots):
|
|
49
|
+
msg = "versions must have one entry per snapshot"
|
|
50
|
+
raise ValueError(msg)
|
|
51
|
+
return versions
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_timeline(
|
|
55
|
+
snapshots: list[Snapshot],
|
|
56
|
+
*,
|
|
57
|
+
versions: list[dict[str, str | None]] | None = None,
|
|
58
|
+
) -> dict[str, list[TimelineEntry]]:
|
|
59
|
+
"""Map each package name to its ordered list of version changes."""
|
|
60
|
+
per_snapshot = _resolve(snapshots, versions)
|
|
61
|
+
timeline: dict[str, list[TimelineEntry]] = {}
|
|
62
|
+
for snapshot, snapshot_view in zip(snapshots, per_snapshot, strict=True):
|
|
63
|
+
for name, version in snapshot_view.items():
|
|
64
|
+
history = timeline.setdefault(name, [])
|
|
65
|
+
if not history or history[-1].version != version:
|
|
66
|
+
history.append(TimelineEntry(version=version, first_seen=snapshot.date))
|
|
67
|
+
return timeline
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def diff_last_two(
|
|
71
|
+
snapshots: list[Snapshot],
|
|
72
|
+
*,
|
|
73
|
+
versions: list[dict[str, str | None]] | None = None,
|
|
74
|
+
) -> tuple[list[Package], list[str]]:
|
|
75
|
+
"""Packages added and package names removed between the last two snapshots."""
|
|
76
|
+
if len(snapshots) < _MIN_SNAPSHOTS_FOR_DIFF:
|
|
77
|
+
return [], []
|
|
78
|
+
per_snapshot = _resolve(snapshots, versions)
|
|
79
|
+
previous = per_snapshot[-2]
|
|
80
|
+
current = {pkg.name: pkg for pkg in snapshots[-1].packages}
|
|
81
|
+
added = [pkg for name, pkg in current.items() if name not in previous]
|
|
82
|
+
removed = [name for name in previous if name not in current]
|
|
83
|
+
return added, sorted(removed)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def recent_changes(
|
|
87
|
+
snapshots: list[Snapshot],
|
|
88
|
+
*,
|
|
89
|
+
since: date,
|
|
90
|
+
versions: list[dict[str, str | None]] | None = None,
|
|
91
|
+
) -> list[Change]:
|
|
92
|
+
"""Add / remove / version-change events dated on or after ``since``."""
|
|
93
|
+
per_snapshot = _resolve(snapshots, versions)
|
|
94
|
+
changes: list[Change] = []
|
|
95
|
+
paired = zip(snapshots, per_snapshot, strict=True)
|
|
96
|
+
for (_previous, before), (current, after) in pairwise(paired):
|
|
97
|
+
if current.date < since:
|
|
98
|
+
continue
|
|
99
|
+
for name, version in after.items():
|
|
100
|
+
if name not in before:
|
|
101
|
+
changes.append(
|
|
102
|
+
Change(
|
|
103
|
+
kind="added", package=name, version=version, date=current.date
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
elif before[name] != version:
|
|
107
|
+
changes.append(
|
|
108
|
+
Change(
|
|
109
|
+
kind="changed",
|
|
110
|
+
package=name,
|
|
111
|
+
version=version,
|
|
112
|
+
previous_version=before[name],
|
|
113
|
+
date=current.date,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
for name in before:
|
|
117
|
+
if name not in after:
|
|
118
|
+
changes.append(Change(kind="removed", package=name, date=current.date))
|
|
119
|
+
changes.sort(key=lambda c: (c.date, c.package, c.kind))
|
|
120
|
+
return changes
|
bompage/bompage.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Command-line entry point for ``bompage``.
|
|
2
|
+
|
|
3
|
+
Defines the ``app`` Typer application, the ``build`` command that turns a
|
|
4
|
+
``reports/`` tree (one directory per component) into the constant dashboard
|
|
5
|
+
assets, the ``data/`` JSON tree they consume at runtime and a copy of each
|
|
6
|
+
component's latest raw manifest (spec section 6.1), and the ``push`` command
|
|
7
|
+
that publishes one SBOM into the central repository (spec sections 5 / 9).
|
|
8
|
+
Version string ``1.0.0`` is substituted at release time.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from bompage._git import (
|
|
19
|
+
DEFAULT_BRANCH,
|
|
20
|
+
DEFAULT_GIT_EMAIL,
|
|
21
|
+
DEFAULT_GIT_USER,
|
|
22
|
+
GitRepoError,
|
|
23
|
+
)
|
|
24
|
+
from bompage.build import build_site
|
|
25
|
+
from bompage.metadata import parse_component, parse_global
|
|
26
|
+
from bompage.prune import prune_report
|
|
27
|
+
from bompage.push import PushError, push_report
|
|
28
|
+
|
|
29
|
+
__version__ = "1.0.0"
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
add_completion=False,
|
|
33
|
+
help="Track SBOM history of N components and build a static dashboard.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version(value: bool) -> None:
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(__version__)
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def main(
|
|
45
|
+
_version_flag: Annotated[
|
|
46
|
+
bool,
|
|
47
|
+
typer.Option(
|
|
48
|
+
"--version",
|
|
49
|
+
callback=_version,
|
|
50
|
+
is_eager=True,
|
|
51
|
+
help="Show the version and exit.",
|
|
52
|
+
),
|
|
53
|
+
] = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""bompage command-line interface."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def build(
|
|
60
|
+
reports: Annotated[
|
|
61
|
+
Path,
|
|
62
|
+
typer.Option(
|
|
63
|
+
exists=True,
|
|
64
|
+
file_okay=False,
|
|
65
|
+
dir_okay=True,
|
|
66
|
+
help="Directory holding one sub-directory per component.",
|
|
67
|
+
),
|
|
68
|
+
] = Path("reports"),
|
|
69
|
+
output: Annotated[
|
|
70
|
+
Path,
|
|
71
|
+
typer.Option(help="Directory the static site is written to."),
|
|
72
|
+
] = Path("public"),
|
|
73
|
+
web_dir: Annotated[
|
|
74
|
+
Path | None,
|
|
75
|
+
typer.Option(
|
|
76
|
+
exists=True,
|
|
77
|
+
file_okay=False,
|
|
78
|
+
help="Directory of a pre-built dashboard (index.html + assets/) to "
|
|
79
|
+
"use instead of the packaged one.",
|
|
80
|
+
),
|
|
81
|
+
] = None,
|
|
82
|
+
stale_after: Annotated[
|
|
83
|
+
int,
|
|
84
|
+
typer.Option(
|
|
85
|
+
min=0,
|
|
86
|
+
help="A component with no new report for more than this many days "
|
|
87
|
+
"is flagged stale.",
|
|
88
|
+
),
|
|
89
|
+
] = 2,
|
|
90
|
+
recent_days: Annotated[
|
|
91
|
+
int,
|
|
92
|
+
typer.Option(
|
|
93
|
+
min=1,
|
|
94
|
+
help="Sliding window (days) for the dashboard's recent-changes feed.",
|
|
95
|
+
),
|
|
96
|
+
] = 30,
|
|
97
|
+
metadata: Annotated[
|
|
98
|
+
list[str] | None,
|
|
99
|
+
typer.Option(
|
|
100
|
+
"--metadata",
|
|
101
|
+
help='Global metadata row "key=value" for the dashboard header; '
|
|
102
|
+
"repeatable.",
|
|
103
|
+
),
|
|
104
|
+
] = None,
|
|
105
|
+
metadata_component: Annotated[
|
|
106
|
+
list[str] | None,
|
|
107
|
+
typer.Option(
|
|
108
|
+
"--metadata-component",
|
|
109
|
+
help='Per-component metadata row "component:key=value"; repeatable.',
|
|
110
|
+
),
|
|
111
|
+
] = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Build the static dashboard and its data/ tree from a reports/ tree."""
|
|
114
|
+
try:
|
|
115
|
+
global_metadata = parse_global(metadata or [])
|
|
116
|
+
by_component = parse_component(metadata_component or [])
|
|
117
|
+
except ValueError as err:
|
|
118
|
+
raise typer.BadParameter(str(err)) from err
|
|
119
|
+
data = build_site(
|
|
120
|
+
reports,
|
|
121
|
+
output,
|
|
122
|
+
web_dir=web_dir,
|
|
123
|
+
stale_after=stale_after,
|
|
124
|
+
recent_days=recent_days,
|
|
125
|
+
metadata=global_metadata,
|
|
126
|
+
component_metadata=by_component,
|
|
127
|
+
)
|
|
128
|
+
total_packages = sum(len(c.packages) for c in data.components)
|
|
129
|
+
stale = [c.name for c in data.components if c.freshness.is_stale]
|
|
130
|
+
typer.echo(
|
|
131
|
+
f"{len(data.components)} component(s), {total_packages} package(s), "
|
|
132
|
+
f"{len(data.drifts)} drift(s) -> {output}"
|
|
133
|
+
)
|
|
134
|
+
if stale:
|
|
135
|
+
typer.echo(f" stale: {', '.join(stale)}")
|
|
136
|
+
for component in data.components:
|
|
137
|
+
typer.echo(f" {component.name}: {len(component.packages)} packages")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@app.command()
|
|
141
|
+
def push(
|
|
142
|
+
component: Annotated[
|
|
143
|
+
str,
|
|
144
|
+
typer.Option(help="Logical component name (one directory under reports/)."),
|
|
145
|
+
],
|
|
146
|
+
sbom: Annotated[
|
|
147
|
+
Path,
|
|
148
|
+
typer.Option(
|
|
149
|
+
exists=True,
|
|
150
|
+
dir_okay=False,
|
|
151
|
+
help="Path to the SBOM file produced by the calling job.",
|
|
152
|
+
),
|
|
153
|
+
],
|
|
154
|
+
repo: Annotated[
|
|
155
|
+
str,
|
|
156
|
+
typer.Option(help="HTTPS clone URL of the central bompage repository."),
|
|
157
|
+
],
|
|
158
|
+
token: Annotated[
|
|
159
|
+
str,
|
|
160
|
+
typer.Option(
|
|
161
|
+
envvar="BOMPAGE_TOKEN",
|
|
162
|
+
help="Scoped write token for the central repository.",
|
|
163
|
+
),
|
|
164
|
+
],
|
|
165
|
+
branch: Annotated[
|
|
166
|
+
str,
|
|
167
|
+
typer.Option(help="Target branch in the central repository."),
|
|
168
|
+
] = DEFAULT_BRANCH,
|
|
169
|
+
sbom_version: Annotated[
|
|
170
|
+
str | None,
|
|
171
|
+
typer.Option(help="Version suffix for the SBOM file name."),
|
|
172
|
+
] = None,
|
|
173
|
+
sbom_format: Annotated[
|
|
174
|
+
str | None,
|
|
175
|
+
typer.Option(help="Informative (spdx | cyclonedx); recorded in the commit."),
|
|
176
|
+
] = None,
|
|
177
|
+
git_user: Annotated[
|
|
178
|
+
str, typer.Option(help="Commit author name.")
|
|
179
|
+
] = DEFAULT_GIT_USER,
|
|
180
|
+
git_email: Annotated[
|
|
181
|
+
str, typer.Option(help="Commit author email.")
|
|
182
|
+
] = DEFAULT_GIT_EMAIL,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Publish one SBOM into the central repository.
|
|
185
|
+
|
|
186
|
+
Clones the central repo, drops the SBOM, commits only if it changed, pushes.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
outcome = push_report(
|
|
190
|
+
component=component,
|
|
191
|
+
sbom=sbom,
|
|
192
|
+
repo=repo,
|
|
193
|
+
token=token,
|
|
194
|
+
branch=branch,
|
|
195
|
+
sbom_version=sbom_version or None,
|
|
196
|
+
sbom_format=sbom_format or None,
|
|
197
|
+
git_user=git_user,
|
|
198
|
+
git_email=git_email,
|
|
199
|
+
)
|
|
200
|
+
except PushError as err:
|
|
201
|
+
typer.echo(f"push: {err}", err=True)
|
|
202
|
+
raise typer.Exit(1) from err
|
|
203
|
+
|
|
204
|
+
if outcome == "unchanged":
|
|
205
|
+
typer.echo(f"push: {component} unchanged, nothing to commit")
|
|
206
|
+
else:
|
|
207
|
+
typer.echo(f"push: committed {component}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def prune(
|
|
212
|
+
repo: Annotated[
|
|
213
|
+
str,
|
|
214
|
+
typer.Option(help="HTTPS clone URL of the central bompage repository."),
|
|
215
|
+
],
|
|
216
|
+
token: Annotated[
|
|
217
|
+
str,
|
|
218
|
+
typer.Option(
|
|
219
|
+
envvar="BOMPAGE_TOKEN",
|
|
220
|
+
help="Scoped write token for the central repository.",
|
|
221
|
+
),
|
|
222
|
+
],
|
|
223
|
+
branch: Annotated[
|
|
224
|
+
str,
|
|
225
|
+
typer.Option(help="Target branch in the central repository."),
|
|
226
|
+
] = DEFAULT_BRANCH,
|
|
227
|
+
keep_days: Annotated[
|
|
228
|
+
int,
|
|
229
|
+
typer.Option(
|
|
230
|
+
min=1,
|
|
231
|
+
help="Snapshots older than this many days are removed; the most "
|
|
232
|
+
"recent snapshot of each component is always kept.",
|
|
233
|
+
),
|
|
234
|
+
] = 90,
|
|
235
|
+
dry_run: Annotated[
|
|
236
|
+
bool,
|
|
237
|
+
typer.Option(help="List what would be removed without committing."),
|
|
238
|
+
] = False,
|
|
239
|
+
git_user: Annotated[
|
|
240
|
+
str, typer.Option(help="Commit author name.")
|
|
241
|
+
] = DEFAULT_GIT_USER,
|
|
242
|
+
git_email: Annotated[
|
|
243
|
+
str, typer.Option(help="Commit author email.")
|
|
244
|
+
] = DEFAULT_GIT_EMAIL,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Remove SBOM snapshots older than --keep-days from the central repository.
|
|
247
|
+
|
|
248
|
+
Clones the central repo, deletes the stale snapshot files in a single
|
|
249
|
+
ordinary commit (no history rewrite) and pushes (spec section 5.4).
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
outcome, paths = prune_report(
|
|
253
|
+
repo=repo,
|
|
254
|
+
token=token,
|
|
255
|
+
branch=branch,
|
|
256
|
+
keep_days=keep_days,
|
|
257
|
+
dry_run=dry_run,
|
|
258
|
+
git_user=git_user,
|
|
259
|
+
git_email=git_email,
|
|
260
|
+
)
|
|
261
|
+
except GitRepoError as err:
|
|
262
|
+
typer.echo(f"prune: {err}", err=True)
|
|
263
|
+
raise typer.Exit(1) from err
|
|
264
|
+
|
|
265
|
+
if outcome == "nothing":
|
|
266
|
+
typer.echo(f"prune: nothing older than {keep_days} days")
|
|
267
|
+
return
|
|
268
|
+
if outcome == "would-prune":
|
|
269
|
+
for path in paths:
|
|
270
|
+
typer.echo(f" {path}")
|
|
271
|
+
typer.echo(f"prune: {len(paths)} file(s) would be removed (dry run)")
|
|
272
|
+
return
|
|
273
|
+
components = len({Path(path).parent.name for path in paths})
|
|
274
|
+
typer.echo(
|
|
275
|
+
f"prune: removed {len(paths)} snapshot(s) across {components} component(s)"
|
|
276
|
+
)
|