diff --git a/.github/scripts/generate_3rdparty_sbom.py b/.github/scripts/generate_3rdparty_sbom.py new file mode 100644 index 000000000000..19b9410f4d92 --- /dev/null +++ b/.github/scripts/generate_3rdparty_sbom.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +Generate a small CycloneDX JSON SBOM for OpenCV bundled (vendored) deps under 3rdparty/. + +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. + +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. +""" + +import argparse +import json +import re +from pathlib import Path +from typing import Optional, Iterable + + +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 + + +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 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 + major = n // 1_000_000 + minor = (n // 1_000) % 1_000 + micro = n % 1_000 + return f"{major}.{minor}.{micro}" + + +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 + # 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: + 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() + + 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. + 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 a0fce4972eb2..aa4fb2f1653d 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -16,30 +16,38 @@ 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 for bundled deps (CycloneDX) + shell: bash + run: | + set -euo pipefail + 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.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.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-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