Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ Setting `LOCK_FILE` (or `SBOM_FILE`) to `none` creates an empty SBOM and injects
| `COMPONENT_VERSION` | No | Override component version in SBOM |
| `COMPONENT_PURL` | No | Add or override component PURL in SBOM |
| `PRODUCT_RELEASE` | No | Tag SBOM with product releases (see [Product Releases](#product-releases)) |
| `SUBMODULE_PATH` | No | Treat the component as a git submodule pinned at this path: resolve the pin to a version (exact version tag, else short commit SHA), attach the component's existing SBOM at that version if one exists, otherwise generate and upload it. Requires `LOCK_FILE` and the `sbomify` upload destination |
| `UPLOAD` | No | Upload SBOM (default: true) |
| `UPLOAD_DESTINATIONS` | No | Comma-separated destinations: `sbomify`, `dependency-track` (default: `sbomify`) |
| `API_BASE_URL` | No | Override sbomify API URL for self-hosted instances |
Expand Down
304 changes: 226 additions & 78 deletions sbomify_action/cli/main.py

Large diffs are not rendered by default.

59 changes: 56 additions & 3 deletions sbomify_action/cli/wizard/ci_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,21 @@ def _matrix_block(
components: list[PlannedComponent],
formats: list[SbomFormat],
component_ids: dict[str, str],
*,
attestation: bool = False,
) -> str:
"""Render the ``matrix.include:`` rows — one per (component, format).

Row ``name`` is suffixed with the format only when more than one
format is being emitted, so single-format workflows stay readable.

When ``attestation`` is enabled every row carries an ``attest``
boolean gating the attest-build-provenance step. Lockfiles inside a
submodule / vendored repo get ``attest: false``: the attestation's
Sigstore identity would be *this* repo's workflow, but the SBOM
describes code owned by another repository — signing it here would
produce provenance that fails verification against the repo the
code actually comes from.
"""
rows: list[str] = []
multi_format = len(formats) > 1
Expand Down Expand Up @@ -288,14 +298,33 @@ def _matrix_block(
ext = _format_extension(fmt)
row_name = f"{row_slug}-{fmt}" if multi_format else row_slug
output_file = f"{row_slug}.{ext}"
rows.append(
row = (
" - name: " + row_name + "\n"
" component_name: " + c.name + "\n"
" component_id: " + cid + "\n"
" lockfile: " + rel + "\n"
" sbom_format: " + fmt + "\n"
" output_file: " + output_file + "\n"
)
if c.lockfile.nested_repo:
# Drives the action's attach-or-backfill submodule mode:
# it resolves the pinned commit to a version, attaches the
# component's existing SBOM at that version when one
# exists, and only generates + uploads otherwise.
row += " submodule_path: " + c.lockfile.nested_repo + "\n"
if attestation:
if c.lockfile.nested_repo:
kind = "submodule" if c.lockfile.nested_repo_kind == "submodule" else "vendored repo"
row += (
f" # Deliberately NOT signed: {c.lockfile.nested_repo} is a {kind} — "
"another repository's code. An attestation from this repo's workflow\n"
" # would carry the wrong Sigstore identity and fail verification "
"against the repository the code actually comes from.\n"
" attest: false\n"
)
else:
row += " attest: true\n"
rows.append(row)
return "".join(rows)


Expand Down Expand Up @@ -372,6 +401,7 @@ def _env_block(
enrich: bool,
release_strategy: ReleaseStrategy,
product_id: str | None,
has_submodules: bool = False,
) -> str:
"""The ``env:`` block under the action step.

Expand Down Expand Up @@ -402,6 +432,10 @@ def _env_block(
" SYFT_CACHE_DIR: ${{ github.workspace }}/.sbomify-cache/syft",
]
)
if has_submodules:
# Empty for non-submodule rows (matrix field unset) — the action
# treats an empty SUBMODULE_PATH as disabled.
lines.append(" SUBMODULE_PATH: ${{ matrix.submodule_path }}")
if release_strategy == "tag" and product_id:
# PRODUCT_RELEASE is parsed by cli/main.py as a JSON list — see
# cli/main.py's "PRODUCT_RELEASE must be a JSON list like
Expand Down Expand Up @@ -469,7 +503,13 @@ def _attest_step() -> str:
" # If you hit one of the unsupported configurations, either upgrade to\n"
" # GitHub Enterprise Cloud, make the repo public, or remove this step.\n"
" # Reference: https://github.com/actions/attest-build-provenance\n"
" #\n"
" # Matrix entries with attest: false are skipped: those SBOMs describe\n"
" # code owned by another repository (a git submodule or vendored\n"
" # checkout), and an attestation signed by this repo's workflow would\n"
" # fail verification against the repository the code comes from.\n"
f" - uses: actions/attest-build-provenance@{PINNED_ATTEST_SHA} # {PINNED_ATTEST_VERSION}\n"
" if: ${{ matrix.attest }}\n"
" with:\n"
" subject-path: '${{ github.workspace }}/${{ matrix.output_file }}'\n"
)
Expand Down Expand Up @@ -509,6 +549,7 @@ def emit_workflow(
component_ids = component_ids or {}
lockfile_paths = [str(c.lockfile.rel_path) for c in plan.create_components]
formats = plan.sbom_formats or ["cyclonedx"]
has_submodules = any(c.lockfile.nested_repo for c in plan.create_components)

permissions = _permissions_block(plan.credential_mode, plan.attestation)
trigger = _trigger_block(plan.release_strategy, facts.default_branch, lockfile_paths)
Expand All @@ -520,9 +561,21 @@ def emit_workflow(
enrich=plan.enrich,
release_strategy=plan.release_strategy,
product_id=product_id or plan.use_product_id,
has_submodules=has_submodules,
)
matrix = _matrix_block(plan.create_components, formats, component_ids)
matrix = _matrix_block(plan.create_components, formats, component_ids, attestation=plan.attestation)
attest_step = _attest_step() if plan.attestation else ""
if has_submodules:
# Submodule contents are only needed on the backfill path (no
# published SBOM at the pinned version yet), but the checkout has
# to cover it unconditionally.
checkout_step = (
f" - uses: actions/checkout@{PINNED_CHECKOUT_SHA} # {PINNED_CHECKOUT_VERSION}\n"
" with:\n"
" submodules: recursive\n"
)
else:
checkout_step = f" - uses: actions/checkout@{PINNED_CHECKOUT_SHA} # {PINNED_CHECKOUT_VERSION}\n"

# Resolved by the caller (apply / review) so the GitHub lookup happens
# once per run. Omitted by snapshot tests and ad-hoc callers — fall back
Expand All @@ -544,7 +597,7 @@ def emit_workflow(
" include:\n"
f"{matrix}"
" steps:\n"
f" - uses: actions/checkout@{PINNED_CHECKOUT_SHA} # {PINNED_CHECKOUT_VERSION}\n"
f"{checkout_step}"
f"{_cache_step()}"
f"{version_step}"
f" - uses: {action_ref}\n"
Expand Down
56 changes: 55 additions & 1 deletion sbomify_action/cli/wizard/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pathlib import Path

from sbomify_action._generation.utils import ALL_LOCK_FILES, get_lock_file_ecosystem
from sbomify_action.cli.wizard.state import DiscoveredLockfile
from sbomify_action.cli.wizard.state import DiscoveredLockfile, NestedRepoKind

# Cap the walk so we never scan a degenerate monorepo into memory. 200
# is enormous in practice; if a repo legitimately has more lockfiles
Expand Down Expand Up @@ -165,15 +165,21 @@ def discover(repo_root: Path, *, repo_name: str | None = None) -> list[Discovere
if len(best_per_dir_eco) >= DISCOVERY_CAP:
break

submodule_paths = _submodule_paths(repo_root)
nested_cache: dict[Path, tuple[str, NestedRepoKind] | None] = {}

found: list[DiscoveredLockfile] = []
for (_dir, ecosystem), (_priority, path) in best_per_dir_eco.items():
rel = path.relative_to(repo_root)
nested = _enclosing_nested_repo(path.parent, repo_root, submodule_paths, nested_cache)
found.append(
DiscoveredLockfile(
path=path,
rel_path=rel,
ecosystem=ecosystem,
suggested_name=_suggested_name(repo_name, path.name),
nested_repo=nested[0] if nested else None,
nested_repo_kind=nested[1] if nested else None,
)
)

Expand All @@ -197,6 +203,54 @@ def discover(repo_root: Path, *, repo_name: str | None = None) -> list[Discovere
return found


# ``path = <value>`` lines inside .gitmodules. Values may be quoted when
# they contain spaces; git writes forward slashes on every platform.
_GITMODULES_PATH_RE = re.compile(r"^\s*path\s*=\s*(?P<path>.+?)\s*$", re.MULTILINE)


def _submodule_paths(repo_root: Path) -> frozenset[str]:
"""Repo-relative POSIX paths declared as submodules in ``.gitmodules``.

Parsed directly from the file rather than via ``git submodule`` so it
works without a git binary and for submodules that were declared but
never initialised.
"""
try:
text = (repo_root / ".gitmodules").read_text(encoding="utf-8", errors="replace")
except OSError:
return frozenset()
return frozenset(m.group("path").strip('"').rstrip("/") for m in _GITMODULES_PATH_RE.finditer(text))


def _enclosing_nested_repo(
directory: Path,
repo_root: Path,
submodule_paths: frozenset[str],
cache: dict[Path, tuple[str, NestedRepoKind] | None],
) -> tuple[str, NestedRepoKind] | None:
"""Innermost ancestor of ``directory`` that is its own repository.

A directory counts if it is declared in ``.gitmodules`` (submodule)
or has its own ``.git`` entry — a file for submodule checkouts, a
directory for vendored clones. Results are memoised per directory
since sibling lockfiles share ancestors.
"""
if directory == repo_root:
return None
if directory in cache:
return cache[directory]
rel = directory.relative_to(repo_root).as_posix()
result: tuple[str, NestedRepoKind] | None
if rel in submodule_paths:
result = (rel, "submodule")
elif (directory / ".git").exists():
result = (rel, "vendored")
else:
result = _enclosing_nested_repo(directory.parent, repo_root, submodule_paths, cache)
cache[directory] = result
return result


def _walk(root: Path) -> Iterator[Path]:
"""Yield every file under ``root``, skipping irrelevant directories."""
stack: list[Path] = [root]
Expand Down
15 changes: 14 additions & 1 deletion sbomify_action/cli/wizard/screens/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ def compose_body(self) -> ComposeResult:
"[b]n[/] to select none, [b]Enter[/] when you're done.",
classes="wizard-help",
)
if any(lf.nested_repo for lf in self.wizard.state.discovered):
yield Static(
"[#F4B57F]Lockfiles inside submodules or vendored repos are deselected "
"by default — they belong to another repository, so set up SBOMs there "
"instead.[/]",
id="nested-repo-note",
classes="wizard-help",
)
yield SelectionList[int](id="lockfile-list")
yield Static("", id="discover-status", markup=True)
with Horizontal(classes="button-row"):
Expand All @@ -48,7 +56,12 @@ def on_mount(self) -> None:
sel = self.query_one("#lockfile-list", SelectionList)
for idx, lf in enumerate(self.wizard.state.discovered):
label = f"{lf.rel_path} [#5E5E5E]({lf.ecosystem})[/]"
sel.add_option((label, idx, True)) # default-selected
if lf.nested_repo:
kind = "submodule" if lf.nested_repo_kind == "submodule" else "vendored repo"
label += f" [#F4B57F]({kind}: {lf.nested_repo})[/]"
# Nested-repo lockfiles default to deselected — they belong to
# another repository and are better tracked from there.
sel.add_option((label, idx, lf.nested_repo is None))
sel.focus()

def action_toggle_selection(self) -> None:
Expand Down
15 changes: 15 additions & 0 deletions sbomify_action/cli/wizard/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@
"""


NestedRepoKind = Literal["submodule", "vendored"]
"""Why a lockfile's directory belongs to a different repository.

- ``submodule`` — under a path declared in ``.gitmodules``.
- ``vendored`` — under a directory with its own ``.git`` (a checked-in
clone that isn't a registered submodule).
"""


@dataclass(frozen=True)
class DiscoveredLockfile:
"""One lockfile the wizard found in the repo."""
Expand All @@ -70,6 +79,12 @@ class DiscoveredLockfile:
rel_path: Path
ecosystem: str
suggested_name: str
nested_repo: str | None = None
"""Repo-relative POSIX path of the enclosing submodule / vendored
repo root, when the lockfile belongs to one. Those lockfiles are
better tracked from their own repository, so the discover screen
annotates them and leaves them deselected by default."""
nested_repo_kind: NestedRepoKind | None = None


@dataclass(frozen=True)
Expand Down
43 changes: 43 additions & 0 deletions sbomify_action/sbomify_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,49 @@ def patch_component_visibility(self, component_id: str, visibility: str) -> None
if not response.ok:
logger.warning(f"Failed to set visibility for component {component_id}: [{response.status_code}]")

def list_component_sboms(
self,
component_id: str,
*,
version: str | None = None,
sbom_format: str | None = None,
) -> list[dict[str, Any]]:
"""List a component's SBOMs, newest first.

``version`` / ``sbom_format`` are passed to the backend's
exact-match filters (``GET /api/v1/components/{id}/sboms``).
Each item is ``{"sbom": {id, version, format, created_at, ...},
"releases": [...], ...}``.
"""
params: dict[str, Any] = {}
if version is not None:
params["version"] = version
if sbom_format is not None:
params["format"] = sbom_format
return list(
self._paginate(
f"/api/v1/components/{component_id}/sboms",
params=params,
error_context="list component SBOMs",
)
)

def find_component_sbom(self, component_id: str, version: str, sbom_format: str) -> str | None:
"""ID of the newest SBOM at exactly ``(version, sbom_format)``, or None.

The filter params are re-checked client-side: a backend that
predates the server-side filters ignores unknown query params and
would otherwise return the full unfiltered listing, silently
matching the wrong SBOM.
"""
for item in self.list_component_sboms(component_id, version=version, sbom_format=sbom_format):
sbom = item.get("sbom")
if not isinstance(sbom, dict):
continue
if sbom.get("version") == version and sbom.get("format") == sbom_format and sbom.get("id"):
return str(sbom["id"])
return None

def get_augmentation_meta(self, component_id: str) -> dict[str, Any]:
"""Fetch augmentation metadata for a component.

Expand Down
Loading