From 049aad666479e2966449d3fce281e2475fd40ee6 Mon Sep 17 00:00:00 2001 From: sylvain Date: Mon, 5 Jan 2026 13:59:27 +0100 Subject: [PATCH 1/9] CI: generate Syft SBOM and import into Sonar SCA --- .github/workflows/sonar.yaml | 43 ++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index a0fce4972eb2..700d6f411f71 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -16,30 +16,39 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Install sonar-scanner and build-wrapper - env: - SONAR_HOST_URL: ${{secrets.SONAR_HOST_URL}} - uses: SonarSource/sonarqube-github-c-cpp@v1 - - name: Set up JDK 17 - uses: actions/setup-java@v2 - with: - java-version: '17' - distribution: 'temurin' - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' + - name: Install Build Wrapper + uses: SonarSource/sonarqube-scan-action/install-build-wrapper@v7.0.0 - name: Run build-wrapper run: | rm -rf build mkdir build cd build cmake .. - cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON . + cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_JAVA=OFF -DBUILD_opencv_java=OFF . cd .. - - name: Run sonar-scanner + - name: Generate SBOM (Syft / CycloneDX) + uses: anchore/sbom-action@v0.21.0 + with: + path: . + format: cyclonedx-json + output-file: sbom.cdx.json + - name: Dump SBOM to logs + shell: bash + run: | + set -euo pipefail + echo "SBOM file: sbom.cdx.json" + echo "SBOM size (bytes): $(wc -c < sbom.cdx.json)" + echo "----- BEGIN SBOM (truncated to first 200000 bytes) -----" + head -c 200000 sbom.cdx.json || true + echo + echo "----- END SBOM -----" + - name: Run SonarQube scan + uses: SonarSource/sonarqube-scan-action@v7.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{secrets.SONAR_HOST_URL}} - run: | - sonar-scanner + with: + args: > + -X + -Dsonar.sca.enabled=true + -Dsonar.sca.sbomImportPaths=sbom.cdx.json From 8cf2ea6ab8b80f0409a47e2d3af841bc14aa3707 Mon Sep 17 00:00:00 2001 From: sylvain Date: Mon, 5 Jan 2026 14:11:11 +0100 Subject: [PATCH 2/9] CI: prune Syft SBOM noise before Sonar import --- .github/workflows/sonar.yaml | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 700d6f411f71..c6b6ce95d89c 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -32,6 +32,58 @@ jobs: path: . format: cyclonedx-json output-file: sbom.cdx.json + - name: Prune SBOM noise + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import json + from pathlib import Path + + p = Path("sbom.cdx.json") + d = json.loads(p.read_text(encoding="utf-8")) + + deny_types = {"binary", "github-action", "java-archive"} + + def syft_pkg_type(component: dict) -> str | None: + for prop in component.get("properties") or []: + if prop.get("name") == "syft:package:type": + return prop.get("value") + return None + + comps = d.get("components") or [] + before = len(comps) + kept = [] + kept_refs = set() + + for c in comps: + t = syft_pkg_type(c) + if t in deny_types: + continue + kept.append(c) + ref = c.get("bom-ref") + if ref: + kept_refs.add(ref) + + d["components"] = kept + + # Prune dependency graph to match kept components (if present) + deps = d.get("dependencies") or [] + if deps: + new_deps = [] + for dep in deps: + ref = dep.get("ref") + if ref and ref not in kept_refs: + continue + depends_on = dep.get("dependsOn") or [] + dep["dependsOn"] = [r for r in depends_on if r in kept_refs] + new_deps.append(dep) + d["dependencies"] = new_deps + + p.write_text(json.dumps(d, indent=2, sort_keys=True) + "\n", encoding="utf-8") + after = len(d.get("components") or []) + print(f"Pruned SBOM components: {before} -> {after} (dropped types: {sorted(deny_types)})") + PY - name: Dump SBOM to logs shell: bash run: | From 4be0982f83e5465b158779dd0de45c249eebfb08 Mon Sep 17 00:00:00 2001 From: sylvain Date: Mon, 5 Jan 2026 18:23:22 +0100 Subject: [PATCH 3/9] CI: prune non-package entries from Syft SBOM --- .github/workflows/sonar.yaml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index c6b6ce95d89c..6c26cc95555c 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -39,34 +39,38 @@ jobs: python3 - <<'PY' import json from pathlib import Path - + p = Path("sbom.cdx.json") d = json.loads(p.read_text(encoding="utf-8")) - + deny_types = {"binary", "github-action", "java-archive"} - + def syft_pkg_type(component: dict) -> str | None: for prop in component.get("properties") or []: if prop.get("name") == "syft:package:type": return prop.get("value") return None - + comps = d.get("components") or [] before = len(comps) kept = [] kept_refs = set() - + for c in comps: t = syft_pkg_type(c) + # Only keep components that Syft actually identified as a package. + # This drops generic "file" components that add noise but no dependency signal. + if not t: + continue if t in deny_types: continue kept.append(c) ref = c.get("bom-ref") if ref: kept_refs.add(ref) - + d["components"] = kept - + # Prune dependency graph to match kept components (if present) deps = d.get("dependencies") or [] if deps: @@ -79,7 +83,7 @@ jobs: dep["dependsOn"] = [r for r in depends_on if r in kept_refs] new_deps.append(dep) d["dependencies"] = new_deps - + p.write_text(json.dumps(d, indent=2, sort_keys=True) + "\n", encoding="utf-8") after = len(d.get("components") or []) print(f"Pruned SBOM components: {before} -> {after} (dropped types: {sorted(deny_types)})") From 84b3526c0225b9cf13a8fee34b76a1f901e73fb7 Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 6 Jan 2026 09:19:42 +0100 Subject: [PATCH 4/9] CI: scope Syft SBOM to 3rdparty and align Sonar projectKey --- .github/workflows/sonar.yaml | 14 +++++++------- sonar-project.properties | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 6c26cc95555c..4bb42ca3191f 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -29,9 +29,9 @@ jobs: - name: Generate SBOM (Syft / CycloneDX) uses: anchore/sbom-action@v0.21.0 with: - path: . + path: 3rdparty format: cyclonedx-json - output-file: sbom.cdx.json + output-file: sbom-3rdparty.cdx.json - name: Prune SBOM noise shell: bash run: | @@ -40,7 +40,7 @@ jobs: import json from pathlib import Path - p = Path("sbom.cdx.json") + p = Path("sbom-3rdparty.cdx.json") d = json.loads(p.read_text(encoding="utf-8")) deny_types = {"binary", "github-action", "java-archive"} @@ -92,10 +92,10 @@ jobs: shell: bash run: | set -euo pipefail - echo "SBOM file: sbom.cdx.json" - echo "SBOM size (bytes): $(wc -c < sbom.cdx.json)" + echo "SBOM file: sbom-3rdparty.cdx.json" + echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.cdx.json)" echo "----- BEGIN SBOM (truncated to first 200000 bytes) -----" - head -c 200000 sbom.cdx.json || true + head -c 200000 sbom-3rdparty.cdx.json || true echo echo "----- END SBOM -----" - name: Run SonarQube scan @@ -107,4 +107,4 @@ jobs: args: > -X -Dsonar.sca.enabled=true - -Dsonar.sca.sbomImportPaths=sbom.cdx.json + -Dsonar.sca.sbomImportPaths=sbom-3rdparty.cdx.json diff --git a/sonar-project.properties b/sonar-project.properties index e03ad696388d..a90a8d5630f7 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,3 @@ -sonar.projectKey=SonarSource-Demos_opencv_552f564f-bcdb-47eb-abb2-4ba7feb428ee +sonar.projectKey=SonarSource-Demos_opencv sonar.cfamily.compile-commands=build/compile_commands.json sonar.exclusions=**/*.java From 584e147ade9fdc7c45faa62cc1d4e497bc926446 Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 6 Jan 2026 09:50:06 +0100 Subject: [PATCH 5/9] CI: generate 3rdparty SBOM with ScanCode (SPDX) --- .github/workflows/sonar.yaml | 72 +++++------------------------------- 1 file changed, 9 insertions(+), 63 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 4bb42ca3191f..ae3c88cdb60d 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -26,76 +26,22 @@ jobs: cmake .. cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_JAVA=OFF -DBUILD_opencv_java=OFF . cd .. - - name: Generate SBOM (Syft / CycloneDX) - uses: anchore/sbom-action@v0.21.0 - with: - path: 3rdparty - format: cyclonedx-json - output-file: sbom-3rdparty.cdx.json - - name: Prune SBOM noise + - name: Generate SBOM for bundled deps (ScanCode / SPDX JSON) shell: bash run: | set -euo pipefail - python3 - <<'PY' - import json - from pathlib import Path - - p = Path("sbom-3rdparty.cdx.json") - d = json.loads(p.read_text(encoding="utf-8")) - - deny_types = {"binary", "github-action", "java-archive"} - - def syft_pkg_type(component: dict) -> str | None: - for prop in component.get("properties") or []: - if prop.get("name") == "syft:package:type": - return prop.get("value") - return None - - comps = d.get("components") or [] - before = len(comps) - kept = [] - kept_refs = set() - - for c in comps: - t = syft_pkg_type(c) - # Only keep components that Syft actually identified as a package. - # This drops generic "file" components that add noise but no dependency signal. - if not t: - continue - if t in deny_types: - continue - kept.append(c) - ref = c.get("bom-ref") - if ref: - kept_refs.add(ref) - - d["components"] = kept - - # Prune dependency graph to match kept components (if present) - deps = d.get("dependencies") or [] - if deps: - new_deps = [] - for dep in deps: - ref = dep.get("ref") - if ref and ref not in kept_refs: - continue - depends_on = dep.get("dependsOn") or [] - dep["dependsOn"] = [r for r in depends_on if r in kept_refs] - new_deps.append(dep) - d["dependencies"] = new_deps - - p.write_text(json.dumps(d, indent=2, sort_keys=True) + "\n", encoding="utf-8") - after = len(d.get("components") or []) - print(f"Pruned SBOM components: {before} -> {after} (dropped types: {sorted(deny_types)})") - PY + docker run --rm \ + -v "$PWD:/w" \ + aboutcodeorg/scancode-toolkit:latest \ + scancode --package --spdx-json /w/sbom-3rdparty.spdx.json /w/3rdparty - name: Dump SBOM to logs shell: bash run: | set -euo pipefail - echo "SBOM file: sbom-3rdparty.cdx.json" - echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.cdx.json)" + echo "SBOM file: sbom-3rdparty.spdx.json" + echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.spdx.json)" echo "----- BEGIN SBOM (truncated to first 200000 bytes) -----" - head -c 200000 sbom-3rdparty.cdx.json || true + head -c 200000 sbom-3rdparty.spdx.json || true echo echo "----- END SBOM -----" - name: Run SonarQube scan @@ -107,4 +53,4 @@ jobs: args: > -X -Dsonar.sca.enabled=true - -Dsonar.sca.sbomImportPaths=sbom-3rdparty.cdx.json + -Dsonar.sca.sbomImportPaths=sbom-3rdparty.spdx.json From c3b3a7636d3d17b4edc53c263a4f402e6d76ea40 Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 6 Jan 2026 09:57:08 +0100 Subject: [PATCH 6/9] CI: install ScanCode via pip (fix SBOM generation) --- .github/workflows/sonar.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index ae3c88cdb60d..fe9ad7825e27 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -30,10 +30,8 @@ jobs: shell: bash run: | set -euo pipefail - docker run --rm \ - -v "$PWD:/w" \ - aboutcodeorg/scancode-toolkit:latest \ - scancode --package --spdx-json /w/sbom-3rdparty.spdx.json /w/3rdparty + python3 -m pip install --disable-pip-version-check --no-cache-dir scancode-toolkit==32.4.1 + scancode --package --spdx-json sbom-3rdparty.spdx.json 3rdparty - name: Dump SBOM to logs shell: bash run: | From 5201676c696e5292c3b61f9db1e07959ccaa069d Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 6 Jan 2026 10:10:21 +0100 Subject: [PATCH 7/9] CI: output ScanCode SBOM as SPDX tag-value --- .github/workflows/sonar.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index fe9ad7825e27..e6f6d4e50111 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -31,15 +31,15 @@ jobs: run: | set -euo pipefail python3 -m pip install --disable-pip-version-check --no-cache-dir scancode-toolkit==32.4.1 - scancode --package --spdx-json sbom-3rdparty.spdx.json 3rdparty + scancode --package --spdx-tv sbom-3rdparty.spdx 3rdparty - name: Dump SBOM to logs shell: bash run: | set -euo pipefail - echo "SBOM file: sbom-3rdparty.spdx.json" - echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.spdx.json)" + echo "SBOM file: sbom-3rdparty.spdx" + echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.spdx)" echo "----- BEGIN SBOM (truncated to first 200000 bytes) -----" - head -c 200000 sbom-3rdparty.spdx.json || true + head -c 200000 sbom-3rdparty.spdx || true echo echo "----- END SBOM -----" - name: Run SonarQube scan @@ -51,4 +51,4 @@ jobs: args: > -X -Dsonar.sca.enabled=true - -Dsonar.sca.sbomImportPaths=sbom-3rdparty.spdx.json + -Dsonar.sca.sbomImportPaths=sbom-3rdparty.spdx From caf581406da3aa00f1f9ad1d31813892cb8674a6 Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 6 Jan 2026 10:24:54 +0100 Subject: [PATCH 8/9] CI: generate 3rdparty CycloneDX SBOM from vendored sources --- .github/scripts/generate_3rdparty_sbom.py | 179 ++++++++++++++++++++++ .github/workflows/sonar.yaml | 13 +- 2 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/generate_3rdparty_sbom.py diff --git a/.github/scripts/generate_3rdparty_sbom.py b/.github/scripts/generate_3rdparty_sbom.py new file mode 100644 index 000000000000..2fb773272e39 --- /dev/null +++ b/.github/scripts/generate_3rdparty_sbom.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Generate a small CycloneDX JSON SBOM for OpenCV bundled (vendored) deps under 3rdparty/. + +Rationale: +- Tools like Syft often can't infer "packages" from C/C++ source-only vendor trees. +- ScanCode can emit SPDX inventories, but those are typically file-centric and too noisy for dependency SBOM import. + +So we extract versions from upstream headers / CMake metadata in the vendored sources and emit a +package-oriented CycloneDX 1.6 SBOM with a small set of components. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Optional + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="ignore") + + +def first_match(text: str, patterns: list[str]) -> Optional[str]: + for pat in patterns: + m = re.search(pat, text, flags=re.MULTILINE) + if m: + return m.group(1) + return None + + +def zlib_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/zlib/zlib.h" + if not p.exists(): + return None + t = read_text(p) + return first_match(t, [r'^\s*#define\s+ZLIB_VERSION\s+"([^"]+)"\s*$']) + + +def libpng_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/libpng/png.h" + if not p.exists(): + return None + t = read_text(p) + # Prefer macro; fallback to the comment header. + return first_match( + t, + [ + r'^\s*#define\s+PNG_LIBPNG_VER_STRING\s+"([^"]+)"\s*$', + r"^\s*\*\s*libpng version\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", + ], + ) + + +def libjpeg_turbo_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/libjpeg-turbo/CMakeLists.txt" + if not p.exists(): + return None + t = read_text(p) + maj = first_match(t, [r'^\s*set\s*\(\s*VERSION_MAJOR\s+(\d+)\s*\)\s*$']) + min_ = first_match(t, [r'^\s*set\s*\(\s*VERSION_MINOR\s+(\d+)\s*\)\s*$']) + rev = first_match(t, [r'^\s*set\s*\(\s*VERSION_REVISION\s+(\d+)\s*\)\s*$']) + if maj and min_ and rev: + return f"{maj}.{min_}.{rev}" + return None + + +def openexr_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/openexr/CMakeLists.txt" + if not p.exists(): + return None + t = read_text(p) + maj = first_match(t, [r'^\s*set\s*\(\s*OPENEXR_VERSION_MAJOR\s+"?(\d+)"?\s*\)\s*$']) + min_ = first_match(t, [r'^\s*set\s*\(\s*OPENEXR_VERSION_MINOR\s+"?(\d+)"?\s*\)\s*$']) + pat = first_match(t, [r'^\s*set\s*\(\s*OPENEXR_VERSION_PATCH\s+"?(\d+)"?\s*\)\s*$']) + if maj and min_ and pat: + return f"{maj}.{min_}.{pat}" + return None + + +def protobuf_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/protobuf/src/google/protobuf/stubs/common.h" + if not p.exists(): + return None + t = read_text(p) + v = first_match(t, [r'^\s*#define\s+GOOGLE_PROTOBUF_VERSION\s+(\d+)\s*$']) + if not v: + return None + n = int(v) + major = n // 1_000_000 + minor = (n // 1_000) % 1_000 + micro = n % 1_000 + return f"{major}.{minor}.{micro}" + + +def libtiff_version() -> Optional[str]: + # The vendored libtiff uses configure_file() templates; easiest reliable hint is ChangeLog header. + p = REPO_ROOT / "3rdparty/libtiff/ChangeLog" + if not p.exists(): + return None + t = read_text(p) + # Example line: "libtiff v4.6.0 released" + return first_match(t, [r"^\s*libtiff v([0-9]+\.[0-9]+\.[0-9]+)\s+released\s*$"]) + + +def jasper_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/libjasper/jasper/jas_config.h" + if not p.exists(): + return None + t = read_text(p) + # JasPer in OpenCV historically uses 1.900.1 style version. + return first_match(t, [r'^\s*#define\s+JAS_VERSION\s+"([^"]+)"\s*$']) + + +def ippicv_version() -> Optional[str]: + p = REPO_ROOT / "3rdparty/ippicv/ippicv.cmake" + if not p.exists(): + return None + t = read_text(p) + # Extract first ippicv__* token in the package names. + m = re.search(r'ippicv_([0-9]+\.[0-9]+\.[0-9]+)_', t) + return m.group(1) if m else None + + +def component(name: str, version: Optional[str], purl: Optional[str] = None) -> dict: + c = {"type": "library", "name": name} + if version: + c["version"] = version + if purl: + c["purl"] = purl + return c + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--output", required=True, help="Output CycloneDX JSON file path") + args = ap.parse_args() + + comps = [ + component("zlib", zlib_version(), "pkg:generic/zlib"), + component("libpng", libpng_version(), "pkg:generic/libpng"), + component("libjpeg-turbo", libjpeg_turbo_version(), "pkg:generic/libjpeg-turbo"), + component("libtiff", libtiff_version(), "pkg:generic/libtiff"), + component("libwebp", None, "pkg:generic/libwebp"), + component("jasper", jasper_version(), "pkg:generic/jasper"), + component("openexr", openexr_version(), "pkg:generic/openexr"), + component("protobuf", protobuf_version(), "pkg:generic/protobuf"), + component("ippicv", ippicv_version(), "pkg:generic/ippicv"), + ] + + # Drop components with no version only if we couldn't determine anything useful. + # (We keep libwebp with no version as a marker, since OpenCV bundles it.) + bom = { + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": {"type": "file", "name": "3rdparty"}, + "tools": {"components": [{"type": "application", "name": "generate_3rdparty_sbom.py"}]}, + }, + "components": comps, + } + + out = Path(args.output) + out.write_text(json.dumps(bom, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"Wrote {out} with {len(comps)} components") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index e6f6d4e50111..aa4fb2f1653d 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -26,20 +26,19 @@ jobs: cmake .. cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DBUILD_JAVA=OFF -DBUILD_opencv_java=OFF . cd .. - - name: Generate SBOM for bundled deps (ScanCode / SPDX JSON) + - name: Generate SBOM for bundled deps (CycloneDX) shell: bash run: | set -euo pipefail - python3 -m pip install --disable-pip-version-check --no-cache-dir scancode-toolkit==32.4.1 - scancode --package --spdx-tv sbom-3rdparty.spdx 3rdparty + python3 .github/scripts/generate_3rdparty_sbom.py --output sbom-3rdparty.cdx.json - name: Dump SBOM to logs shell: bash run: | set -euo pipefail - echo "SBOM file: sbom-3rdparty.spdx" - echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.spdx)" + echo "SBOM file: sbom-3rdparty.cdx.json" + echo "SBOM size (bytes): $(wc -c < sbom-3rdparty.cdx.json)" echo "----- BEGIN SBOM (truncated to first 200000 bytes) -----" - head -c 200000 sbom-3rdparty.spdx || true + head -c 200000 sbom-3rdparty.cdx.json || true echo echo "----- END SBOM -----" - name: Run SonarQube scan @@ -51,4 +50,4 @@ jobs: args: > -X -Dsonar.sca.enabled=true - -Dsonar.sca.sbomImportPaths=sbom-3rdparty.spdx + -Dsonar.sca.sbomImportPaths=sbom-3rdparty.cdx.json From a644b09a9e8947792657b08376b493c066a136bc Mon Sep 17 00:00:00 2001 From: sylvain Date: Tue, 6 Jan 2026 10:45:08 +0100 Subject: [PATCH 9/9] CI: auto-discover 3rdparty component versions --- .github/scripts/generate_3rdparty_sbom.py | 189 ++++++++++------------ 1 file changed, 88 insertions(+), 101 deletions(-) diff --git a/.github/scripts/generate_3rdparty_sbom.py b/.github/scripts/generate_3rdparty_sbom.py index 2fb773272e39..19b9410f4d92 100644 --- a/.github/scripts/generate_3rdparty_sbom.py +++ b/.github/scripts/generate_3rdparty_sbom.py @@ -2,21 +2,20 @@ """ Generate a small CycloneDX JSON SBOM for OpenCV bundled (vendored) deps under 3rdparty/. -Rationale: -- Tools like Syft often can't infer "packages" from C/C++ source-only vendor trees. -- ScanCode can emit SPDX inventories, but those are typically file-centric and too noisy for dependency SBOM import. +Important constraint: +- No curated allow-list of library names. This script *discovers* candidates by scanning the + 3rdparty directory tree and extracting version-like identifiers from common metadata files. -So we extract versions from upstream headers / CMake metadata in the vendored sources and emit a -package-oriented CycloneDX 1.6 SBOM with a small set of components. +Limitations: +- Vendored C/C++ sources often do not include a single authoritative version string; this uses + heuristics and therefore may miss some libraries or pick a less-than-perfect version string. """ -from __future__ import annotations - import argparse import json import re from pathlib import Path -from typing import Optional +from typing import Optional, Iterable REPO_ROOT = Path(__file__).resolve().parents[2] @@ -34,97 +33,89 @@ def first_match(text: str, patterns: list[str]) -> Optional[str]: return None -def zlib_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/zlib/zlib.h" - if not p.exists(): - return None - t = read_text(p) - return first_match(t, [r'^\s*#define\s+ZLIB_VERSION\s+"([^"]+)"\s*$']) - - -def libpng_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/libpng/png.h" - if not p.exists(): - return None - t = read_text(p) - # Prefer macro; fallback to the comment header. - return first_match( - t, - [ - r'^\s*#define\s+PNG_LIBPNG_VER_STRING\s+"([^"]+)"\s*$', - r"^\s*\*\s*libpng version\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", - ], - ) - - -def libjpeg_turbo_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/libjpeg-turbo/CMakeLists.txt" - if not p.exists(): - return None - t = read_text(p) - maj = first_match(t, [r'^\s*set\s*\(\s*VERSION_MAJOR\s+(\d+)\s*\)\s*$']) - min_ = first_match(t, [r'^\s*set\s*\(\s*VERSION_MINOR\s+(\d+)\s*\)\s*$']) - rev = first_match(t, [r'^\s*set\s*\(\s*VERSION_REVISION\s+(\d+)\s*\)\s*$']) - if maj and min_ and rev: - return f"{maj}.{min_}.{rev}" - return None - - -def openexr_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/openexr/CMakeLists.txt" - if not p.exists(): - return None - t = read_text(p) - maj = first_match(t, [r'^\s*set\s*\(\s*OPENEXR_VERSION_MAJOR\s+"?(\d+)"?\s*\)\s*$']) - min_ = first_match(t, [r'^\s*set\s*\(\s*OPENEXR_VERSION_MINOR\s+"?(\d+)"?\s*\)\s*$']) - pat = first_match(t, [r'^\s*set\s*\(\s*OPENEXR_VERSION_PATCH\s+"?(\d+)"?\s*\)\s*$']) - if maj and min_ and pat: - return f"{maj}.{min_}.{pat}" - return None +VERSION_PATTERNS = [ + # C preprocessor macros: "#define FOO_VERSION "1.2.3"" + r'^\s*#define\s+\w*VERSION\w*\s+"([0-9]+(?:\.[0-9]+){1,3})"\s*$', + # Generic comment header patterns: "version 1.2.3" or "v1.2.3 released" + r'\bversion\s+v?([0-9]+(?:\.[0-9]+){1,3})\b', + r'\bv([0-9]+(?:\.[0-9]+){1,3})\s+released\b', + # CMake: set(VERSION 1.2.3) / project(... VERSION 1.2.3) + r'^\s*set\s*\(\s*\w*VERSION\w*\s+"?([0-9]+(?:\.[0-9]+){1,3})"?\s*\)\s*$', + r'^\s*project\s*\(.*\bVERSION\s+([0-9]+(?:\.[0-9]+){1,3})\b.*\)\s*$', +] -def protobuf_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/protobuf/src/google/protobuf/stubs/common.h" - if not p.exists(): - return None - t = read_text(p) - v = first_match(t, [r'^\s*#define\s+GOOGLE_PROTOBUF_VERSION\s+(\d+)\s*$']) - if not v: +def int_version_to_semver(n: int) -> Optional[str]: + # Heuristic for packed integer versions like protobuf: + # major * 10^6 + minor * 10^3 + micro + if n < 1_000_000 or n > 9_999_999: return None - n = int(v) major = n // 1_000_000 minor = (n // 1_000) % 1_000 micro = n % 1_000 return f"{major}.{minor}.{micro}" -def libtiff_version() -> Optional[str]: - # The vendored libtiff uses configure_file() templates; easiest reliable hint is ChangeLog header. - p = REPO_ROOT / "3rdparty/libtiff/ChangeLog" - if not p.exists(): - return None - t = read_text(p) - # Example line: "libtiff v4.6.0 released" - return first_match(t, [r"^\s*libtiff v([0-9]+\.[0-9]+\.[0-9]+)\s+released\s*$"]) - - -def jasper_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/libjasper/jasper/jas_config.h" - if not p.exists(): - return None - t = read_text(p) - # JasPer in OpenCV historically uses 1.900.1 style version. - return first_match(t, [r'^\s*#define\s+JAS_VERSION\s+"([^"]+)"\s*$']) - - -def ippicv_version() -> Optional[str]: - p = REPO_ROOT / "3rdparty/ippicv/ippicv.cmake" - if not p.exists(): +def extract_versions_from_text(text: str) -> list[str]: + found: list[str] = [] + for pat in VERSION_PATTERNS: + for m in re.finditer(pat, text, flags=re.IGNORECASE | re.MULTILINE): + v = m.group(1) + if v and v not in found: + found.append(v) + # Packed integer version macro: "#define ..._VERSION 3019001" + for m in re.finditer(r"^\s*#define\s+\w*VERSION\w*\s+(\d{7})\s*$", text, flags=re.MULTILINE): + sv = int_version_to_semver(int(m.group(1))) + if sv and sv not in found: + found.append(sv) + return found + + +def candidate_files(root: Path) -> Iterable[Path]: + # Deterministic selection of "likely to contain version" files, to avoid scanning everything. + patterns = [ + "CMakeLists.txt", + "configure.ac", + "configure.in", + "VERSION", + "version", + "version.txt", + "ChangeLog", + "CHANGES", + "NEWS", + "README", + "README.md", + "README.txt", + ] + picked: list[Path] = [] + # 1) exact-ish filename hits at shallow depth + for p in root.rglob("*"): + if not p.is_file(): + continue + if p.stat().st_size > 256 * 1024: + continue + name = p.name + if name in patterns or "version" in name.lower() or "changelog" in name.lower(): + picked.append(p) + # Prefer closer files, then lexicographic for determinism; cap to keep runtime bounded. + picked.sort(key=lambda x: (len(x.relative_to(root).parts), str(x))) + return picked[:200] + + +def infer_version_for_dir(d: Path) -> Optional[str]: + versions: list[str] = [] + for p in candidate_files(d): + try: + text = read_text(p) + except Exception: + continue + for v in extract_versions_from_text(text): + versions.append(v) + if not versions: return None - t = read_text(p) - # Extract first ippicv__* token in the package names. - m = re.search(r'ippicv_([0-9]+\.[0-9]+\.[0-9]+)_', t) - return m.group(1) if m else None + # Heuristic: prefer semver-like with 3 dots, then 2 dots, then 1 dot; stable by first appearance. + versions.sort(key=lambda v: (-v.count("."), -len(v))) + return versions[0] def component(name: str, version: Optional[str], purl: Optional[str] = None) -> dict: @@ -141,20 +132,16 @@ def main() -> int: ap.add_argument("--output", required=True, help="Output CycloneDX JSON file path") args = ap.parse_args() - comps = [ - component("zlib", zlib_version(), "pkg:generic/zlib"), - component("libpng", libpng_version(), "pkg:generic/libpng"), - component("libjpeg-turbo", libjpeg_turbo_version(), "pkg:generic/libjpeg-turbo"), - component("libtiff", libtiff_version(), "pkg:generic/libtiff"), - component("libwebp", None, "pkg:generic/libwebp"), - component("jasper", jasper_version(), "pkg:generic/jasper"), - component("openexr", openexr_version(), "pkg:generic/openexr"), - component("protobuf", protobuf_version(), "pkg:generic/protobuf"), - component("ippicv", ippicv_version(), "pkg:generic/ippicv"), - ] + thirdparty = REPO_ROOT / "3rdparty" + comps: list[dict] = [] + for child in sorted([p for p in thirdparty.iterdir() if p.is_dir()]): + name = child.name + version = infer_version_for_dir(child) + if not version: + continue + comps.append(component(name, version, f"pkg:generic/{name}")) # Drop components with no version only if we couldn't determine anything useful. - # (We keep libwebp with no version as a marker, since OpenCV bundles it.) bom = { "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", "bomFormat": "CycloneDX",