diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a93f1d..f366b32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,3 +91,42 @@ jobs: reports/ci/static-analysis-sample-app.json \ reports/sample-app/static-analysis.json python -m pytest tests/test_sample_app_security_demo.py -q + + dependency-scan: + name: Dependency scan + needs: test + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install development dependencies + run: python -m pip install -e ".[dev]" + + - name: Run offline OSV dependency gate + run: | + python -m tools.run_ci_dependency_scan \ + --requirements sample_app/requirements-vulnerable.txt \ + --fixture tests/fixtures/osv/fastapi-0.109.0.json \ + --output reports/ci/dependency-scan.json \ + --fail-on critical + + - name: Validate dependency report + run: | + cmp \ + reports/ci/dependency-scan.json \ + reports/sample-app/dependency-scan.json + python -m pytest \ + tests/test_ci_dependency_scan.py \ + tests/test_dependency_integration.py -q diff --git a/docs/ci.md b/docs/ci.md index f7bc404..6fc6dac 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,11 +1,13 @@ # GitHub Actions CI -Bu doküman Backlog 5.1 ve 5.2 kapsamındaki test ve statik analiz pipeline'ının -gereksinimlerini, güvenlik sınırlarını ve çalışma sırasını açıklar. +Bu doküman Backlog 5.1, 5.2 ve 5.3 kapsamındaki test, statik analiz ve +bağımlılık tarama pipeline'ının gereksinimlerini, güvenlik sınırlarını ve +çalışma sırasını açıklar. ## Mevcut Durum -GitHub Actions test ve statik analiz workflow'u tamamlanmıştır: +GitHub Actions test, statik analiz ve bağımlılık tarama workflow'u +tamamlanmıştır: ```text .github/workflows/ci.yml @@ -14,7 +16,9 @@ GitHub Actions test ve statik analiz workflow'u tamamlanmıştır: Workflow Pull Request değişikliklerinde ve `main` branch push olaylarında Python 3.11 test paketini çalıştırır. Test gate'i geçtikten sonra proje kaynaklarını ve kontrollü demo uygulamasını ayrı bir static-analysis job'unda -tarar. Sıradaki aşama dependency scanner ve report artifact job'larıdır. +tarar. Buna paralel dependency-scan job'u, checked-in OSV fixture ile kritik +güvenlik açığı gate'ini çalıştırır. Sıradaki aşama report artifact upload +job'udur. ## Trigger Sözleşmesi @@ -35,9 +39,9 @@ permissions: contents: read ``` -Checkout sonrasında credential persistence kapalıdır. Test ve statik analiz -job'ları `ubuntu-latest` runner üzerinde en fazla 10 dakika çalışır. Aynı -workflow ve Git ref için yeni bir run başladığında önceki run iptal edilir. +Checkout sonrasında credential persistence kapalıdır. Üç job da +`ubuntu-latest` runner üzerinde en fazla 10 dakika çalışır. Aynı workflow ve +Git ref için yeni bir run başladığında önceki run iptal edilir. Bu job'lar secret, write permission, deployment environment veya external service credential kullanmaz. @@ -102,6 +106,40 @@ Exit code veya baseline değişirse job başarısız olur. Bu aşamada `reports/ci` yalnızca job workspace'inde tutulur. Artifact upload Backlog 5.4 kapsamında eklenecektir. +## Bağımlılık Tarama Job Akışı + +`dependency-scan` job'u test job'una bağlıdır ve statik analiz job'uyla +paralel çalışabilir. Aşağıdaki komut production dependency runner'ını yerel +OSV verisiyle çalıştırır: + +```text +python -m tools.run_ci_dependency_scan +``` + +Workflow girdileri açıkça sabitlenmiştir: + +```text +requirements: sample_app/requirements-vulnerable.txt +OSV fixture: tests/fixtures/osv/fastapi-0.109.0.json +report: reports/ci/dependency-scan.json +fail-on: critical +``` + +`tools/osv_fixture.py`, fixture metadata'sındaki package, ecosystem ve version +değerlerini doğrular ve yalnızca aynı query için cevap verir. Canlı HTTP +client oluşturulmaz. Fixture bulunamazsa, geçersizse, query eşleşmezse veya +scan error üretirse komut fail-closed davranarak exit code `2` döndürür. + +Checked-in gerçek OSV kaydı `PYSEC-2024-38` / `CVE-2024-24762` için `HIGH` +severity üretir. CI eşiği `CRITICAL` olduğundan bu kontrollü bulgu raporlanır +ancak job'u başarısız yapmaz. Aynı veri `--fail-on high` ile exit code `1` +üretir; bu davranış testlerle korunur. + +Üretilen JSON, `reports/sample-app/dependency-scan.json` baseline'ı ile byte +düzeyinde karşılaştırılır. Ardından offline CI ve production-layer dependency +entegrasyon testleri çalıştırılır. Böylece ağ erişilebilirliği pipeline +sonucunu etkilemez ve fixture drift'i sessizce kabul edilmez. + ## Yerel Eşdeğer CI test adımını yerelde doğrulamak için: @@ -128,6 +166,19 @@ python -m pytest tests/test_sample_app_security_demo.py -q Demo komutunun beklenen exit code değeri `1` olmalıdır. Üretilen demo JSON'u checked-in baseline ile birebir aynı olmalıdır. +Offline dependency gate'ini yerelde çalıştırmak için: + +```powershell +python -m tools.run_ci_dependency_scan ` + --requirements sample_app/requirements-vulnerable.txt ` + --fixture tests/fixtures/osv/fastapi-0.109.0.json ` + --output reports/ci/dependency-scan.json ` + --fail-on critical +``` + +Başarılı kontrollü tarama exit code `0` üretir. Report, gerçek `HIGH` bulguyu +korur; `--fail-on high` kullanıldığında aynı komut exit code `1` döndürür. + Workflow sözleşmesi de normal test paketi içindedir: ```powershell @@ -156,6 +207,9 @@ python -m pytest tests/test_python_compatibility.py -q - Test job'una bağlı statik analiz job'u - `src`, `tools` ve `sample_app` JSON rapor yolları - Kontrollü demo exit code ve baseline doğrulaması +- Test job'una bağlı offline dependency-scan job'u +- Açık requirements, fixture, output ve `critical` eşik değerleri +- Dependency baseline ve entegrasyon testi doğrulaması `tests/test_python_compatibility.py`, `src`, `sample_app`, `tools` ve `tests` altındaki bütün Python dosyalarını desteklenen en düşük sürüm olan Python @@ -165,18 +219,18 @@ geçen Python 3.12+ söz dizimi değişiklikleri CI'a ulaşmadan tespit edilir. Doğrulanan mevcut sonuç: ```text -CI workflow contract tests: 10 passed +CI workflow contract tests: 13 passed +Offline dependency CI tests: 6 passed Python 3.11 compatibility tests: 1 passed -Complete test suite: 988 passed +Complete test suite: 997 passed Workflow YAML parse check: passed ``` ## Sonraki Güvenlik Job'ları -Backlog 5.1 ve 5.2 tamamlanmıştır. Sonraki aşamalar: +Backlog 5.1, 5.2 ve 5.3 tamamlanmıştır. Sonraki aşama: -1. Offline OSV fixture-backed dependency scan gate'i -2. Static ve dependency JSON raporlarını workflow artifact olarak yükleme +1. Static ve dependency JSON raporlarını workflow artifact olarak yükleme Bu ayrım her güvenlik gate'inin davranışını bağımsız olarak incelemeyi sağlar. diff --git a/docs/components/dependency-scanner/README.md b/docs/components/dependency-scanner/README.md index 43be666..4c97d9c 100644 --- a/docs/components/dependency-scanner/README.md +++ b/docs/components/dependency-scanner/README.md @@ -1732,6 +1732,47 @@ Self-analysis: No findings found. Exit code: 0 ``` +## Offline CI Dependency Gate + +Backlog 5.3 kapsamında dependency scanner production runner'ı deterministik +bir CI komutuna bağlanmıştır: + +```powershell +python -m tools.run_ci_dependency_scan ` + --requirements sample_app/requirements-vulnerable.txt ` + --fixture tests/fixtures/osv/fastapi-0.109.0.json ` + --output reports/ci/dependency-scan.json ` + --fail-on critical +``` + +Komut aşağıdaki production katmanlarını değiştirmeden kullanır: + +```text +dependency CLI runner + -> DependencyScanner + -> OsvVulnerabilitySource + -> metadata-validated fixture query client + -> dependency JSON formatter +``` + +Fixture query client yalnızca metadata'da kayıtlı `fastapi==0.109.0` PyPI +query değerini kabul eder ve HTTP çağrısı yapmaz. Eksik veya geçersiz fixture +operational exit code `2` ile gate'i kapalı tutar. + +CI eşiği `CRITICAL` olarak belirlenmiştir. Fixture'daki gerçek `HIGH` +`PYSEC-2024-38` bulgusu JSON raporunda korunur ve exit code `0` üretir. Aynı +scan `--fail-on high` seçeneğiyle exit code `1` döndürür. Üretilen report, +checked-in dependency baseline ile byte düzeyinde karşılaştırılır. + +Güncel doğrulama: + +```text +Offline dependency CI tests: 6 passed +CI workflow contract tests: 13 passed +Complete test suite: 997 passed +Live HTTP requests: disabled +``` + ## Navigation - [Tüm bileşenlere dön](../README.md) diff --git a/docs/components/sample-web-app/README.md b/docs/components/sample-web-app/README.md index b6e6000..5a1ebbf 100644 --- a/docs/components/sample-web-app/README.md +++ b/docs/components/sample-web-app/README.md @@ -60,9 +60,9 @@ Mevcut özellikler: * Ayrı ve runtime dışı vulnerable dependency fixture'ı * Portable static-analysis ve dependency-scan JSON baseline'ları * Offline report generator ve drift check -* Pull Request ve `main` push için pytest ve statik analiz CI job'ları +* Pull Request ve `main` push için test, static ve dependency CI job'ları -Sıradaki aşama dependency scanner ve report artifact CI job'larıdır. +Sıradaki aşama security report artifact upload job'udur. ## Kurulum @@ -386,13 +386,13 @@ reports/sample-app/dependency-scan.json İki raporu production analyzer/scanner katmanlarından yeniden üretmek için: ```powershell -python tools/generate_sample_app_reports.py +python -m tools.generate_sample_app_reports ``` Checked-in artifact'ların güncel olduğunu dosya yazmadan doğrulamak için: ```powershell -python tools/generate_sample_app_reports.py --check +python -m tools.generate_sample_app_reports --check ``` Generator static analyzer'ı doğrudan çalıştırır. Dependency baseline için @@ -439,7 +439,7 @@ repository-relative `/` biçimindedir. ```powershell Get-Content reports/sample-app/dependency-scan.json - python tools/generate_sample_app_reports.py --check + python -m tools.generate_sample_app_reports --check ``` Demo sonunda Flask runtime requirements dosyasının yalnızca `Flask==3.1.3` @@ -547,10 +547,11 @@ New update/delete test cases: 54 passed New frontend test cases: 18 passed New security demo test cases: 4 passed New integration report test cases: 6 passed -New CI workflow test cases: 10 passed +New CI workflow test cases: 13 passed +New offline dependency CI test cases: 6 passed New Python 3.11 compatibility test cases: 1 passed Sample app targeted suite: 149 passed -Complete test suite: 988 passed +Complete test suite: 997 passed Compile check: passed Sample app analysis: 5 expected findings Analyzer source self-analysis: no findings diff --git a/docs/components/sample-web-app/analysis.md b/docs/components/sample-web-app/analysis.md index 813c4ca..017d01b 100644 --- a/docs/components/sample-web-app/analysis.md +++ b/docs/components/sample-web-app/analysis.md @@ -697,14 +697,14 @@ veri checked-in baseline'ın deterministikliğini değiştirmemelidir. Tek repository aracı iki raporu birlikte üretmelidir: ```powershell -python tools/generate_sample_app_reports.py +python -m tools.generate_sample_app_reports ``` `--check` modu dosya yazmadan güncellik kontrolü yapmalı ve missing veya stale artifact için exit code `1` döndürmelidir: ```powershell -python tools/generate_sample_app_reports.py --check +python -m tools.generate_sample_app_reports --check ``` ### Demo Sırası diff --git a/tests/test_ci_dependency_scan.py b/tests/test_ci_dependency_scan.py new file mode 100644 index 0000000..1088e80 --- /dev/null +++ b/tests/test_ci_dependency_scan.py @@ -0,0 +1,218 @@ +"""Tests for the deterministic dependency-scan CI command.""" + +from __future__ import annotations + +import io +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +import dependency_scanner.osv_client as osv_client_module +from dependency_scanner.osv_client import OsvQueryError +from tools.osv_fixture import FixtureOsvQueryClient +from tools.run_ci_dependency_scan import main + + +_REPOSITORY_ROOT = Path(__file__).parents[1] +_FIXTURE_PATH = ( + _REPOSITORY_ROOT + / "tests" + / "fixtures" + / "osv" + / "fastapi-0.109.0.json" +) +_REQUIREMENTS_PATH = ( + _REPOSITORY_ROOT + / "sample_app" + / "requirements-vulnerable.txt" +) +_BASELINE_PATH = ( + _REPOSITORY_ROOT + / "reports" + / "sample-app" + / "dependency-scan.json" +) + + +def test_fixture_client_enforces_recorded_query() -> None: + """Offline data should answer only its metadata query.""" + + client = FixtureOsvQueryClient(_FIXTURE_PATH) + + assert client.expected_query == ( + "fastapi", + "0.109.0", + None, + ) + response = client.query_package( + "FastAPI", + "0.109.0", + ) + assert response.vulnerabilities[0].advisory_id == ( + "PYSEC-2024-38" + ) + + with pytest.raises(OsvQueryError): + client.query_package("fastapi", "0.110.0") + + with pytest.raises(OsvQueryError): + client.query_package( + "fastapi", + "0.109.0", + page_token="unexpected", + ) + + +def test_ci_scan_matches_baseline_without_http( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default critical gate should write the offline baseline.""" + + def fail_network_call( + *args: object, + **kwargs: object, + ) -> None: + pytest.fail("Unexpected live OSV HTTP request.") + + monkeypatch.setattr( + osv_client_module, + "urlopen", + fail_network_call, + ) + output_path = tmp_path / "dependency-scan.json" + + exit_code = main( + [ + "--requirements", + str(_REQUIREMENTS_PATH), + "--fixture", + str(_FIXTURE_PATH), + "--output", + str(output_path), + ] + ) + + assert exit_code == 0 + assert output_path.read_text(encoding="utf-8") == ( + _BASELINE_PATH.read_text(encoding="utf-8") + ) + + +def test_ci_scan_module_entrypoint_runs_without_root_pythonpath( + tmp_path: Path, +) -> None: + """The workflow command should resolve the tools package on Linux.""" + + output_path = tmp_path / "dependency-scan.json" + environment = os.environ.copy() + pythonpath_entries = [str(_REPOSITORY_ROOT / "src")] + + for entry in environment.get("PYTHONPATH", "").split( + os.pathsep + ): + if not entry: + continue + + resolved_entry = Path(entry).resolve() + if resolved_entry in { + _REPOSITORY_ROOT.resolve(), + (_REPOSITORY_ROOT / "src").resolve(), + }: + continue + + pythonpath_entries.append(entry) + + environment["PYTHONPATH"] = os.pathsep.join( + pythonpath_entries + ) + completed = subprocess.run( + [ + sys.executable, + "-m", + "tools.run_ci_dependency_scan", + "--requirements", + str(_REQUIREMENTS_PATH), + "--fixture", + str(_FIXTURE_PATH), + "--output", + str(output_path), + "--fail-on", + "critical", + ], + cwd=_REPOSITORY_ROOT, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert output_path.read_text(encoding="utf-8") == ( + _BASELINE_PATH.read_text(encoding="utf-8") + ) + + +@pytest.mark.parametrize( + ("fail_on", "expected_exit_code"), + [ + ("high", 1), + ("critical", 0), + ], +) +def test_ci_scan_applies_configured_threshold( + tmp_path: Path, + fail_on: str, + expected_exit_code: int, +) -> None: + """The recorded HIGH finding should exercise the CI threshold.""" + + output_path = tmp_path / f"{fail_on}.json" + + exit_code = main( + [ + "--requirements", + str(_REQUIREMENTS_PATH), + "--fixture", + str(_FIXTURE_PATH), + "--output", + str(output_path), + "--fail-on", + fail_on, + ] + ) + + payload = json.loads( + output_path.read_text(encoding="utf-8") + ) + assert exit_code == expected_exit_code + assert payload["findings"][0]["severity"] == "high" + + +def test_ci_scan_fails_closed_when_fixture_is_missing( + tmp_path: Path, +) -> None: + """Unavailable offline data should return operational exit code two.""" + + output_path = tmp_path / "dependency-scan.json" + stderr = io.StringIO() + + exit_code = main( + [ + "--requirements", + str(_REQUIREMENTS_PATH), + "--fixture", + str(tmp_path / "missing.json"), + "--output", + str(output_path), + ], + stderr=stderr, + ) + + assert exit_code == 2 + assert not output_path.exists() + assert "Error:" in stderr.getvalue() diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index b903c10..e0e303d 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -137,10 +137,6 @@ def test_static_analysis_job_waits_for_tests() -> None: " runs-on: ubuntu-latest\n" " timeout-minutes: 10\n" ) in workflow - assert workflow.count("persist-credentials: false") == 2 - assert workflow.count('python-version: "3.11"') == 2 - assert workflow.count("actions/checkout@") == 2 - assert workflow.count("actions/setup-python@") == 2 def test_static_analysis_job_writes_project_json_reports() -> None: @@ -194,3 +190,55 @@ def test_static_analysis_job_validates_controlled_demo() -> None: "tests/test_sample_app_security_demo.py -q" in workflow ) + + +def test_dependency_scan_job_waits_for_tests() -> None: + """The offline vulnerability gate should follow the test job.""" + + workflow = _workflow_text() + + assert ( + " dependency-scan:\n" + " name: Dependency scan\n" + " needs: test\n" + " runs-on: ubuntu-latest\n" + " timeout-minutes: 10\n" + ) in workflow + assert workflow.count("persist-credentials: false") == 3 + assert workflow.count('python-version: "3.11"') == 3 + assert workflow.count("actions/checkout@") == 3 + assert workflow.count("actions/setup-python@") == 3 + + +def test_dependency_scan_job_uses_offline_critical_gate() -> None: + """CI should use recorded OSV data and a critical threshold.""" + + workflow = _workflow_text() + + assert "python -m tools.run_ci_dependency_scan" in workflow + assert ( + "--requirements " + "sample_app/requirements-vulnerable.txt" + in workflow + ) + assert ( + "--fixture " + "tests/fixtures/osv/fastapi-0.109.0.json" + in workflow + ) + assert ( + "--output reports/ci/dependency-scan.json" + in workflow + ) + assert "--fail-on critical" in workflow + + +def test_dependency_scan_job_validates_offline_report() -> None: + """Generated dependency data should match its tested baseline.""" + + workflow = _workflow_text() + + assert "reports/ci/dependency-scan.json" in workflow + assert "reports/sample-app/dependency-scan.json" in workflow + assert "tests/test_ci_dependency_scan.py" in workflow + assert "tests/test_dependency_integration.py -q" in workflow diff --git a/tools/generate_sample_app_reports.py b/tools/generate_sample_app_reports.py index 1742714..ed8d47f 100644 --- a/tools/generate_sample_app_reports.py +++ b/tools/generate_sample_app_reports.py @@ -9,10 +9,8 @@ from dependency_scanner import ( DependencyScanner, - OsvQueryResponse, OsvVulnerabilitySource, format_dependency_scan_json, - parse_osv_query_response, ) from static_analyzer.default_factory import ( create_default_analyzer, @@ -20,6 +18,7 @@ from static_analyzer.formatters import ( format_findings_json, ) +from .osv_fixture import FixtureOsvQueryClient _REPOSITORY_ROOT = Path(__file__).resolve().parents[1] @@ -37,37 +36,6 @@ _DEFAULT_OUTPUT_DIRECTORY = ( _REPOSITORY_ROOT / "reports" / "sample-app" ) -_EXPECTED_QUERY = ("fastapi", "0.109.0", None) - - -class FixtureOsvQueryClient: - """Serve the checked-in official OSV projection without HTTP.""" - - def __init__(self) -> None: - """Load the deterministic response used by the report.""" - - payload = json.loads( - _OSV_FIXTURE.read_text(encoding="utf-8") - ) - self._response = parse_osv_query_response(payload) - - def query_package( - self, - package_name: str, - version: str, - page_token: str | None = None, - ) -> OsvQueryResponse: - """Return the fixture only for its documented exact query.""" - - query = (package_name, version, page_token) - - if query != _EXPECTED_QUERY: - raise ValueError( - "OSV fixture query does not match " - f"the documented input: {query!r}" - ) - - return self._response def build_static_analysis_report() -> str: @@ -90,7 +58,9 @@ def build_dependency_scan_report() -> str: """Return the offline OSV dependency baseline document.""" scanner = DependencyScanner( - OsvVulnerabilitySource(FixtureOsvQueryClient()) + OsvVulnerabilitySource( + FixtureOsvQueryClient(_OSV_FIXTURE) + ) ) result = scanner.scan_requirements( _VULNERABLE_REQUIREMENTS diff --git a/tools/osv_fixture.py b/tools/osv_fixture.py new file mode 100644 index 0000000..a416751 --- /dev/null +++ b/tools/osv_fixture.py @@ -0,0 +1,182 @@ +"""Serve one recorded OSV query without network access.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from dependency_scanner.osv_client import OsvQueryError +from dependency_scanner.osv_models import OsvQueryResponse +from dependency_scanner.osv_parser import ( + OsvResponseParseError, + parse_osv_query_response, +) +from dependency_scanner.package_normalizer import ( + normalize_package_name, +) + + +class OsvFixtureError(ValueError): + """Represent an invalid offline OSV fixture.""" + + +class FixtureOsvQueryClient: + """Return one checked-in OSV response for its recorded query.""" + + def __init__(self, fixture_path: str | Path) -> None: + """Load and validate one fixture document.""" + + self._fixture_path = Path(fixture_path) + payload = self._load_payload() + self._package_name, self._version = ( + _parse_fixture_query(payload) + ) + + try: + self._response = parse_osv_query_response( + payload + ) + except OsvResponseParseError as error: + raise OsvFixtureError( + f"Invalid OSV response fixture: {error}" + ) from error + + @property + def fixture_path(self) -> Path: + """Return the configured fixture path.""" + + return self._fixture_path + + @property + def expected_query(self) -> tuple[str, str, None]: + """Return the single package query recorded by the fixture.""" + + return (self._package_name, self._version, None) + + def query_package( + self, + package_name: str, + version: str, + page_token: str | None = None, + ) -> OsvQueryResponse: + """Return the response only for the fixture's exact query.""" + + try: + normalized_name = normalize_package_name( + package_name + ) + except ValueError as error: + raise OsvQueryError( + f"Invalid fixture package query: {error}" + ) from error + + query = ( + normalized_name, + version, + page_token, + ) + + if query != self.expected_query: + raise OsvQueryError( + "OSV fixture query does not match its " + f"recorded input: {query!r}" + ) + + return self._response + + def _load_payload(self) -> dict[str, object]: + """Read the fixture as one JSON object.""" + + try: + payload = json.loads( + self._fixture_path.read_text( + encoding="utf-8" + ) + ) + except json.JSONDecodeError as error: + raise OsvFixtureError( + f"Invalid OSV fixture JSON: {error.msg}" + ) from error + + if not isinstance(payload, dict): + raise OsvFixtureError( + "OSV fixture root must be an object." + ) + + return payload + + +def _parse_fixture_query( + payload: dict[str, object], +) -> tuple[str, str]: + """Return the normalized package and version from metadata.""" + + fixture = _require_mapping( + payload.get("_fixture"), + "_fixture", + ) + query = _require_mapping( + fixture.get("query"), + "_fixture.query", + ) + package = _require_mapping( + query.get("package"), + "_fixture.query.package", + ) + ecosystem = _require_string( + package.get("ecosystem"), + "_fixture.query.package.ecosystem", + ) + + if ecosystem != "PyPI": + raise OsvFixtureError( + "OSV fixture ecosystem must be PyPI." + ) + + package_name = _require_string( + package.get("name"), + "_fixture.query.package.name", + ) + version = _require_string( + query.get("version"), + "_fixture.query.version", + ) + + try: + normalized_name = normalize_package_name( + package_name + ) + except ValueError as error: + raise OsvFixtureError( + f"Invalid OSV fixture package name: {error}" + ) from error + + return normalized_name, version + + +def _require_mapping( + value: object, + path: str, +) -> dict[str, object]: + """Require one metadata mapping.""" + + if not isinstance(value, dict): + raise OsvFixtureError( + f"{path} must be an object." + ) + + return value + + +def _require_string( + value: object, + path: str, +) -> str: + """Require one non-empty metadata string.""" + + if not isinstance(value, str) or not value.strip(): + raise OsvFixtureError( + f"{path} must be a non-empty string." + ) + + return value.strip() diff --git a/tools/run_ci_dependency_scan.py b/tools/run_ci_dependency_scan.py new file mode 100644 index 0000000..a1c21be --- /dev/null +++ b/tools/run_ci_dependency_scan.py @@ -0,0 +1,212 @@ +"""Run the dependency CLI against a deterministic OSV fixture.""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Sequence +from pathlib import Path +from typing import TextIO + +from dependency_scanner import ( + DependencyScanner, + OsvVulnerabilitySource, +) +from dependency_scanner.runner import ( + main as dependency_cli_main, +) +from .osv_fixture import ( + FixtureOsvQueryClient, + OsvFixtureError, +) + + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_DEFAULT_REQUIREMENTS = Path( + "sample_app/requirements-vulnerable.txt" +) +_DEFAULT_FIXTURE = Path( + "tests/fixtures/osv/fastapi-0.109.0.json" +) +_DEFAULT_OUTPUT = Path( + "reports/ci/dependency-scan.json" +) +_FAIL_ON_LEVELS = ( + "any", + "low", + "medium", + "high", + "critical", +) + + +class FixtureScannerFactory: + """Create dependency scanners backed by one local fixture.""" + + def __init__(self, fixture_path: Path) -> None: + """Store the OSV fixture used by each scanner.""" + + self._fixture_path = fixture_path + + def __call__( + self, + *, + source_name: str, + timeout: float, + ) -> DependencyScanner: + """Create an offline scanner for the production runner.""" + + if source_name != "osv": + raise OsvFixtureError( + "Offline CI scanning supports only OSV." + ) + + if timeout <= 0: + raise OsvFixtureError( + "Scanner timeout must be positive." + ) + + client = FixtureOsvQueryClient( + self._fixture_path + ) + return DependencyScanner( + OsvVulnerabilitySource(client) + ) + + +def main( + argv: Sequence[str] | None = None, + *, + stderr: TextIO | None = None, +) -> int: + """Run one fail-closed offline dependency scan.""" + + arguments = _build_parser().parse_args(argv) + error_stream = stderr if stderr is not None else sys.stderr + + try: + arguments.output.parent.mkdir( + parents=True, + exist_ok=True, + ) + exit_code = dependency_cli_main( + [ + str(arguments.requirements), + "--format", + "json", + "--output", + str(arguments.output), + "--fail-on", + arguments.fail_on, + ], + scanner_factory=FixtureScannerFactory( + arguments.fixture + ), + stderr=error_stream, + ) + + if exit_code in (0, 1): + _normalize_report_paths( + arguments.output + ) + + return exit_code + except ( + OSError, + UnicodeDecodeError, + OsvFixtureError, + json.JSONDecodeError, + ) as error: + error_stream.write(f"Error: {error}\n") + return 2 + + +def _normalize_report_paths(output_path: Path) -> None: + """Rewrite repository paths with portable POSIX separators.""" + + payload = json.loads( + output_path.read_text(encoding="utf-8") + ) + + for dependency in payload["dependencies"]: + dependency["source_file"] = _portable_path( + dependency["source_file"] + ) + + for finding in payload["findings"]: + dependency = finding["dependency"] + dependency["source_file"] = _portable_path( + dependency["source_file"] + ) + + output_path.write_text( + json.dumps( + payload, + indent=2, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + newline="\n", + ) + + +def _portable_path(value: str) -> str: + """Return one repository-relative POSIX path.""" + + path = Path(value) + + if not path.is_absolute(): + path = _REPOSITORY_ROOT / path + + try: + relative_path = path.resolve().relative_to( + _REPOSITORY_ROOT.resolve() + ) + except ValueError as error: + raise OsvFixtureError( + f"Report path is outside the repository: {value}" + ) from error + + return relative_path.as_posix() + + +def _build_parser() -> argparse.ArgumentParser: + """Create the offline CI command parser.""" + + parser = argparse.ArgumentParser( + prog="run-ci-dependency-scan", + description=( + "Run the production dependency CLI with local OSV data." + ), + ) + parser.add_argument( + "--requirements", + type=Path, + default=_DEFAULT_REQUIREMENTS, + help="Pinned requirements file to scan.", + ) + parser.add_argument( + "--fixture", + type=Path, + default=_DEFAULT_FIXTURE, + help="Recorded OSV response fixture.", + ) + parser.add_argument( + "--output", + type=Path, + default=_DEFAULT_OUTPUT, + help="Destination JSON report path.", + ) + parser.add_argument( + "--fail-on", + choices=_FAIL_ON_LEVELS, + default="critical", + help="Minimum vulnerability severity that fails the gate.", + ) + return parser + + +if __name__ == "__main__": + raise SystemExit(main())