diff --git a/.github/workflows/oss-license-vulnerability-report.yml b/.github/workflows/oss-license-vulnerability-report.yml new file mode 100644 index 000000000..620dfdca8 --- /dev/null +++ b/.github/workflows/oss-license-vulnerability-report.yml @@ -0,0 +1,91 @@ +# 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 + +env: + 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: + oss-license-vulnerability-report: + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@v6 + 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 + python -m pip install reuse + + - name: Install Trivy + uses: aquasecurity/setup-trivy@v0.2.6 + with: + version: v0.72.0 + + - name: Generate OSS report + run: | + ./scripts/run_oss_license_vulnerability_report.sh . + + - name: Publish OSS report summary + if: always() + 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 + + - name: Upload OSS report artifact + if: always() + 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 101b4b1ea..079317f90 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -83,6 +83,7 @@ Table of Contents references/rig 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..c3a777e12 --- /dev/null +++ b/docs/source/references/oss_license_vulnerability_report.rst @@ -0,0 +1,88 @@ +.. _oss-license-vulnerability-report: + +OSS License and Vulnerability Report +==================================== + +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 +--------------- + +The workflow uploads the ``isaacteleop-oss-license-vulnerability-report`` +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``. +- ``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. + +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 +--------------- + +REUSE runs before report artifacts are generated, so generated JSON, SARIF, and +Markdown files do not inflate the missing-license baseline. ``summary.md`` reports +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 +----------- + +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 +--------- + +Run the report locally from the repository root: + +.. code-block:: bash + + ./scripts/run_oss_license_vulnerability_report.sh . + +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..e09c03f57 --- /dev/null +++ b/scripts/audit_oss_dependency_coverage.py @@ -0,0 +1,448 @@ +#!/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", + "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", + "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 new file mode 100755 index 000000000..6886ea98b --- /dev/null +++ b/scripts/collect_oss_dependencies.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Collect OSS declarations and prepare deterministic resolver inputs.""" + +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_.-]+)" + 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 +) +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_.\-/]+)" +) +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]: + 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") or "").strip(), + scope=scope, + source=marker.strip(), + 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]] = [] + 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 = 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: + name = image[:last_colon] + version = image[last_colon + 1 :] + else: + name = image + version = "" + 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_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"] + 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") + ) + 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() + 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) + if args.resolution_input_dir: + write_resolution_inputs(report, repo_root, args.resolution_input_dir.resolve()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resolve_oss_dependencies.sh b/scripts/resolve_oss_dependencies.sh new file mode 100755 index 000000000..847ff78c0 --- /dev/null +++ b/scripts/resolve_oss_dependencies.sh @@ -0,0 +1,94 @@ +#!/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_PLUGIN_OGLO=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 +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}" + +audit_status=0 +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_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 || 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/run_oss_license_vulnerability_report.sh b/scripts/run_oss_license_vulnerability_report.sh new file mode 100755 index 000000000..38b09af5b --- /dev/null +++ b/scripts/run_oss_license_vulnerability_report.sh @@ -0,0 +1,195 @@ +#!/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}" + +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_lint_status=$? + reuse spdx -o "${output_dir}/reuse.spdx" || reuse_spdx_status=$? +else + reuse_lint_status=127 + reuse_spdx_status=127 + cat > "${output_dir}/reuse-lint.txt" <<'TXT' +reuse not installed +TXT +fi + +bash "${repo_root}/scripts/resolve_oss_dependencies.sh" "${repo_root}" + +trivy_status=0 +if command -v trivy >/dev/null 2>&1; then + trivy sbom \ + --no-progress \ + --format json \ + --output "${output_dir}/trivy-vulnerability-report.json" \ + --scanners vuln,license \ + "${output_dir}/bom.cdx.json" || trivy_status=$? + + trivy sbom \ + --no-progress \ + --format sarif \ + --output "${output_dir}/trivy-vulnerability-report.sarif" \ + --scanners vuln \ + "${output_dir}/bom.cdx.json" || true +else + trivy_status=127 + cat > "${output_dir}/trivy-vulnerability-report.json" <<'JSON' +{ + "Results": [], + "Metadata": { + "Status": "trivy not installed" + } +} +JSON +fi + +python3 - "${output_dir}" "${trivy_status}" "${reuse_lint_status}" "${reuse_spdx_status}" <<'PY' +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +output_dir = Path(sys.argv[1]) +trivy_status = int(sys.argv[2]) +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") + +severity_counts: dict[str, int] = {} +license_count = 0 +resolved_package_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_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 + license_count += len(result.get("Licenses", []) or []) + +def reuse_fraction(label: str) -> str | None: + 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 + + +lines = [ + "# OSS license and vulnerability report", + "", + "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", + "", + 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", + "", +] +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"- 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}`") + else: + lines.append( + "- No vulnerabilities were reported for the concrete package versions Trivy resolved." + ) + if resolved_package_count < inventory.get("dependency_count", 0): + lines.append( + "- Coverage is fail-closed before scanning: every declaration must be resolved or explicitly excluded." + ) + +lines.extend( + [ + "", + "## Licenses", + "", + f"- Trivy license findings: `{license_count}`", + f"- REUSE lint exit status: `{reuse_lint_status}`", + f"- REUSE SPDX export exit status: `{reuse_spdx_status}`", + ] +) +if reuse_lint_status == 127: + lines.append("- REUSE was not installed; license hygiene validation was skipped.") +elif reuse_lint_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", + "", + "- `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`", + "- `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" ]]; then + if [[ "${trivy_status}" -ne 0 ]]; then + exit "${trivy_status}" + fi + 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 + +exit 0 diff --git a/scripts/tests/test_oss_dependency_coverage.py b/scripts/tests/test_oss_dependency_coverage.py new file mode 100644 index 000000000..ff955e8a7 --- /dev/null +++ b/scripts/tests/test_oss_dependency_coverage.py @@ -0,0 +1,142 @@ +# 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_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) + (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") + (root / "environment.yml").write_text( + "dependencies:\n - python\n", encoding="utf-8" + ) + manifests = audit.unsupported_manifests(root) + + self.assertEqual(manifests, ["Cargo.toml", "environment.yml"]) + + +if __name__ == "__main__": + unittest.main()