From 9c2d3b498e0072bbd036f71f7dc64d7f5988420d Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:49:38 -0400 Subject: [PATCH 1/9] ci: add OSS license vulnerability report --- .../oss-license-vulnerability-report.yml | 55 +++ docs/source/index.rst | 1 + .../oss_license_vulnerability_report.rst | 54 +++ scripts/collect_oss_dependencies.py | 374 ++++++++++++++++++ .../run_oss_license_vulnerability_report.sh | 127 ++++++ 5 files changed, 611 insertions(+) create mode 100644 .github/workflows/oss-license-vulnerability-report.yml create mode 100644 docs/source/references/oss_license_vulnerability_report.rst create mode 100644 scripts/collect_oss_dependencies.py create mode 100755 scripts/run_oss_license_vulnerability_report.sh diff --git a/.github/workflows/oss-license-vulnerability-report.yml b/.github/workflows/oss-license-vulnerability-report.yml new file mode 100644 index 000000000..c877cba3b --- /dev/null +++ b/.github/workflows/oss-license-vulnerability-report.yml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: OSS license and vulnerability report + +on: + push: + branches: [ main ] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + oss-license-vulnerability-report: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install REUSE + run: | + python -m pip install --upgrade pip + python -m pip install reuse + + - name: Install Trivy + uses: aquasecurity/setup-trivy@v0.2.6 + + - name: Generate OSS report + run: | + ./scripts/run_oss_license_vulnerability_report.sh . + + - name: Publish OSS report summary + run: | + cat oss-report/summary.md >> "${GITHUB_STEP_SUMMARY}" + + - name: Upload OSS report artifact + uses: actions/upload-artifact@v6 + with: + name: isaacteleop-oss-license-vulnerability-report + path: oss-report + if-no-files-found: error + retention-days: 14 diff --git a/docs/source/index.rst b/docs/source/index.rst index 4c6c12994..217255b86 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -69,6 +69,7 @@ Table of Contents references/cloudxr references/oob_teleop_control references/egocentric_hand_reconstruction + references/oss_license_vulnerability_report references/license Indices and tables diff --git a/docs/source/references/oss_license_vulnerability_report.rst b/docs/source/references/oss_license_vulnerability_report.rst new file mode 100644 index 000000000..1d3bcd0a9 --- /dev/null +++ b/docs/source/references/oss_license_vulnerability_report.rst @@ -0,0 +1,54 @@ +.. _oss-license-vulnerability-report: + +OSS License and Vulnerability Report +==================================== + +The ``OSS license and vulnerability report`` workflow publishes a report-only CI +artifact for dependency inventory, vulnerability findings, and license metadata. +It is intended to make review evidence visible before any blocking policy is +enabled. + +Generated files +--------------- + +The workflow uploads the ``isaacteleop-oss-license-vulnerability-report`` +artifact. It contains: + +- ``dependency-inventory.json`` and ``dependency-inventory.md`` from the + repository manifest collector. +- ``trivy-vulnerability-report.json`` and, when available, + ``trivy-vulnerability-report.sarif``. +- ``reuse-lint.txt`` and, when available, ``reuse.spdx``. +- ``summary.md``, which is also appended to the GitHub workflow summary. + +Collector coverage +------------------ + +``scripts/collect_oss_dependencies.py`` scans common dependency declarations: + +- Python ``requirements*.txt`` and ``pyproject.toml`` files. +- npm ``package.json`` files. +- CMake ``FetchContent_Declare``, ``ExternalProject_Add``, and + ``CPMAddPackage`` declarations. +- ``vcpkg.json`` manifests. +- Docker ``FROM`` image references. +- GitHub Actions ``uses: owner/action@ref`` references. + +Policy mode +----------- + +The workflow is non-blocking by default. After the review policy is approved, +set ``OSS_REPORT_STRICT=true`` in CI to fail on scanner errors and extend the +wrapper with the agreed severity and license thresholds. + +Local use +--------- + +Run the report locally from the repository root: + +.. code-block:: bash + + ./scripts/run_oss_license_vulnerability_report.sh . + +If Trivy or REUSE is unavailable, the script still emits the inventory and +records the skipped tool in the summary. diff --git a/scripts/collect_oss_dependencies.py b/scripts/collect_oss_dependencies.py new file mode 100644 index 000000000..3dc7a38eb --- /dev/null +++ b/scripts/collect_oss_dependencies.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Collect a lightweight OSS dependency inventory from repository manifests.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - CI uses Python 3.12. + tomllib = None # type: ignore[assignment] + + +EXCLUDED_DIRS = { + ".cache", + ".git", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", +} + +REQUIREMENTS_RE = re.compile( + r"^\s*(?P[A-Za-z0-9_.-]+)\s*(?P(?:===|==|~=|!=|<=|>=|<|>).*)?$" +) +DOCKER_FROM_RE = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P\S+)", re.IGNORECASE) +GITHUB_ACTION_RE = re.compile(r"uses:\s*(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.\-/]+)@(?P[A-Za-z0-9_.\-/]+)") +CMAKE_FETCH_RE = re.compile( + r"(?PFetchContent_Declare|ExternalProject_Add|CPMAddPackage)\s*\((?P.*?)\)", + re.IGNORECASE | re.DOTALL, +) +CMAKE_NAME_RE = re.compile(r"^\s*(?P[A-Za-z0-9_.:+/-]+)", re.MULTILINE) +CMAKE_KEY_VALUE_RE = re.compile( + r"\b(?PGIT_REPOSITORY|GIT_TAG|URL|VERSION|NAME)\s+(?P\"[^\"]+\"|\S+)", + re.IGNORECASE, +) + + +def _repo_path(repo_root: Path, path: Path) -> str: + return path.relative_to(repo_root).as_posix() + + +def _iter_files(repo_root: Path) -> list[Path]: + files: list[Path] = [] + for current_root, dirnames, filenames in os.walk(repo_root): + dirnames[:] = [ + dirname + for dirname in dirnames + if dirname not in EXCLUDED_DIRS and not dirname.endswith("_deps") + ] + root = Path(current_root) + for filename in filenames: + files.append(root / filename) + return files + + +def _entry( + *, + ecosystem: str, + manifest_type: str, + name: str, + path: Path, + repo_root: Path, + line: int | None = None, + version: str | None = None, + scope: str | None = None, + source: str | None = None, +) -> dict[str, Any]: + return { + "ecosystem": ecosystem, + "manifest_type": manifest_type, + "name": name.strip(), + "version": (version or "").strip(), + "scope": (scope or "").strip(), + "source": (source or "").strip(), + "path": _repo_path(repo_root, path), + "line": line, + } + + +def _parse_requirement_text(path: Path, repo_root: Path) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1): + line = raw_line.split("#", maxsplit=1)[0].strip() + if not line or line.startswith(("-", "--")) or "://" in line: + continue + match = REQUIREMENTS_RE.match(line) + if match: + entries.append( + _entry( + ecosystem="python", + manifest_type="requirements", + name=match.group("name"), + version=match.group("specifier"), + path=path, + repo_root=repo_root, + line=line_number, + ) + ) + return entries + + +def _parse_pyproject(path: Path, repo_root: Path) -> list[dict[str, Any]]: + if tomllib is None: + return [] + data = tomllib.loads(path.read_text(encoding="utf-8", errors="ignore")) + project = data.get("project", {}) + entries: list[dict[str, Any]] = [] + for dependency in project.get("dependencies", []) or []: + entries.append(_python_dependency_entry(dependency, path, repo_root, "runtime")) + optional = project.get("optional-dependencies", {}) or {} + for scope, dependencies in optional.items(): + for dependency in dependencies or []: + entries.append(_python_dependency_entry(dependency, path, repo_root, str(scope))) + return entries + + +def _python_dependency_entry(dependency: str, path: Path, repo_root: Path, scope: str) -> dict[str, Any]: + match = REQUIREMENTS_RE.match(dependency) + if match: + return _entry( + ecosystem="python", + manifest_type="pyproject", + name=match.group("name"), + version=match.group("specifier"), + scope=scope, + path=path, + repo_root=repo_root, + ) + return _entry( + ecosystem="python", + manifest_type="pyproject", + name=dependency, + scope=scope, + path=path, + repo_root=repo_root, + ) + + +def _parse_package_json(path: Path, repo_root: Path) -> list[dict[str, Any]]: + data = json.loads(path.read_text(encoding="utf-8", errors="ignore")) + entries: list[dict[str, Any]] = [] + for scope in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): + for name, version in (data.get(scope, {}) or {}).items(): + entries.append( + _entry( + ecosystem="npm", + manifest_type="package.json", + name=name, + version=str(version), + scope=scope, + path=path, + repo_root=repo_root, + ) + ) + return entries + + +def _parse_vcpkg_manifest(path: Path, repo_root: Path) -> list[dict[str, Any]]: + data = json.loads(path.read_text(encoding="utf-8", errors="ignore")) + entries: list[dict[str, Any]] = [] + for dependency in data.get("dependencies", []) or []: + if isinstance(dependency, str): + name = dependency + version = "" + else: + name = str(dependency.get("name", "")) + version = str(dependency.get("version>=", dependency.get("version", ""))) + if name: + entries.append( + _entry( + ecosystem="vcpkg", + manifest_type="vcpkg.json", + name=name, + version=version, + path=path, + repo_root=repo_root, + ) + ) + return entries + + +def _parse_dockerfile(path: Path, repo_root: Path) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1): + match = DOCKER_FROM_RE.match(raw_line) + if not match: + continue + image = match.group("image").split("@", maxsplit=1)[0] + name, _, version = image.partition(":") + entries.append( + _entry( + ecosystem="container", + manifest_type="Dockerfile", + name=name, + version=version, + path=path, + repo_root=repo_root, + line=line_number, + ) + ) + return entries + + +def _parse_github_workflow(path: Path, repo_root: Path) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + for line_number, raw_line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1): + match = GITHUB_ACTION_RE.search(raw_line) + if match: + entries.append( + _entry( + ecosystem="github-actions", + manifest_type="workflow", + name=match.group("action"), + version=match.group("ref"), + path=path, + repo_root=repo_root, + line=line_number, + ) + ) + return entries + + +def _parse_cmake(path: Path, repo_root: Path) -> list[dict[str, Any]]: + text = path.read_text(encoding="utf-8", errors="ignore") + entries: list[dict[str, Any]] = [] + for match in CMAKE_FETCH_RE.finditer(text): + body = match.group("body") + fields = {field.group("key").upper(): field.group("value").strip('"') for field in CMAKE_KEY_VALUE_RE.finditer(body)} + name_match = CMAKE_NAME_RE.search(body) + name = fields.get("NAME") or (name_match.group("name") if name_match else "") + source = fields.get("GIT_REPOSITORY") or fields.get("URL") or "" + version = fields.get("GIT_TAG") or fields.get("VERSION") or "" + line = text.count("\n", 0, match.start()) + 1 + if name: + entries.append( + _entry( + ecosystem="cmake", + manifest_type=match.group("kind"), + name=name, + version=version, + source=source, + path=path, + repo_root=repo_root, + line=line, + ) + ) + return entries + + +def collect(repo_root: Path) -> dict[str, Any]: + entries: list[dict[str, Any]] = [] + manifests: Counter[str] = Counter() + for path in _iter_files(repo_root): + relative = _repo_path(repo_root, path) + name = path.name.lower() + suffix = path.suffix.lower() + try: + if name.startswith("requirements") and suffix == ".txt": + entries.extend(_parse_requirement_text(path, repo_root)) + manifests["requirements"] += 1 + elif name == "pyproject.toml": + entries.extend(_parse_pyproject(path, repo_root)) + manifests["pyproject"] += 1 + elif name == "package.json": + entries.extend(_parse_package_json(path, repo_root)) + manifests["package.json"] += 1 + elif name == "vcpkg.json": + entries.extend(_parse_vcpkg_manifest(path, repo_root)) + manifests["vcpkg.json"] += 1 + elif name.startswith("dockerfile") or "/dockerfile" in relative.lower(): + entries.extend(_parse_dockerfile(path, repo_root)) + manifests["Dockerfile"] += 1 + elif relative.startswith(".github/workflows/") and suffix in {".yml", ".yaml"}: + entries.extend(_parse_github_workflow(path, repo_root)) + manifests["GitHub workflow"] += 1 + elif name == "cmakelists.txt" or suffix == ".cmake": + entries.extend(_parse_cmake(path, repo_root)) + manifests["CMake"] += 1 + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + entries.append( + _entry( + ecosystem="scanner", + manifest_type="parse-error", + name=relative, + version=type(exc).__name__, + source=str(exc), + path=path, + repo_root=repo_root, + ) + ) + + entries.sort(key=lambda item: (item["ecosystem"], item["name"].lower(), item["path"], item["line"] or 0)) + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "repo_root": str(repo_root), + "manifest_counts": dict(sorted(manifests.items())), + "dependency_count": len([entry for entry in entries if entry["manifest_type"] != "parse-error"]), + "parse_error_count": len([entry for entry in entries if entry["manifest_type"] == "parse-error"]), + "dependencies": entries, + } + + +def write_summary(report: dict[str, Any], summary_path: Path, limit: int = 200) -> None: + counts = Counter(entry["ecosystem"] for entry in report["dependencies"] if entry["manifest_type"] != "parse-error") + lines = [ + "# OSS dependency inventory", + "", + f"Generated at: `{report['generated_at']}`", + "", + "## Totals", + "", + f"- Dependencies discovered: `{report['dependency_count']}`", + f"- Parse errors: `{report['parse_error_count']}`", + "", + "## Ecosystems", + "", + ] + for ecosystem, count in sorted(counts.items()): + lines.append(f"- `{ecosystem}`: `{count}`") + lines.extend(["", "## Manifest coverage", ""]) + for manifest, count in report["manifest_counts"].items(): + lines.append(f"- `{manifest}`: `{count}`") + lines.extend(["", "## Dependency sample", "", "| Ecosystem | Name | Version | Scope | Source | Path |", "|---|---|---|---|---|---|"]) + for entry in report["dependencies"][:limit]: + path = entry["path"] + if entry["line"]: + path = f"{path}:{entry['line']}" + lines.append( + "| {ecosystem} | {name} | {version} | {scope} | {source} | {path} |".format( + ecosystem=entry["ecosystem"], + name=entry["name"].replace("|", "\\|"), + version=entry["version"].replace("|", "\\|"), + scope=entry["scope"].replace("|", "\\|"), + source=entry["source"].replace("|", "\\|"), + path=path.replace("|", "\\|"), + ) + ) + summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path, default=Path("oss-report/dependency-inventory.json")) + parser.add_argument("--summary", type=Path, default=Path("oss-report/dependency-inventory.md")) + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + report = collect(repo_root) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.summary.parent.mkdir(parents=True, exist_ok=True) + write_summary(report, args.summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_oss_license_vulnerability_report.sh b/scripts/run_oss_license_vulnerability_report.sh new file mode 100755 index 000000000..01fa220c5 --- /dev/null +++ b/scripts/run_oss_license_vulnerability_report.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="${1:-.}" +output_dir="${OSS_REPORT_DIR:-oss-report}" +mkdir -p "${output_dir}" + +python3 "${repo_root}/scripts/collect_oss_dependencies.py" \ + --repo-root "${repo_root}" \ + --output "${output_dir}/dependency-inventory.json" \ + --summary "${output_dir}/dependency-inventory.md" + +trivy_status=0 +if command -v trivy >/dev/null 2>&1; then + trivy fs \ + --no-progress \ + --format json \ + --output "${output_dir}/trivy-vulnerability-report.json" \ + --scanners vuln,license \ + "${repo_root}" || trivy_status=$? + + trivy fs \ + --no-progress \ + --format sarif \ + --output "${output_dir}/trivy-vulnerability-report.sarif" \ + --scanners vuln \ + "${repo_root}" || true +else + trivy_status=127 + cat > "${output_dir}/trivy-vulnerability-report.json" <<'JSON' +{ + "Results": [], + "Metadata": { + "Status": "trivy not installed" + } +} +JSON +fi + +reuse_status=0 +if command -v reuse >/dev/null 2>&1; then + reuse lint > "${output_dir}/reuse-lint.txt" 2>&1 || reuse_status=$? + reuse spdx -o "${output_dir}/reuse.spdx" || reuse_status=$? +else + reuse_status=127 + cat > "${output_dir}/reuse-lint.txt" <<'TXT' +reuse not installed +TXT +fi + +python3 - "${output_dir}" "${trivy_status}" "${reuse_status}" <<'PY' +from __future__ import annotations + +import json +import sys +from pathlib import Path + +output_dir = Path(sys.argv[1]) +trivy_status = int(sys.argv[2]) +reuse_status = int(sys.argv[3]) + +inventory = json.loads((output_dir / "dependency-inventory.json").read_text(encoding="utf-8")) +trivy_report = json.loads((output_dir / "trivy-vulnerability-report.json").read_text(encoding="utf-8")) + +severity_counts: dict[str, int] = {} +license_count = 0 +for result in trivy_report.get("Results", []) or []: + for vulnerability in result.get("Vulnerabilities", []) or []: + severity = vulnerability.get("Severity", "UNKNOWN") + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + license_count += len(result.get("Licenses", []) or []) + +lines = [ + "# OSS license and vulnerability report", + "", + "This CI job is report-only by default. Set `OSS_REPORT_STRICT=true` after policy approval to fail on scanner errors.", + "", + "## Inventory", + "", + f"- Dependencies discovered: `{inventory.get('dependency_count', 0)}`", + f"- Parse errors: `{inventory.get('parse_error_count', 0)}`", + "", + "## Vulnerabilities", + "", +] +if trivy_status == 127: + lines.append("- Trivy was not installed; vulnerability scanning was skipped.") +else: + lines.append(f"- Trivy exit status: `{trivy_status}`") + if severity_counts: + for severity, count in sorted(severity_counts.items()): + lines.append(f"- `{severity}`: `{count}`") + else: + lines.append("- No vulnerabilities were reported by Trivy.") + +lines.extend( + [ + "", + "## Licenses", + "", + f"- Trivy license findings: `{license_count}`", + f"- REUSE exit status: `{reuse_status}`", + "", + "## Artifacts", + "", + "- `dependency-inventory.json`", + "- `dependency-inventory.md`", + "- `trivy-vulnerability-report.json`", + "- `trivy-vulnerability-report.sarif` when Trivy SARIF generation succeeds", + "- `reuse-lint.txt`", + "- `reuse.spdx` when REUSE is available", + ] +) + +(output_dir / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8") +PY + +cat "${output_dir}/summary.md" + +if [[ "${OSS_REPORT_STRICT:-false}" == "true" && "${trivy_status}" -ne 0 ]]; then + exit "${trivy_status}" +fi + +exit 0 From b554357a1484ebbfb9d20c7eafe8583207815d2d Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Sat, 18 Jul 2026 01:36:51 -0400 Subject: [PATCH 2/9] style: format OSS dependency collector --- scripts/collect_oss_dependencies.py | 78 +++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 15 deletions(-) mode change 100644 => 100755 scripts/collect_oss_dependencies.py diff --git a/scripts/collect_oss_dependencies.py b/scripts/collect_oss_dependencies.py old mode 100644 new mode 100755 index 3dc7a38eb..9c6b8c0b0 --- a/scripts/collect_oss_dependencies.py +++ b/scripts/collect_oss_dependencies.py @@ -95,7 +95,9 @@ def _entry( def _parse_requirement_text(path: Path, repo_root: Path) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] - for line_number, raw_line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1): + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1 + ): line = raw_line.split("#", maxsplit=1)[0].strip() if not line or line.startswith(("-", "--")) or "://" in line: continue @@ -122,15 +124,21 @@ def _parse_pyproject(path: Path, repo_root: Path) -> list[dict[str, Any]]: project = data.get("project", {}) entries: list[dict[str, Any]] = [] for dependency in project.get("dependencies", []) or []: - entries.append(_python_dependency_entry(dependency, path, repo_root, "runtime")) + entries.append( + _python_dependency_entry(dependency, path, repo_root, "runtime") + ) optional = project.get("optional-dependencies", {}) or {} for scope, dependencies in optional.items(): for dependency in dependencies or []: - entries.append(_python_dependency_entry(dependency, path, repo_root, str(scope))) + entries.append( + _python_dependency_entry(dependency, path, repo_root, str(scope)) + ) return entries -def _python_dependency_entry(dependency: str, path: Path, repo_root: Path, scope: str) -> dict[str, Any]: +def _python_dependency_entry( + dependency: str, path: Path, repo_root: Path, scope: str +) -> dict[str, Any]: match = REQUIREMENTS_RE.match(dependency) if match: return _entry( @@ -155,7 +163,12 @@ def _python_dependency_entry(dependency: str, path: Path, repo_root: Path, scope def _parse_package_json(path: Path, repo_root: Path) -> list[dict[str, Any]]: data = json.loads(path.read_text(encoding="utf-8", errors="ignore")) entries: list[dict[str, Any]] = [] - for scope in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): + for scope in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ): for name, version in (data.get(scope, {}) or {}).items(): entries.append( _entry( @@ -197,7 +210,9 @@ def _parse_vcpkg_manifest(path: Path, repo_root: Path) -> list[dict[str, Any]]: def _parse_dockerfile(path: Path, repo_root: Path) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] - for line_number, raw_line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1): + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1 + ): match = DOCKER_FROM_RE.match(raw_line) if not match: continue @@ -219,7 +234,9 @@ def _parse_dockerfile(path: Path, repo_root: Path) -> list[dict[str, Any]]: def _parse_github_workflow(path: Path, repo_root: Path) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] - for line_number, raw_line in enumerate(path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1): + for line_number, raw_line in enumerate( + path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1 + ): match = GITHUB_ACTION_RE.search(raw_line) if match: entries.append( @@ -241,7 +258,10 @@ def _parse_cmake(path: Path, repo_root: Path) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] for match in CMAKE_FETCH_RE.finditer(text): body = match.group("body") - fields = {field.group("key").upper(): field.group("value").strip('"') for field in CMAKE_KEY_VALUE_RE.finditer(body)} + fields = { + field.group("key").upper(): field.group("value").strip('"') + for field in CMAKE_KEY_VALUE_RE.finditer(body) + } name_match = CMAKE_NAME_RE.search(body) name = fields.get("NAME") or (name_match.group("name") if name_match else "") source = fields.get("GIT_REPOSITORY") or fields.get("URL") or "" @@ -286,7 +306,10 @@ def collect(repo_root: Path) -> dict[str, Any]: elif name.startswith("dockerfile") or "/dockerfile" in relative.lower(): entries.extend(_parse_dockerfile(path, repo_root)) manifests["Dockerfile"] += 1 - elif relative.startswith(".github/workflows/") and suffix in {".yml", ".yaml"}: + elif relative.startswith(".github/workflows/") and suffix in { + ".yml", + ".yaml", + }: entries.extend(_parse_github_workflow(path, repo_root)) manifests["GitHub workflow"] += 1 elif name == "cmakelists.txt" or suffix == ".cmake": @@ -305,19 +328,34 @@ def collect(repo_root: Path) -> dict[str, Any]: ) ) - entries.sort(key=lambda item: (item["ecosystem"], item["name"].lower(), item["path"], item["line"] or 0)) + entries.sort( + key=lambda item: ( + item["ecosystem"], + item["name"].lower(), + item["path"], + item["line"] or 0, + ) + ) return { "generated_at": datetime.now(timezone.utc).isoformat(), "repo_root": str(repo_root), "manifest_counts": dict(sorted(manifests.items())), - "dependency_count": len([entry for entry in entries if entry["manifest_type"] != "parse-error"]), - "parse_error_count": len([entry for entry in entries if entry["manifest_type"] == "parse-error"]), + "dependency_count": len( + [entry for entry in entries if entry["manifest_type"] != "parse-error"] + ), + "parse_error_count": len( + [entry for entry in entries if entry["manifest_type"] == "parse-error"] + ), "dependencies": entries, } def write_summary(report: dict[str, Any], summary_path: Path, limit: int = 200) -> None: - counts = Counter(entry["ecosystem"] for entry in report["dependencies"] if entry["manifest_type"] != "parse-error") + counts = Counter( + entry["ecosystem"] + for entry in report["dependencies"] + if entry["manifest_type"] != "parse-error" + ) lines = [ "# OSS dependency inventory", "", @@ -336,7 +374,15 @@ def write_summary(report: dict[str, Any], summary_path: Path, limit: int = 200) lines.extend(["", "## Manifest coverage", ""]) for manifest, count in report["manifest_counts"].items(): lines.append(f"- `{manifest}`: `{count}`") - lines.extend(["", "## Dependency sample", "", "| Ecosystem | Name | Version | Scope | Source | Path |", "|---|---|---|---|---|---|"]) + lines.extend( + [ + "", + "## Dependency sample", + "", + "| Ecosystem | Name | Version | Scope | Source | Path |", + "|---|---|---|---|---|---|", + ] + ) for entry in report["dependencies"][:limit]: path = entry["path"] if entry["line"]: @@ -364,7 +410,9 @@ def main() -> int: repo_root = args.repo_root.resolve() report = collect(repo_root) args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + args.output.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) args.summary.parent.mkdir(parents=True, exist_ok=True) write_summary(report, args.summary) return 0 From 107d689576370df0214b74aed88850bf41a02759 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Sat, 18 Jul 2026 01:43:41 -0400 Subject: [PATCH 3/9] style: match ruff format for OSS collector --- scripts/collect_oss_dependencies.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/scripts/collect_oss_dependencies.py b/scripts/collect_oss_dependencies.py index 9c6b8c0b0..fd21464b2 100755 --- a/scripts/collect_oss_dependencies.py +++ b/scripts/collect_oss_dependencies.py @@ -38,8 +38,12 @@ REQUIREMENTS_RE = re.compile( r"^\s*(?P[A-Za-z0-9_.-]+)\s*(?P(?:===|==|~=|!=|<=|>=|<|>).*)?$" ) -DOCKER_FROM_RE = re.compile(r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P\S+)", re.IGNORECASE) -GITHUB_ACTION_RE = re.compile(r"uses:\s*(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.\-/]+)@(?P[A-Za-z0-9_.\-/]+)") +DOCKER_FROM_RE = re.compile( + r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P\S+)", re.IGNORECASE +) +GITHUB_ACTION_RE = re.compile( + r"uses:\s*(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.\-/]+)@(?P[A-Za-z0-9_.\-/]+)" +) CMAKE_FETCH_RE = re.compile( r"(?PFetchContent_Declare|ExternalProject_Add|CPMAddPackage)\s*\((?P.*?)\)", re.IGNORECASE | re.DOTALL, @@ -124,9 +128,7 @@ def _parse_pyproject(path: Path, repo_root: Path) -> list[dict[str, Any]]: project = data.get("project", {}) entries: list[dict[str, Any]] = [] for dependency in project.get("dependencies", []) or []: - entries.append( - _python_dependency_entry(dependency, path, repo_root, "runtime") - ) + entries.append(_python_dependency_entry(dependency, path, repo_root, "runtime")) optional = project.get("optional-dependencies", {}) or {} for scope, dependencies in optional.items(): for dependency in dependencies or []: @@ -403,8 +405,12 @@ def write_summary(report: dict[str, Any], summary_path: Path, limit: int = 200) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo-root", type=Path, default=Path.cwd()) - parser.add_argument("--output", type=Path, default=Path("oss-report/dependency-inventory.json")) - parser.add_argument("--summary", type=Path, default=Path("oss-report/dependency-inventory.md")) + parser.add_argument( + "--output", type=Path, default=Path("oss-report/dependency-inventory.json") + ) + parser.add_argument( + "--summary", type=Path, default=Path("oss-report/dependency-inventory.md") + ) args = parser.parse_args() repo_root = args.repo_root.resolve() From 11899fe9c3c5d3c1eb9fb965b07125ed2babc098 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Sat, 18 Jul 2026 22:23:51 -0400 Subject: [PATCH 4/9] oss: Clarify scanner coverage and license evidence --- .../oss-license-vulnerability-report.yml | 8 +- .../oss_license_vulnerability_report.rst | 24 +++++- .../run_oss_license_vulnerability_report.sh | 76 +++++++++++++++---- 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/.github/workflows/oss-license-vulnerability-report.yml b/.github/workflows/oss-license-vulnerability-report.yml index c877cba3b..52de927b8 100644 --- a/.github/workflows/oss-license-vulnerability-report.yml +++ b/.github/workflows/oss-license-vulnerability-report.yml @@ -43,10 +43,16 @@ jobs: ./scripts/run_oss_license_vulnerability_report.sh . - name: Publish OSS report summary + if: always() run: | - cat oss-report/summary.md >> "${GITHUB_STEP_SUMMARY}" + if [[ -f oss-report/summary.md ]]; then + cat oss-report/summary.md >> "${GITHUB_STEP_SUMMARY}" + else + echo "OSS report generation did not produce summary.md." >> "${GITHUB_STEP_SUMMARY}" + fi - name: Upload OSS report artifact + if: always() uses: actions/upload-artifact@v6 with: name: isaacteleop-oss-license-vulnerability-report diff --git a/docs/source/references/oss_license_vulnerability_report.rst b/docs/source/references/oss_license_vulnerability_report.rst index 1d3bcd0a9..9e13e8217 100644 --- a/docs/source/references/oss_license_vulnerability_report.rst +++ b/docs/source/references/oss_license_vulnerability_report.rst @@ -6,7 +6,8 @@ OSS License and Vulnerability Report The ``OSS license and vulnerability report`` workflow publishes a report-only CI artifact for dependency inventory, vulnerability findings, and license metadata. It is intended to make review evidence visible before any blocking policy is -enabled. +enabled. The report is evidence, not a certification that every inventoried +reference has been resolved and scanned. Generated files --------------- @@ -34,12 +35,29 @@ Collector coverage - Docker ``FROM`` image references. - GitHub Actions ``uses: owner/action@ref`` references. +The inventory and vulnerability scan have different scopes. The collector records +references even when they use ranges, variables, branch names, or source URLs. +Trivy reports vulnerabilities only for package names and concrete versions that +its filesystem analyzers can resolve. ``summary.md`` records both the inventory +total and the scanner-resolved package count so a clean scan is not interpreted as +coverage of unresolved inventory entries. + +License hygiene +--------------- + +REUSE runs before report artifacts are generated, so generated JSON, SARIF, and +Markdown files do not inflate the missing-license baseline. ``summary.md`` reports +whether REUSE passed and, when available, the number of files with license and +copyright metadata. Full details remain in ``reuse-lint.txt`` and ``reuse.spdx``. + Policy mode ----------- The workflow is non-blocking by default. After the review policy is approved, -set ``OSS_REPORT_STRICT=true`` in CI to fail on scanner errors and extend the -wrapper with the agreed severity and license thresholds. +set ``OSS_REPORT_STRICT=true`` in CI to fail when Trivy or REUSE returns a nonzero +status. Severity and license thresholds still require stakeholder approval; until +then, a successful report job means the evidence was generated, not that the +repository meets a final compliance policy. Local use --------- diff --git a/scripts/run_oss_license_vulnerability_report.sh b/scripts/run_oss_license_vulnerability_report.sh index 01fa220c5..f644055bd 100755 --- a/scripts/run_oss_license_vulnerability_report.sh +++ b/scripts/run_oss_license_vulnerability_report.sh @@ -8,6 +8,18 @@ repo_root="${1:-.}" output_dir="${OSS_REPORT_DIR:-oss-report}" mkdir -p "${output_dir}" +reuse_status=0 +if command -v reuse >/dev/null 2>&1; then + # Run before generating artifacts so the report does not flag its own output. + reuse lint > "${output_dir}/reuse-lint.txt" 2>&1 || reuse_status=$? + reuse spdx -o "${output_dir}/reuse.spdx" || reuse_status=$? +else + reuse_status=127 + cat > "${output_dir}/reuse-lint.txt" <<'TXT' +reuse not installed +TXT +fi + python3 "${repo_root}/scripts/collect_oss_dependencies.py" \ --repo-root "${repo_root}" \ --output "${output_dir}/dependency-inventory.json" \ @@ -40,21 +52,11 @@ else JSON fi -reuse_status=0 -if command -v reuse >/dev/null 2>&1; then - reuse lint > "${output_dir}/reuse-lint.txt" 2>&1 || reuse_status=$? - reuse spdx -o "${output_dir}/reuse.spdx" || reuse_status=$? -else - reuse_status=127 - cat > "${output_dir}/reuse-lint.txt" <<'TXT' -reuse not installed -TXT -fi - python3 - "${output_dir}" "${trivy_status}" "${reuse_status}" <<'PY' from __future__ import annotations import json +import re import sys from pathlib import Path @@ -64,19 +66,32 @@ reuse_status = int(sys.argv[3]) inventory = json.loads((output_dir / "dependency-inventory.json").read_text(encoding="utf-8")) trivy_report = json.loads((output_dir / "trivy-vulnerability-report.json").read_text(encoding="utf-8")) +reuse_lint = (output_dir / "reuse-lint.txt").read_text(encoding="utf-8") severity_counts: dict[str, int] = {} license_count = 0 +resolved_package_count = 0 +resolved_target_count = 0 for result in trivy_report.get("Results", []) or []: + packages = result.get("Packages", []) or [] + resolved_package_count += len(packages) + if packages: + resolved_target_count += 1 for vulnerability in result.get("Vulnerabilities", []) or []: severity = vulnerability.get("Severity", "UNKNOWN") severity_counts[severity] = severity_counts.get(severity, 0) + 1 license_count += len(result.get("Licenses", []) or []) +def reuse_fraction(label: str) -> str | None: + match = re.search(rf"^{re.escape(label)}:\s*(\d+\s*/\s*\d+)$", reuse_lint, re.MULTILINE) + return match.group(1).replace(" ", "") if match else None + + lines = [ "# OSS license and vulnerability report", "", - "This CI job is report-only by default. Set `OSS_REPORT_STRICT=true` after policy approval to fail on scanner errors.", + "This CI job is report-only by default. It publishes discovery and scanner evidence; it does not certify repository compliance.", + "Set `OSS_REPORT_STRICT=true` after policy approval to fail when Trivy or REUSE returns a nonzero status.", "", "## Inventory", "", @@ -90,11 +105,19 @@ if trivy_status == 127: lines.append("- Trivy was not installed; vulnerability scanning was skipped.") else: lines.append(f"- Trivy exit status: `{trivy_status}`") + lines.append(f"- Scanner-resolved packages: `{resolved_package_count}`") + lines.append(f"- Manifest targets with resolved packages: `{resolved_target_count}`") if severity_counts: for severity, count in sorted(severity_counts.items()): lines.append(f"- `{severity}`: `{count}`") else: - lines.append("- No vulnerabilities were reported by Trivy.") + lines.append( + "- No vulnerabilities were reported for the concrete package versions Trivy resolved." + ) + if resolved_package_count < inventory.get("dependency_count", 0): + lines.append( + "- Inventory-only references are not claimed as vulnerability-scanned until Trivy resolves a concrete package and version." + ) lines.extend( [ @@ -103,6 +126,24 @@ lines.extend( "", f"- Trivy license findings: `{license_count}`", f"- REUSE exit status: `{reuse_status}`", + ] +) +if reuse_status == 127: + lines.append("- REUSE was not installed; license hygiene validation was skipped.") +elif reuse_status == 0: + lines.append("- REUSE compliance: `pass`") +else: + lines.append("- REUSE compliance: `fail` (see `reuse-lint.txt`)") + +license_fraction = reuse_fraction("Files with license information") +copyright_fraction = reuse_fraction("Files with copyright information") +if license_fraction: + lines.append(f"- Files with license information: `{license_fraction}`") +if copyright_fraction: + lines.append(f"- Files with copyright information: `{copyright_fraction}`") + +lines.extend( + [ "", "## Artifacts", "", @@ -120,8 +161,13 @@ PY cat "${output_dir}/summary.md" -if [[ "${OSS_REPORT_STRICT:-false}" == "true" && "${trivy_status}" -ne 0 ]]; then - exit "${trivy_status}" +if [[ "${OSS_REPORT_STRICT:-false}" == "true" ]]; then + if [[ "${trivy_status}" -ne 0 ]]; then + exit "${trivy_status}" + fi + if [[ "${reuse_status}" -ne 0 ]]; then + exit "${reuse_status}" + fi fi exit 0 From 81277e5ce289354addb3a63fbdcfd842f3b3a4c6 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Sat, 18 Jul 2026 22:28:44 -0400 Subject: [PATCH 5/9] oss: Include REUSE coverage fractions --- scripts/run_oss_license_vulnerability_report.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/run_oss_license_vulnerability_report.sh b/scripts/run_oss_license_vulnerability_report.sh index f644055bd..0b538e518 100755 --- a/scripts/run_oss_license_vulnerability_report.sh +++ b/scripts/run_oss_license_vulnerability_report.sh @@ -83,7 +83,11 @@ for result in trivy_report.get("Results", []) or []: license_count += len(result.get("Licenses", []) or []) def reuse_fraction(label: str) -> str | None: - match = re.search(rf"^{re.escape(label)}:\s*(\d+\s*/\s*\d+)$", reuse_lint, re.MULTILINE) + match = re.search( + rf"^(?:\*\s+)?{re.escape(label)}:\s*(\d+\s*/\s*\d+)$", + reuse_lint, + re.MULTILINE, + ) return match.group(1).replace(" ", "") if match else None From 1b84605c944ee10ee438753ecced53e0bce9f0b9 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Wed, 22 Jul 2026 17:40:12 -0400 Subject: [PATCH 6/9] Fix OSS report review findings --- .../oss-license-vulnerability-report.yml | 4 ++ .../oss_license_vulnerability_report.rst | 10 +++-- scripts/collect_oss_dependencies.py | 9 +++- .../run_oss_license_vulnerability_report.sh | 42 ++++++++++++------- 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/.github/workflows/oss-license-vulnerability-report.yml b/.github/workflows/oss-license-vulnerability-report.yml index 52de927b8..e3ff8a7df 100644 --- a/.github/workflows/oss-license-vulnerability-report.yml +++ b/.github/workflows/oss-license-vulnerability-report.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + persist-credentials: false - name: Setup Python uses: actions/setup-python@v6 @@ -37,6 +39,8 @@ jobs: - name: Install Trivy uses: aquasecurity/setup-trivy@v0.2.6 + with: + version: v0.72.0 - name: Generate OSS report run: | diff --git a/docs/source/references/oss_license_vulnerability_report.rst b/docs/source/references/oss_license_vulnerability_report.rst index 9e13e8217..bc74b2142 100644 --- a/docs/source/references/oss_license_vulnerability_report.rst +++ b/docs/source/references/oss_license_vulnerability_report.rst @@ -39,16 +39,18 @@ The inventory and vulnerability scan have different scopes. The collector record references even when they use ranges, variables, branch names, or source URLs. Trivy reports vulnerabilities only for package names and concrete versions that its filesystem analyzers can resolve. ``summary.md`` records both the inventory -total and the scanner-resolved package count so a clean scan is not interpreted as -coverage of unresolved inventory entries. +total, the Trivy-resolved concrete package count, and the resolved manifest target +paths so a clean scan is not interpreted as coverage of unresolved inventory +entries. License hygiene --------------- REUSE runs before report artifacts are generated, so generated JSON, SARIF, and Markdown files do not inflate the missing-license baseline. ``summary.md`` reports -whether REUSE passed and, when available, the number of files with license and -copyright metadata. Full details remain in ``reuse-lint.txt`` and ``reuse.spdx``. +the lint and SPDX export statuses separately, whether REUSE lint passed, and, when +available, the number of files with license and copyright metadata. Full details +remain in ``reuse-lint.txt`` and ``reuse.spdx``. Policy mode ----------- diff --git a/scripts/collect_oss_dependencies.py b/scripts/collect_oss_dependencies.py index fd21464b2..f04c544d3 100755 --- a/scripts/collect_oss_dependencies.py +++ b/scripts/collect_oss_dependencies.py @@ -219,7 +219,14 @@ def _parse_dockerfile(path: Path, repo_root: Path) -> list[dict[str, Any]]: if not match: continue image = match.group("image").split("@", maxsplit=1)[0] - name, _, version = image.partition(":") + last_colon = image.rfind(":") + last_slash = image.rfind("/") + if last_colon > last_slash: + name = image[:last_colon] + version = image[last_colon + 1 :] + else: + name = image + version = "" entries.append( _entry( ecosystem="container", diff --git a/scripts/run_oss_license_vulnerability_report.sh b/scripts/run_oss_license_vulnerability_report.sh index 0b538e518..010ad12db 100755 --- a/scripts/run_oss_license_vulnerability_report.sh +++ b/scripts/run_oss_license_vulnerability_report.sh @@ -8,13 +8,15 @@ repo_root="${1:-.}" output_dir="${OSS_REPORT_DIR:-oss-report}" mkdir -p "${output_dir}" -reuse_status=0 +reuse_lint_status=0 +reuse_spdx_status=0 if command -v reuse >/dev/null 2>&1; then # Run before generating artifacts so the report does not flag its own output. - reuse lint > "${output_dir}/reuse-lint.txt" 2>&1 || reuse_status=$? - reuse spdx -o "${output_dir}/reuse.spdx" || reuse_status=$? + reuse lint > "${output_dir}/reuse-lint.txt" 2>&1 || reuse_lint_status=$? + reuse spdx -o "${output_dir}/reuse.spdx" || reuse_spdx_status=$? else - reuse_status=127 + reuse_lint_status=127 + reuse_spdx_status=127 cat > "${output_dir}/reuse-lint.txt" <<'TXT' reuse not installed TXT @@ -52,7 +54,7 @@ else JSON fi -python3 - "${output_dir}" "${trivy_status}" "${reuse_status}" <<'PY' +python3 - "${output_dir}" "${trivy_status}" "${reuse_lint_status}" "${reuse_spdx_status}" <<'PY' from __future__ import annotations import json @@ -62,7 +64,8 @@ from pathlib import Path output_dir = Path(sys.argv[1]) trivy_status = int(sys.argv[2]) -reuse_status = int(sys.argv[3]) +reuse_lint_status = int(sys.argv[3]) +reuse_spdx_status = int(sys.argv[4]) inventory = json.loads((output_dir / "dependency-inventory.json").read_text(encoding="utf-8")) trivy_report = json.loads((output_dir / "trivy-vulnerability-report.json").read_text(encoding="utf-8")) @@ -71,12 +74,12 @@ reuse_lint = (output_dir / "reuse-lint.txt").read_text(encoding="utf-8") severity_counts: dict[str, int] = {} license_count = 0 resolved_package_count = 0 -resolved_target_count = 0 +resolved_targets: list[str] = [] for result in trivy_report.get("Results", []) or []: packages = result.get("Packages", []) or [] resolved_package_count += len(packages) if packages: - resolved_target_count += 1 + resolved_targets.append(str(result.get("Target", "unknown"))) for vulnerability in result.get("Vulnerabilities", []) or []: severity = vulnerability.get("Severity", "UNKNOWN") severity_counts[severity] = severity_counts.get(severity, 0) + 1 @@ -109,8 +112,13 @@ if trivy_status == 127: lines.append("- Trivy was not installed; vulnerability scanning was skipped.") else: lines.append(f"- Trivy exit status: `{trivy_status}`") - lines.append(f"- Scanner-resolved packages: `{resolved_package_count}`") - lines.append(f"- Manifest targets with resolved packages: `{resolved_target_count}`") + lines.append(f"- Trivy-resolved concrete packages: `{resolved_package_count}`") + lines.append(f"- Manifest targets resolved by Trivy: `{len(resolved_targets)}`") + if resolved_targets: + lines.append( + "- Resolved target paths: " + + ", ".join(f"`{target}`" for target in sorted(set(resolved_targets))) + ) if severity_counts: for severity, count in sorted(severity_counts.items()): lines.append(f"- `{severity}`: `{count}`") @@ -129,12 +137,13 @@ lines.extend( "## Licenses", "", f"- Trivy license findings: `{license_count}`", - f"- REUSE exit status: `{reuse_status}`", + f"- REUSE lint exit status: `{reuse_lint_status}`", + f"- REUSE SPDX export exit status: `{reuse_spdx_status}`", ] ) -if reuse_status == 127: +if reuse_lint_status == 127: lines.append("- REUSE was not installed; license hygiene validation was skipped.") -elif reuse_status == 0: +elif reuse_lint_status == 0: lines.append("- REUSE compliance: `pass`") else: lines.append("- REUSE compliance: `fail` (see `reuse-lint.txt`)") @@ -169,8 +178,11 @@ if [[ "${OSS_REPORT_STRICT:-false}" == "true" ]]; then if [[ "${trivy_status}" -ne 0 ]]; then exit "${trivy_status}" fi - if [[ "${reuse_status}" -ne 0 ]]; then - exit "${reuse_status}" + if [[ "${reuse_lint_status}" -ne 0 ]]; then + exit "${reuse_lint_status}" + fi + if [[ "${reuse_spdx_status}" -ne 0 ]]; then + exit "${reuse_spdx_status}" fi fi From f0f09634916438b3b2a6063bed6c850d723818b3 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Wed, 22 Jul 2026 19:19:36 -0400 Subject: [PATCH 7/9] Resolve full OSS dependency coverage into CycloneDX Configure the maximum public CMake profile, resolve npm/Python/vcpkg inputs, and fail closed when dependency declarations are omitted. Generate one canonical CycloneDX SBOM and scan that SBOM with Trivy. Signed-off-by: Andrew Russell --- .../oss-license-vulnerability-report.yml | 26 ++ .../oss_license_vulnerability_report.rst | 52 ++- scripts/audit_oss_dependency_coverage.py | 440 ++++++++++++++++++ scripts/collect_oss_dependencies.py | 106 ++++- scripts/resolve_oss_dependencies.sh | 84 ++++ .../run_oss_license_vulnerability_report.sh | 24 +- scripts/tests/test_oss_dependency_coverage.py | 121 +++++ 7 files changed, 821 insertions(+), 32 deletions(-) create mode 100755 scripts/audit_oss_dependency_coverage.py create mode 100755 scripts/resolve_oss_dependencies.sh create mode 100644 scripts/tests/test_oss_dependency_coverage.py diff --git a/.github/workflows/oss-license-vulnerability-report.yml b/.github/workflows/oss-license-vulnerability-report.yml index e3ff8a7df..8a7f4d3cf 100644 --- a/.github/workflows/oss-license-vulnerability-report.yml +++ b/.github/workflows/oss-license-vulnerability-report.yml @@ -17,9 +17,14 @@ concurrency: permissions: contents: read +env: + CORE_APT_DEPS: build-essential cmake glslang-tools libvulkan-dev libwayland-dev libx11-dev libxcursor-dev libxext-dev libxi-dev libxinerama-dev libxkbcommon-dev libxrandr-dev wayland-protocols + VCPKG_COMMIT: d015e31e90838a4c9dfa3eed45979bc70d9357fc # 2026.05.25 + jobs: oss-license-vulnerability-report: runs-on: ubuntu-latest + timeout-minutes: 90 steps: - name: Checkout code @@ -32,6 +37,25 @@ jobs: with: python-version: "3.12" + - name: Install uv + uses: ./.github/actions/setup-uv + + - name: Install maximum-profile build dependencies + run: | + sudo apt-get update + sudo apt-get install -y $CORE_APT_DEPS \ + autoconf automake libtool pkg-config libudev-dev + + - name: Install CUDA toolkit for maximum-profile configuration + uses: ./.github/actions/setup-cuda + + - name: Setup pinned vcpkg + run: | + git clone https://github.com/microsoft/vcpkg "${RUNNER_TEMP}/vcpkg" + git -C "${RUNNER_TEMP}/vcpkg" checkout --detach "${VCPKG_COMMIT}" + "${RUNNER_TEMP}/vcpkg/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=${RUNNER_TEMP}/vcpkg" >> "${GITHUB_ENV}" + - name: Install REUSE run: | python -m pip install --upgrade pip @@ -51,6 +75,8 @@ jobs: run: | if [[ -f oss-report/summary.md ]]; then cat oss-report/summary.md >> "${GITHUB_STEP_SUMMARY}" + elif [[ -f oss-report/dependency-coverage.md ]]; then + cat oss-report/dependency-coverage.md >> "${GITHUB_STEP_SUMMARY}" else echo "OSS report generation did not produce summary.md." >> "${GITHUB_STEP_SUMMARY}" fi diff --git a/docs/source/references/oss_license_vulnerability_report.rst b/docs/source/references/oss_license_vulnerability_report.rst index bc74b2142..c3a777e12 100644 --- a/docs/source/references/oss_license_vulnerability_report.rst +++ b/docs/source/references/oss_license_vulnerability_report.rst @@ -3,11 +3,10 @@ OSS License and Vulnerability Report ==================================== -The ``OSS license and vulnerability report`` workflow publishes a report-only CI -artifact for dependency inventory, vulnerability findings, and license metadata. -It is intended to make review evidence visible before any blocking policy is -enabled. The report is evidence, not a certification that every inventoried -reference has been resolved and scanned. +The ``OSS license and vulnerability report`` workflow publishes resolved +dependency coverage, a canonical CycloneDX SBOM, vulnerability findings, and +license metadata. Dependency coverage fails closed even while vulnerability and +license policy findings remain advisory. Generated files --------------- @@ -17,6 +16,9 @@ artifact. It contains: - ``dependency-inventory.json`` and ``dependency-inventory.md`` from the repository manifest collector. +- ``dependency-coverage.json`` and ``dependency-coverage.md``, which classify + every declaration as resolved, explicitly excluded, or unresolved. +- ``bom.cdx.json``, the canonical CycloneDX 1.6 SBOM scanned by Trivy. - ``trivy-vulnerability-report.json`` and, when available, ``trivy-vulnerability-report.sarif``. - ``reuse-lint.txt`` and, when available, ``reuse.spdx``. @@ -35,13 +37,24 @@ Collector coverage - Docker ``FROM`` image references. - GitHub Actions ``uses: owner/action@ref`` references. -The inventory and vulnerability scan have different scopes. The collector records -references even when they use ranges, variables, branch names, or source URLs. -Trivy reports vulnerabilities only for package names and concrete versions that -its filesystem analyzers can resolve. ``summary.md`` records both the inventory -total, the Trivy-resolved concrete package count, and the resolved manifest target -paths so a clean scan is not interpreted as coverage of unresolved inventory -entries. +The resolver configures a maximum open-source CMake profile with plugins, +examples, tests, visualization, and the OAK camera plugin enabled. CMake trace +output captures expanded ``FetchContent`` declarations, including declarations +from populated projects. The generated vcpkg status database supplies exact +installed versions. The resolver also produces an npm lockfile from each npm +manifest and a universal ``uv`` lock from the combined Python declarations. + +``dependency-coverage.json`` joins those package-manager and configured-build +results back to the declaration inventory. A declaration without concrete +resolver evidence must have a narrow, named exclusion with a reason. Parse +errors, unsupported package-manager manifests, unexpanded expressions, and +unmatched declarations fail the job. Adding a new manifest or build option +therefore cannot silently reduce report coverage. + +Trivy first catalogs the resolved workspace into CycloneDX. The coverage audit +adds configured CMake and generated vcpkg components to that document, then +Trivy scans ``bom.cdx.json`` as an SBOM target. ``summary.md`` reports declared, +resolved, excluded, unresolved, SBOM, and scanner counts separately. License hygiene --------------- @@ -55,11 +68,11 @@ remain in ``reuse-lint.txt`` and ``reuse.spdx``. Policy mode ----------- -The workflow is non-blocking by default. After the review policy is approved, -set ``OSS_REPORT_STRICT=true`` in CI to fail when Trivy or REUSE returns a nonzero -status. Severity and license thresholds still require stakeholder approval; until -then, a successful report job means the evidence was generated, not that the -repository meets a final compliance policy. +Dependency coverage is always blocking. After the vulnerability and license +policy is approved, set ``OSS_REPORT_STRICT=true`` in CI to also fail when Trivy +or REUSE returns a nonzero status. Severity and license thresholds still require +stakeholder approval; until then, a successful report job means complete +dependency accounting and generated evidence, not final compliance approval. Local use --------- @@ -70,5 +83,6 @@ Run the report locally from the repository root: ./scripts/run_oss_license_vulnerability_report.sh . -If Trivy or REUSE is unavailable, the script still emits the inventory and -records the skipped tool in the summary. +Local execution requires ``uv``, npm, CMake, Trivy, a bootstrapped vcpkg checkout +in ``VCPKG_ROOT``, and the same system dependencies used by the maximum CMake +profile. REUSE remains advisory unless strict policy mode is enabled. diff --git a/scripts/audit_oss_dependency_coverage.py b/scripts/audit_oss_dependency_coverage.py new file mode 100755 index 000000000..8ab33e9f9 --- /dev/null +++ b/scripts/audit_oss_dependency_coverage.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Merge resolver evidence into CycloneDX and fail closed on coverage gaps.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import quote +from uuid import uuid4 + + +PYTHON_LOCK_RE = re.compile(r"^([A-Za-z0-9_.-]+)==([^;\s]+)") +CONCRETE_COMMIT_RE = re.compile(r"^[0-9a-fA-F]{40}$") +UNSUPPORTED_MANIFEST_NAMES = { + "Cargo.lock", + "Cargo.toml", + "Gemfile", + "Gemfile.lock", + "composer.json", + "composer.lock", + "go.mod", + "go.sum", + "gradle.lockfile", + "package-lock.json", + "pnpm-lock.yaml", + "poetry.lock", + "pom.xml", + "uv.lock", + "yarn.lock", +} +IGNORED_DIRS = { + ".git", + ".venv", + "build", + "dist", + "node_modules", + "oss-report", + "venv", +} + + +def normalized_name(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def component( + ecosystem: str, + name: str, + version: str, + evidence: str, + source: str = "", +) -> dict[str, Any]: + encoded_name = quote(name, safe="/") + encoded_version = quote(version, safe="") + purl_type = { + "npm": "npm", + "python": "pypi", + "vcpkg": "generic", + "cmake": "generic", + "container": "oci", + "github-actions": "github", + }.get(ecosystem, "generic") + purl = f"pkg:{purl_type}/{encoded_name}@{encoded_version}" + properties = [ + {"name": "isaacteleop:dependency:ecosystem", "value": ecosystem}, + {"name": "isaacteleop:dependency:evidence", "value": evidence}, + ] + if source: + properties.append({"name": "isaacteleop:dependency:source", "value": source}) + return { + "type": "library", + "bom-ref": purl, + "name": name, + "version": version, + "purl": purl, + "properties": properties, + } + + +def load_python_lock(path: Path) -> dict[str, dict[str, Any]]: + resolved: dict[str, dict[str, Any]] = {} + if not path.exists(): + return resolved + for raw_line in path.read_text(encoding="utf-8").splitlines(): + match = PYTHON_LOCK_RE.match(raw_line.strip()) + if not match: + continue + name, version = match.groups() + resolved[normalized_name(name)] = component( + "python", name, version, f"uv lock {path.name}" + ) + return resolved + + +def npm_name_from_package_path(package_path: str, metadata: dict[str, Any]) -> str: + if metadata.get("name"): + return str(metadata["name"]) + marker = "node_modules/" + if marker not in package_path: + return "" + return package_path.rsplit(marker, maxsplit=1)[1] + + +def load_npm_locks(root: Path) -> dict[str, dict[str, Any]]: + resolved: dict[str, dict[str, Any]] = {} + if not root.exists(): + return resolved + for path in sorted(root.rglob("package-lock.json")): + data = json.loads(path.read_text(encoding="utf-8")) + for package_path, metadata in (data.get("packages", {}) or {}).items(): + name = npm_name_from_package_path(package_path, metadata) + version = str(metadata.get("version", "")) + if not name or not version: + continue + resolved[normalized_name(name)] = component( + "npm", name, version, f"npm lock {path.name}" + ) + return resolved + + +def parse_vcpkg_status(path: Path) -> dict[str, dict[str, Any]]: + resolved: dict[str, dict[str, Any]] = {} + if not path.exists(): + return resolved + for paragraph in re.split(r"\r?\n\r?\n", path.read_text(encoding="utf-8")): + fields: dict[str, str] = {} + for line in paragraph.splitlines(): + key, separator, value = line.partition(":") + if separator: + fields[key.strip()] = value.strip() + name = fields.get("Package", "") + version = fields.get("Version", "") + if name and version and fields.get("Status", "").endswith(" installed"): + resolved[normalized_name(name)] = component( + "vcpkg", name, version, f"vcpkg status {path.name}" + ) + return resolved + + +def trace_fields(args: list[str]) -> dict[str, str]: + fields: dict[str, str] = {} + keys = { + "GIT_REPOSITORY", + "GIT_TAG", + "NAME", + "URL", + "URL_HASH", + "VERSION", + } + index = 1 + while index < len(args): + key = args[index].upper() + if key in keys and index + 1 < len(args): + fields[key] = args[index + 1] + index += 2 + else: + index += 1 + return fields + + +def git_commit(path: Path) -> str: + if not path.exists(): + return "" + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + value = result.stdout.strip() + return value if result.returncode == 0 and CONCRETE_COMMIT_RE.match(value) else "" + + +def load_cmake_trace(trace_path: Path, build_dir: Path) -> dict[str, dict[str, Any]]: + resolved: dict[str, dict[str, Any]] = {} + if not trace_path.exists(): + return resolved + for raw_line in trace_path.read_text( + encoding="utf-8", errors="ignore" + ).splitlines(): + if not raw_line.startswith("{"): + continue + try: + event = json.loads(raw_line) + except json.JSONDecodeError: + continue + command_name = str(event.get("cmd", "")).lower() + if command_name not in { + "fetchcontent_declare", + "externalproject_add", + "cpmaddpackage", + }: + continue + args = [str(value) for value in event.get("args", [])] + if not args: + continue + fields = trace_fields(args) + name = fields.get("NAME") or args[0] + source = fields.get("GIT_REPOSITORY") or fields.get("URL") or "" + version = fields.get("GIT_TAG") or fields.get("VERSION") or "" + populated_commit = git_commit(build_dir / "_deps" / f"{name.lower()}-src") + if populated_commit: + version = populated_commit + elif fields.get("URL_HASH"): + version = fields["URL_HASH"] + if not version or "${" in version or "${" in source: + continue + resolved[normalized_name(name)] = component( + "cmake", + name, + version, + f"expanded CMake trace {trace_path.name}", + source, + ) + return resolved + + +def load_exclusions( + inputs_path: Path, policy_path: Path | None +) -> list[dict[str, str]]: + exclusions: list[dict[str, str]] = [] + if inputs_path.exists(): + inputs = json.loads(inputs_path.read_text(encoding="utf-8")) + exclusions.extend(inputs.get("generated_exclusions", []) or []) + if policy_path and policy_path.exists(): + policy = json.loads(policy_path.read_text(encoding="utf-8")) + exclusions.extend(policy.get("exclusions", []) or []) + return exclusions + + +def find_exclusion( + entry: dict[str, Any], exclusions: list[dict[str, str]] +) -> dict[str, str] | None: + for exclusion in exclusions: + if exclusion.get("ecosystem") != entry["ecosystem"]: + continue + if normalized_name(exclusion.get("name", "")) != normalized_name(entry["name"]): + continue + if exclusion.get("path") and exclusion["path"] != entry["path"]: + continue + if exclusion.get("reason"): + return exclusion + return None + + +def unsupported_manifests(repo_root: Path) -> list[str]: + paths: list[str] = [] + for path in repo_root.rglob("*"): + if not path.is_file() or any(part in IGNORED_DIRS for part in path.parts): + continue + if path.name in UNSUPPORTED_MANIFEST_NAMES: + paths.append(path.relative_to(repo_root).as_posix()) + return sorted(paths) + + +def declaration_resolution( + entry: dict[str, Any], + resolved: dict[str, dict[str, dict[str, Any]]], + exclusions: list[dict[str, str]], +) -> tuple[str, str, dict[str, Any] | None]: + exclusion = find_exclusion(entry, exclusions) + if exclusion: + return "excluded", exclusion["reason"], None + + ecosystem = entry["ecosystem"] + name = normalized_name(entry["name"]) + if ecosystem in resolved and name in resolved[ecosystem]: + item = resolved[ecosystem][name] + return "resolved", item["version"], item + if ecosystem in {"github-actions", "container"}: + if entry["name"] in {"base", "scratch"}: + return "excluded", "Docker stage alias or empty scratch image.", None + version = entry.get("version", "") + if version and "${" not in version and "$" not in version: + item = component(ecosystem, entry["name"], version, "declared reference") + return "resolved", version, item + return "unresolved", "No concrete resolver evidence or named exclusion.", None + + +def merge_components( + base_sbom: dict[str, Any], components: list[dict[str, Any]], repo_root: Path +) -> dict[str, Any]: + merged: dict[str, dict[str, Any]] = {} + for item in base_sbom.get("components", []) or []: + key = item.get("purl") or item.get("bom-ref") + if key: + merged[str(key)] = item + for item in components: + merged[item["purl"]] = item + version_path = repo_root / "VERSION" + project_version = version_path.read_text(encoding="utf-8").strip() + return { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "serialNumber": f"urn:uuid:{uuid4()}", + "version": 1, + "metadata": { + "timestamp": datetime.now(timezone.utc).isoformat(), + "tools": { + "components": [ + { + "type": "application", + "name": "IsaacTeleop OSS dependency coverage audit", + "version": "1", + } + ] + }, + "component": { + "type": "application", + "name": "IsaacTeleop", + "version": project_version, + }, + }, + "components": sorted( + merged.values(), + key=lambda item: (item.get("name", ""), item.get("version", "")), + ), + } + + +def write_summary(report: dict[str, Any], path: Path) -> None: + counts = Counter(record["status"] for record in report["declarations"]) + lines = [ + "# OSS dependency resolution coverage", + "", + f"- Declared references: `{len(report['declarations'])}`", + f"- Resolved declarations: `{counts['resolved']}`", + f"- Explicit exclusions: `{counts['excluded']}`", + f"- Unresolved declarations: `{counts['unresolved']}`", + f"- Resolved transitive components: `{report['resolved_component_count']}`", + f"- Unsupported manifests: `{len(report['unsupported_manifests'])}`", + "", + ] + if report["unsupported_manifests"]: + lines.extend(["## Unsupported manifests", ""]) + lines.extend(f"- `{item}`" for item in report["unsupported_manifests"]) + lines.append("") + gaps = [item for item in report["declarations"] if item["status"] == "unresolved"] + if gaps: + lines.extend(["## Unresolved declarations", ""]) + lines.extend( + f"- `{item['ecosystem']}` `{item['name']}` in `{item['path']}`" + for item in gaps + ) + lines.append("") + exclusions = [ + item for item in report["declarations"] if item["status"] == "excluded" + ] + if exclusions: + lines.extend(["## Explicit exclusions", ""]) + lines.extend( + f"- `{item['ecosystem']}` `{item['name']}` in `{item['path']}`: {item['detail']}" + for item in exclusions + ) + lines.append("") + path.write_text("\n".join(lines), encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, required=True) + parser.add_argument("--inventory", type=Path, required=True) + parser.add_argument("--resolution-inputs", type=Path, required=True) + parser.add_argument("--python-lock", type=Path, required=True) + parser.add_argument("--npm-root", type=Path, required=True) + parser.add_argument("--cmake-trace", type=Path, required=True) + parser.add_argument("--cmake-build-dir", type=Path, required=True) + parser.add_argument("--vcpkg-status", type=Path, required=True) + parser.add_argument("--base-sbom", type=Path, required=True) + parser.add_argument("--exclusions", type=Path) + parser.add_argument("--output-sbom", type=Path, required=True) + parser.add_argument("--output-report", type=Path, required=True) + parser.add_argument("--output-summary", type=Path, required=True) + parser.add_argument("--fail-on-gaps", action="store_true") + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + inventory = json.loads(args.inventory.read_text(encoding="utf-8")) + base_sbom = json.loads(args.base_sbom.read_text(encoding="utf-8")) + resolved = { + "python": load_python_lock(args.python_lock), + "npm": load_npm_locks(args.npm_root), + "cmake": load_cmake_trace(args.cmake_trace, args.cmake_build_dir), + "vcpkg": parse_vcpkg_status(args.vcpkg_status), + } + exclusions = load_exclusions(args.resolution_inputs, args.exclusions) + declarations: list[dict[str, Any]] = [] + used_components: list[dict[str, Any]] = [] + for entry in inventory["dependencies"]: + if entry["manifest_type"] == "parse-error": + declarations.append( + { + **entry, + "status": "unresolved", + "detail": "Manifest parse error.", + } + ) + continue + status, detail, resolved_component = declaration_resolution( + entry, resolved, exclusions + ) + declarations.append({**entry, "status": status, "detail": detail}) + if resolved_component: + used_components.append(resolved_component) + + for ecosystem_components in resolved.values(): + used_components.extend(ecosystem_components.values()) + unsupported = unsupported_manifests(repo_root) + report = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "declarations": declarations, + "unsupported_manifests": unsupported, + "resolved_component_count": len( + {item["purl"] for item in used_components if item.get("purl")} + ), + } + args.output_report.parent.mkdir(parents=True, exist_ok=True) + args.output_report.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + write_summary(report, args.output_summary) + merged_sbom = merge_components(base_sbom, used_components, repo_root) + args.output_sbom.write_text( + json.dumps(merged_sbom, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + has_gaps = any(item["status"] == "unresolved" for item in declarations) + has_gaps = has_gaps or bool(unsupported) + return 2 if args.fail_on_gaps and has_gaps else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/collect_oss_dependencies.py b/scripts/collect_oss_dependencies.py index f04c544d3..536cc2ae1 100755 --- a/scripts/collect_oss_dependencies.py +++ b/scripts/collect_oss_dependencies.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Collect a lightweight OSS dependency inventory from repository manifests.""" +"""Collect OSS declarations and prepare deterministic resolver inputs.""" from __future__ import annotations @@ -36,7 +36,9 @@ } REQUIREMENTS_RE = re.compile( - r"^\s*(?P[A-Za-z0-9_.-]+)\s*(?P(?:===|==|~=|!=|<=|>=|<|>).*)?$" + r"^\s*(?P[A-Za-z0-9_.-]+)" + r"(?P\[[A-Za-z0-9_,. -]+\])?\s*" + r"(?P(?:===|==|~=|!=|<=|>=|<|>).*)?$" ) DOCKER_FROM_RE = re.compile( r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P\S+)", re.IGNORECASE @@ -141,14 +143,16 @@ def _parse_pyproject(path: Path, repo_root: Path) -> list[dict[str, Any]]: def _python_dependency_entry( dependency: str, path: Path, repo_root: Path, scope: str ) -> dict[str, Any]: - match = REQUIREMENTS_RE.match(dependency) + requirement, _, marker = dependency.partition(";") + match = REQUIREMENTS_RE.match(requirement.strip()) if match: return _entry( ecosystem="python", manifest_type="pyproject", name=match.group("name"), - version=match.group("specifier"), + version=(match.group("specifier") or "").strip(), scope=scope, + source=marker.strip(), path=path, repo_root=repo_root, ) @@ -359,6 +363,93 @@ def collect(repo_root: Path) -> dict[str, Any]: } +def write_resolution_inputs( + report: dict[str, Any], repo_root: Path, output_dir: Path +) -> None: + """Write package-manager inputs used by the fail-closed resolution job.""" + output_dir.mkdir(parents=True, exist_ok=True) + + python_requirements: set[str] = set() + npm_manifests: list[dict[str, Any]] = [] + generated_exclusions: list[dict[str, str]] = [] + for entry in report["dependencies"]: + if entry["manifest_type"] == "parse-error": + continue + if entry["ecosystem"] == "python": + name = entry["name"] + if name.lower() == "isaacteleop": + generated_exclusions.append( + { + "ecosystem": "python", + "name": name, + "path": entry["path"], + "reason": "First-party self dependency, not an OSS component.", + } + ) + continue + requirement = f"{name}{entry['version']}" + if entry["source"]: + requirement = f"{requirement}; {entry['source']}" + python_requirements.add(requirement) + + (output_dir / "python-requirements.in").write_text( + "\n".join(sorted(python_requirements, key=str.lower)) + "\n", + encoding="utf-8", + ) + + npm_root = output_dir / "npm" + for manifest_path in sorted(repo_root.rglob("package.json")): + if any(part in EXCLUDED_DIRS for part in manifest_path.parts): + continue + data = json.loads(manifest_path.read_text(encoding="utf-8")) + relative = _repo_path(repo_root, manifest_path) + staged = dict(data) + for scope in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ): + dependencies = dict(staged.get(scope, {}) or {}) + for name, version in list(dependencies.items()): + if str(version).startswith(("file:", "link:", "workspace:")): + generated_exclusions.append( + { + "ecosystem": "npm", + "name": name, + "path": relative, + "reason": ( + "Local or first-party package input is unavailable in " + "public resolver CI." + ), + } + ) + del dependencies[name] + staged[scope] = dependencies + staged_dir = npm_root / re.sub(r"[^A-Za-z0-9_.-]+", "__", relative) + staged_dir.mkdir(parents=True, exist_ok=True) + (staged_dir / "package.json").write_text( + json.dumps(staged, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + npm_manifests.append( + {"source": relative, "staged": _repo_path(output_dir, staged_dir)} + ) + + (output_dir / "resolution-inputs.json").write_text( + json.dumps( + { + "python": "python-requirements.in", + "npm": npm_manifests, + "generated_exclusions": generated_exclusions, + }, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + def write_summary(report: dict[str, Any], summary_path: Path, limit: int = 200) -> None: counts = Counter( entry["ecosystem"] @@ -418,6 +509,11 @@ def main() -> int: parser.add_argument( "--summary", type=Path, default=Path("oss-report/dependency-inventory.md") ) + parser.add_argument( + "--resolution-input-dir", + type=Path, + help="Write normalized inputs for npm, Python, CMake, and vcpkg resolvers.", + ) args = parser.parse_args() repo_root = args.repo_root.resolve() @@ -428,6 +524,8 @@ def main() -> int: ) args.summary.parent.mkdir(parents=True, exist_ok=True) write_summary(report, args.summary) + if args.resolution_input_dir: + write_resolution_inputs(report, repo_root, args.resolution_input_dir.resolve()) return 0 diff --git a/scripts/resolve_oss_dependencies.sh b/scripts/resolve_oss_dependencies.sh new file mode 100755 index 000000000..ee292da2c --- /dev/null +++ b/scripts/resolve_oss_dependencies.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +repo_root="$(cd "${1:-.}" && pwd)" +output_dir="${OSS_REPORT_DIR:-${repo_root}/oss-report}" +resolution_dir="${output_dir}/resolution" +mkdir -p "${resolution_dir}" + +python3 "${repo_root}/scripts/collect_oss_dependencies.py" \ + --repo-root "${repo_root}" \ + --output "${output_dir}/dependency-inventory.json" \ + --summary "${output_dir}/dependency-inventory.md" \ + --resolution-input-dir "${resolution_dir}/inputs" + +uv pip compile \ + --python-version 3.11 \ + --universal \ + --no-header \ + --output-file "${resolution_dir}/python-requirements.lock" \ + "${resolution_dir}/inputs/python-requirements.in" + +while IFS= read -r package_json; do + npm install \ + --prefix "$(dirname "${package_json}")" \ + --package-lock-only \ + --ignore-scripts \ + --no-audit \ + --no-fund +done < <(find "${resolution_dir}/inputs/npm" -name package.json -print | sort) + +if [[ -z "${VCPKG_ROOT:-}" || ! -f "${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" ]]; then + echo "VCPKG_ROOT must point to a bootstrapped vcpkg checkout." >&2 + exit 2 +fi + +cmake_build_dir="${resolution_dir}/cmake-max" +cmake_trace="${resolution_dir}/cmake-trace.jsonl" +cmake \ + --trace-expand \ + --trace-format=json-v1 \ + --trace-redirect="${cmake_trace}" \ + -S "${repo_root}" \ + -B "${cmake_build_dir}" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="${VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" \ + -DISAAC_TELEOP_PYTHON_VERSION=3.11 \ + -DBUILD_EXAMPLES=ON \ + -DBUILD_PLUGINS=ON \ + -DBUILD_PLUGIN_OAK_CAMERA=ON \ + -DBUILD_TESTING=ON \ + -DBUILD_VIZ=ON \ + -DBUNDLE_ROBOTIC_GROUNDING=OFF \ + -DENABLE_CLANG_FORMAT_CHECK=OFF \ + -DENABLE_CLOUDXR_BUNDLE_CHECK=OFF + +vcpkg_status="${cmake_build_dir}/vcpkg_installed/vcpkg/status" +if [[ ! -s "${vcpkg_status}" ]]; then + echo "Configured max-dependency profile did not produce vcpkg status metadata." >&2 + exit 2 +fi + +trivy fs \ + --no-progress \ + --format cyclonedx \ + --output "${resolution_dir}/trivy-source.cdx.json" \ + "${repo_root}" + +python3 "${repo_root}/scripts/audit_oss_dependency_coverage.py" \ + --repo-root "${repo_root}" \ + --inventory "${output_dir}/dependency-inventory.json" \ + --resolution-inputs "${resolution_dir}/inputs/resolution-inputs.json" \ + --python-lock "${resolution_dir}/python-requirements.lock" \ + --npm-root "${resolution_dir}/inputs/npm" \ + --cmake-trace "${cmake_trace}" \ + --cmake-build-dir "${cmake_build_dir}" \ + --vcpkg-status "${vcpkg_status}" \ + --base-sbom "${resolution_dir}/trivy-source.cdx.json" \ + --output-sbom "${output_dir}/bom.cdx.json" \ + --output-report "${output_dir}/dependency-coverage.json" \ + --output-summary "${output_dir}/dependency-coverage.md" \ + --fail-on-gaps diff --git a/scripts/run_oss_license_vulnerability_report.sh b/scripts/run_oss_license_vulnerability_report.sh index 010ad12db..38b09af5b 100755 --- a/scripts/run_oss_license_vulnerability_report.sh +++ b/scripts/run_oss_license_vulnerability_report.sh @@ -22,26 +22,23 @@ reuse not installed TXT fi -python3 "${repo_root}/scripts/collect_oss_dependencies.py" \ - --repo-root "${repo_root}" \ - --output "${output_dir}/dependency-inventory.json" \ - --summary "${output_dir}/dependency-inventory.md" +bash "${repo_root}/scripts/resolve_oss_dependencies.sh" "${repo_root}" trivy_status=0 if command -v trivy >/dev/null 2>&1; then - trivy fs \ + trivy sbom \ --no-progress \ --format json \ --output "${output_dir}/trivy-vulnerability-report.json" \ --scanners vuln,license \ - "${repo_root}" || trivy_status=$? + "${output_dir}/bom.cdx.json" || trivy_status=$? - trivy fs \ + trivy sbom \ --no-progress \ --format sarif \ --output "${output_dir}/trivy-vulnerability-report.sarif" \ --scanners vuln \ - "${repo_root}" || true + "${output_dir}/bom.cdx.json" || true else trivy_status=127 cat > "${output_dir}/trivy-vulnerability-report.json" <<'JSON' @@ -68,6 +65,7 @@ reuse_lint_status = int(sys.argv[3]) reuse_spdx_status = int(sys.argv[4]) inventory = json.loads((output_dir / "dependency-inventory.json").read_text(encoding="utf-8")) +coverage = json.loads((output_dir / "dependency-coverage.json").read_text(encoding="utf-8")) trivy_report = json.loads((output_dir / "trivy-vulnerability-report.json").read_text(encoding="utf-8")) reuse_lint = (output_dir / "reuse-lint.txt").read_text(encoding="utf-8") @@ -104,6 +102,11 @@ lines = [ "", f"- Dependencies discovered: `{inventory.get('dependency_count', 0)}`", f"- Parse errors: `{inventory.get('parse_error_count', 0)}`", + f"- Resolved declarations: `{sum(item['status'] == 'resolved' for item in coverage['declarations'])}`", + f"- Explicitly excluded declarations: `{sum(item['status'] == 'excluded' for item in coverage['declarations'])}`", + f"- Unresolved declarations: `{sum(item['status'] == 'unresolved' for item in coverage['declarations'])}`", + f"- Canonical CycloneDX components: `{coverage.get('resolved_component_count', 0)}`", + f"- Unsupported manifests: `{len(coverage.get('unsupported_manifests', []))}`", "", "## Vulnerabilities", "", @@ -128,7 +131,7 @@ else: ) if resolved_package_count < inventory.get("dependency_count", 0): lines.append( - "- Inventory-only references are not claimed as vulnerability-scanned until Trivy resolves a concrete package and version." + "- Coverage is fail-closed before scanning: every declaration must be resolved or explicitly excluded." ) lines.extend( @@ -162,6 +165,9 @@ lines.extend( "", "- `dependency-inventory.json`", "- `dependency-inventory.md`", + "- `dependency-coverage.json`", + "- `dependency-coverage.md`", + "- `bom.cdx.json`", "- `trivy-vulnerability-report.json`", "- `trivy-vulnerability-report.sarif` when Trivy SARIF generation succeeds", "- `reuse-lint.txt`", diff --git a/scripts/tests/test_oss_dependency_coverage.py b/scripts/tests/test_oss_dependency_coverage.py new file mode 100644 index 000000000..7d303aeec --- /dev/null +++ b/scripts/tests/test_oss_dependency_coverage.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] + + +def load_script(name: str): + path = SCRIPTS_DIR / f"{name}.py" + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +audit = load_script("audit_oss_dependency_coverage") +collector = load_script("collect_oss_dependencies") + + +class DependencyCoverageTests(unittest.TestCase): + def test_pyproject_extras_are_normalized_to_the_project_name(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + pyproject = root / "pyproject.toml" + pyproject.write_text( + "[project]\n" + 'name = "sample"\n' + 'version = "1"\n' + "dependencies = [\"demo[extra]>=2; python_version >= '3.11'\"]\n", + encoding="utf-8", + ) + entries = collector._parse_pyproject(pyproject, root) + + self.assertEqual(entries[0]["name"], "demo") + self.assertEqual(entries[0]["version"], ">=2") + self.assertEqual(entries[0]["source"], "python_version >= '3.11'") + + def test_lock_and_trace_evidence_resolve_direct_declarations(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "VERSION").write_text("1.4.0\n", encoding="utf-8") + python_lock = root / "requirements.lock" + python_lock.write_text("demo==2.3.4\n", encoding="utf-8") + npm_root = root / "npm" + npm_root.mkdir() + (npm_root / "package-lock.json").write_text( + json.dumps( + { + "lockfileVersion": 3, + "packages": { + "node_modules/widget": { + "name": "widget", + "version": "5.6.7", + } + }, + } + ), + encoding="utf-8", + ) + trace = root / "trace.jsonl" + trace.write_text( + json.dumps( + { + "cmd": "FetchContent_Declare", + "args": [ + "library", + "GIT_REPOSITORY", + "https://example.com/library.git", + "GIT_TAG", + "0123456789abcdef0123456789abcdef01234567", + ], + } + ) + + "\n", + encoding="utf-8", + ) + vcpkg_status = root / "status" + vcpkg_status.write_text( + "Package: archive\nVersion: 1.2.3\nStatus: install ok installed\n", + encoding="utf-8", + ) + + resolved = { + "python": audit.load_python_lock(python_lock), + "npm": audit.load_npm_locks(npm_root), + "cmake": audit.load_cmake_trace(trace, root / "build"), + "vcpkg": audit.parse_vcpkg_status(vcpkg_status), + } + entries = [ + {"ecosystem": "python", "name": "demo", "path": "requirements.txt"}, + {"ecosystem": "npm", "name": "widget", "path": "package.json"}, + {"ecosystem": "cmake", "name": "library", "path": "CMakeLists.txt"}, + ] + statuses = [ + audit.declaration_resolution(entry, resolved, [])[0] + for entry in entries + ] + + self.assertEqual(statuses, ["resolved", "resolved", "resolved"]) + self.assertIn("archive", resolved["vcpkg"]) + + def test_unknown_package_manager_manifest_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + (root / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + manifests = audit.unsupported_manifests(root) + + self.assertEqual(manifests, ["Cargo.toml"]) + + +if __name__ == "__main__": + unittest.main() From 1669a81ef92be49eb3b37a26c20da5f01e4873d7 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Wed, 22 Jul 2026 19:28:27 -0400 Subject: [PATCH 8/9] Exclude generated build trees from SBOM discovery Keep compact CMake and vcpkg evidence while preventing Trivy from recursively resolving fetched dependency build trees. Extend fail-closed manifest detection to Conda, Pipenv, and Gradle inputs. Signed-off-by: Andrew Russell --- scripts/audit_oss_dependency_coverage.py | 8 ++++++++ scripts/resolve_oss_dependencies.sh | 9 ++++++++- scripts/tests/test_oss_dependency_coverage.py | 5 ++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/audit_oss_dependency_coverage.py b/scripts/audit_oss_dependency_coverage.py index 8ab33e9f9..e09c03f57 100755 --- a/scripts/audit_oss_dependency_coverage.py +++ b/scripts/audit_oss_dependency_coverage.py @@ -24,8 +24,16 @@ "Cargo.toml", "Gemfile", "Gemfile.lock", + "Pipfile", + "Pipfile.lock", + "build.gradle", + "build.gradle.kts", "composer.json", "composer.lock", + "conda-lock.yaml", + "conda-lock.yml", + "environment.yaml", + "environment.yml", "go.mod", "go.sum", "gradle.lockfile", diff --git a/scripts/resolve_oss_dependencies.sh b/scripts/resolve_oss_dependencies.sh index ee292da2c..330ec1b37 100755 --- a/scripts/resolve_oss_dependencies.sh +++ b/scripts/resolve_oss_dependencies.sh @@ -61,9 +61,12 @@ if [[ ! -s "${vcpkg_status}" ]]; then echo "Configured max-dependency profile did not produce vcpkg status metadata." >&2 exit 2 fi +vcpkg_status_evidence="${resolution_dir}/vcpkg-status" +cp "${vcpkg_status}" "${vcpkg_status_evidence}" trivy fs \ --no-progress \ + --skip-dirs "${output_dir}" \ --format cyclonedx \ --output "${resolution_dir}/trivy-source.cdx.json" \ "${repo_root}" @@ -76,9 +79,13 @@ python3 "${repo_root}/scripts/audit_oss_dependency_coverage.py" \ --npm-root "${resolution_dir}/inputs/npm" \ --cmake-trace "${cmake_trace}" \ --cmake-build-dir "${cmake_build_dir}" \ - --vcpkg-status "${vcpkg_status}" \ + --vcpkg-status "${vcpkg_status_evidence}" \ --base-sbom "${resolution_dir}/trivy-source.cdx.json" \ --output-sbom "${output_dir}/bom.cdx.json" \ --output-report "${output_dir}/dependency-coverage.json" \ --output-summary "${output_dir}/dependency-coverage.md" \ --fail-on-gaps + +# The audit has already captured the expanded trace, exact fetched commits, and +# vcpkg status. Keep those compact records, not the generated build tree. +rm -rf "${cmake_build_dir}" diff --git a/scripts/tests/test_oss_dependency_coverage.py b/scripts/tests/test_oss_dependency_coverage.py index 7d303aeec..479400b66 100644 --- a/scripts/tests/test_oss_dependency_coverage.py +++ b/scripts/tests/test_oss_dependency_coverage.py @@ -112,9 +112,12 @@ def test_unknown_package_manager_manifest_fails_closed(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) (root / "Cargo.toml").write_text("[package]\n", encoding="utf-8") + (root / "environment.yml").write_text( + "dependencies:\n - python\n", encoding="utf-8" + ) manifests = audit.unsupported_manifests(root) - self.assertEqual(manifests, ["Cargo.toml"]) + self.assertEqual(manifests, ["Cargo.toml", "environment.yml"]) if __name__ == "__main__": From ffdb3df07632e246f4bb4ebc3ef8ca794e331a1b Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Wed, 22 Jul 2026 19:41:29 -0400 Subject: [PATCH 9/9] Resolve remaining fail-closed dependency gaps Enable the maximum OGLO CMake profile so nlohmann_json is traced and pinned. Resolve Docker ARG defaults into concrete Python and ROS base image versions, with focused coverage. Signed-off-by: Andrew Russell --- .../oss-license-vulnerability-report.yml | 2 +- scripts/collect_oss_dependencies.py | 19 ++++++++++++++++++- scripts/resolve_oss_dependencies.sh | 5 ++++- scripts/tests/test_oss_dependency_coverage.py | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/.github/workflows/oss-license-vulnerability-report.yml b/.github/workflows/oss-license-vulnerability-report.yml index 8a7f4d3cf..620dfdca8 100644 --- a/.github/workflows/oss-license-vulnerability-report.yml +++ b/.github/workflows/oss-license-vulnerability-report.yml @@ -18,7 +18,7 @@ permissions: contents: read env: - CORE_APT_DEPS: build-essential cmake glslang-tools libvulkan-dev libwayland-dev libx11-dev libxcursor-dev libxext-dev libxi-dev libxinerama-dev libxkbcommon-dev libxrandr-dev wayland-protocols + CORE_APT_DEPS: build-essential cmake glslang-tools libdbus-1-dev libvulkan-dev libwayland-dev libx11-dev libxcursor-dev libxext-dev libxi-dev libxinerama-dev libxkbcommon-dev libxrandr-dev pkg-config wayland-protocols VCPKG_COMMIT: d015e31e90838a4c9dfa3eed45979bc70d9357fc # 2026.05.25 jobs: diff --git a/scripts/collect_oss_dependencies.py b/scripts/collect_oss_dependencies.py index 536cc2ae1..6886ea98b 100755 --- a/scripts/collect_oss_dependencies.py +++ b/scripts/collect_oss_dependencies.py @@ -43,6 +43,13 @@ DOCKER_FROM_RE = re.compile( r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P\S+)", re.IGNORECASE ) +DOCKER_ARG_RE = re.compile( + r"^\s*ARG\s+(?P[A-Za-z_][A-Za-z0-9_]*)(?:=(?P\S+))?\s*$", + re.IGNORECASE, +) +DOCKER_ARG_REFERENCE_RE = re.compile( + r"\$\{(?P[A-Za-z_][A-Za-z0-9_]*)\}|\$(?P[A-Za-z_][A-Za-z0-9_]*)" +) GITHUB_ACTION_RE = re.compile( r"uses:\s*(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.\-/]+)@(?P[A-Za-z0-9_.\-/]+)" ) @@ -216,13 +223,23 @@ def _parse_vcpkg_manifest(path: Path, repo_root: Path) -> list[dict[str, Any]]: def _parse_dockerfile(path: Path, repo_root: Path) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] + arguments: dict[str, str] = {} for line_number, raw_line in enumerate( path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1 ): + argument_match = DOCKER_ARG_RE.match(raw_line) + if argument_match and argument_match.group("value"): + arguments[argument_match.group("name")] = argument_match.group("value") + continue match = DOCKER_FROM_RE.match(raw_line) if not match: continue - image = match.group("image").split("@", maxsplit=1)[0] + image = DOCKER_ARG_REFERENCE_RE.sub( + lambda variable: arguments.get( + variable.group("braced") or variable.group("plain"), variable.group(0) + ), + match.group("image"), + ).split("@", maxsplit=1)[0] last_colon = image.rfind(":") last_slash = image.rfind("/") if last_colon > last_slash: diff --git a/scripts/resolve_oss_dependencies.sh b/scripts/resolve_oss_dependencies.sh index 330ec1b37..847ff78c0 100755 --- a/scripts/resolve_oss_dependencies.sh +++ b/scripts/resolve_oss_dependencies.sh @@ -50,6 +50,7 @@ cmake \ -DBUILD_EXAMPLES=ON \ -DBUILD_PLUGINS=ON \ -DBUILD_PLUGIN_OAK_CAMERA=ON \ + -DBUILD_PLUGIN_OGLO=ON \ -DBUILD_TESTING=ON \ -DBUILD_VIZ=ON \ -DBUNDLE_ROBOTIC_GROUNDING=OFF \ @@ -71,6 +72,7 @@ trivy fs \ --output "${resolution_dir}/trivy-source.cdx.json" \ "${repo_root}" +audit_status=0 python3 "${repo_root}/scripts/audit_oss_dependency_coverage.py" \ --repo-root "${repo_root}" \ --inventory "${output_dir}/dependency-inventory.json" \ @@ -84,8 +86,9 @@ python3 "${repo_root}/scripts/audit_oss_dependency_coverage.py" \ --output-sbom "${output_dir}/bom.cdx.json" \ --output-report "${output_dir}/dependency-coverage.json" \ --output-summary "${output_dir}/dependency-coverage.md" \ - --fail-on-gaps + --fail-on-gaps || audit_status=$? # The audit has already captured the expanded trace, exact fetched commits, and # vcpkg status. Keep those compact records, not the generated build tree. rm -rf "${cmake_build_dir}" +exit "${audit_status}" diff --git a/scripts/tests/test_oss_dependency_coverage.py b/scripts/tests/test_oss_dependency_coverage.py index 479400b66..ff955e8a7 100644 --- a/scripts/tests/test_oss_dependency_coverage.py +++ b/scripts/tests/test_oss_dependency_coverage.py @@ -44,6 +44,24 @@ def test_pyproject_extras_are_normalized_to_the_project_name(self) -> None: self.assertEqual(entries[0]["version"], ">=2") self.assertEqual(entries[0]["source"], "python_version >= '3.11'") + def test_docker_arg_defaults_make_base_images_concrete(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + dockerfile = root / "Dockerfile" + dockerfile.write_text( + "ARG PYTHON_VERSION=3.11\n" + "FROM python:${PYTHON_VERSION}-slim\n" + "ARG ROS_DISTRO=humble\n" + "FROM ros:$ROS_DISTRO-ros-base\n", + encoding="utf-8", + ) + entries = collector._parse_dockerfile(dockerfile, root) + + self.assertEqual( + [(entry["name"], entry["version"]) for entry in entries], + [("python", "3.11-slim"), ("ros", "humble-ros-base")], + ) + def test_lock_and_trace_evidence_resolve_direct_declarations(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir)