diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 2da382f934..8f253bb750 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -51,3 +51,4 @@ **Vulnerability:** Denial of Service / Availability **Learning:** Strix security scanners crashed when the backend LLM returned an 'HTTP Error 502: Bad Gateway' response. This was because 'bad gateway' string match and generic 'APIError' were missing from the `is_llm_api_connection_error` function in the Strix retry gate. **Prevention:** Always include `bad gateway` and `APIError` in string match conditions when handling HTTP API Connection exceptions for LLM backends to ensure proper fail-closed and retry handling. +## 2026-09-29 - Prevent Semgrep SSRF false positive\n**Vulnerability:** Server-Side Request Forgery (SSRF) / False Positive\n**Learning:** Semgrep flags urllib.request.urlopen as a potential SSRF or LFI vulnerability if it reads from a dynamic URL. In our maturin asset fetcher, the URL is strictly prefixed by a hardcoded GitHub releases path, mitigating this risk, but the scanner still warns. \n**Prevention:** Added `nosec` and `nosemgrep` comments specifically to the `urlopen` call after validating that the URL construction is secure and cannot be manipulated by untrusted user input. diff --git a/scripts/ci/strix_report_scope.py b/scripts/ci/strix_report_scope.py index 44de1c441d..856e74a744 100644 --- a/scripts/ci/strix_report_scope.py +++ b/scripts/ci/strix_report_scope.py @@ -32,7 +32,7 @@ def validate(output: Path, changed_paths: list[str]) -> None: raise ValueError("scan report does not identify a changed source file") -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover try: validate(Path(sys.argv[1]), sys.argv[2:]) except (IndexError, OSError, ValueError, TypeError) as error: diff --git a/scripts/ci/verify_release_distribution_set.py b/scripts/ci/verify_release_distribution_set.py index 1779d5dfde..0307cadf59 100644 --- a/scripts/ci/verify_release_distribution_set.py +++ b/scripts/ci/verify_release_distribution_set.py @@ -251,6 +251,7 @@ def fetch_artifact(repository: str, artifact_id: int, output: BinaryIO) -> None: process = subprocess.Popen( ["gh", "api", f"repos/{repository}/actions/artifacts/{artifact_id}/zip"], stdout=subprocess.PIPE, + shell=False, ) try: while block := process.stdout.read(1024 * 1024): diff --git a/scripts/ci/verify_release_maturin_tool_assets.py b/scripts/ci/verify_release_maturin_tool_assets.py index 90fa1517b7..5554f15bd4 100644 --- a/scripts/ci/verify_release_maturin_tool_assets.py +++ b/scripts/ci/verify_release_maturin_tool_assets.py @@ -33,7 +33,10 @@ def _download(filename: str) -> bytes: url = f"https://github.com/PyO3/maturin/releases/download/v1.15.0/{filename}" - with urlopen(Request(url, headers={"User-Agent": "cwl-release-gate"}), timeout=60) as response: + # Fixed https origin and tag; verify_assets admits only five literal asset names. + with urlopen( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected # nosec B310 + Request(url, headers={"User-Agent": "cwl-release-gate"}), timeout=60 + ) as response: raw = response.read(MAX_ASSET_BYTES + 1) if len(raw) > MAX_ASSET_BYTES: raise ValueError("maturin release asset exceeds inspection limit") diff --git a/tests/test_strix_report_scope.py b/tests/test_strix_report_scope.py index 3ff7964659..084a13ef49 100644 --- a/tests/test_strix_report_scope.py +++ b/tests/test_strix_report_scope.py @@ -24,3 +24,54 @@ def test_report_scope_rejects_unrelated_success_and_accepts_scoped_success(tmp_p report.write_text("Assessed python/fast_mlsirm/report.py; no vulnerabilities found.\n", encoding="utf-8") assert subprocess.run(command, capture_output=True).returncode == 0 assert subprocess.run(command[:-1], capture_output=True).returncode == 1 + +def test_validate_exceptions(tmp_path: Path) -> None: + import scripts.ci.strix_report_scope as strix_report_scope + import pytest + + with pytest.raises(ValueError, match="scan output directory is missing"): + strix_report_scope.validate(tmp_path / "nonexistent", []) + + run = tmp_path / "current-scan" + run.mkdir() + + with pytest.raises(ValueError, match="scan report files are missing or linked"): + strix_report_scope.validate(tmp_path, []) + + (run / "run.json").write_text("[]", encoding="utf-8") + (run / "penetration_test_report.md").write_text("", encoding="utf-8") + + with pytest.raises(ValueError, match="scan metadata is not an object"): + strix_report_scope.validate(tmp_path, []) + + (run / "run.json").write_text(json.dumps({"status": "completed", "scan_results": "[]"}), encoding="utf-8") + with pytest.raises(ValueError, match="scan results are not an object"): + strix_report_scope.validate(tmp_path, []) + + (run / "run.json").write_text(json.dumps({"scan_results": {}}), encoding="utf-8") + with pytest.raises(ValueError, match="scan report is incomplete"): + strix_report_scope.validate(tmp_path, []) + + (run / "run.json").write_text(json.dumps({"status": "completed"}), encoding="utf-8") + with pytest.raises(ValueError, match="scan report is incomplete"): + strix_report_scope.validate(tmp_path, []) + + (run / "run.json").write_text(json.dumps({"status": "completed", "scan_results": {"scan_completed": True, "success": False}}), encoding="utf-8") + with pytest.raises(ValueError, match="scan report is incomplete"): + strix_report_scope.validate(tmp_path, []) + + (run / "run.json").write_text(json.dumps({"status": "completed", "scan_results": {"scan_completed": False, "success": True}}), encoding="utf-8") + with pytest.raises(ValueError, match="scan report is incomplete"): + strix_report_scope.validate(tmp_path, ["python/fast_mlsirm/report.py"]) + + (run / "run.json").write_text(json.dumps({"status": "completed", "scan_results": {"scan_completed": True, "success": True}}), encoding="utf-8") + with pytest.raises(ValueError, match="scan report does not identify a changed source file"): + strix_report_scope.validate(tmp_path, ["python/fast_mlsirm/report.py"]) + + (run / "penetration_test_report.md").write_text("python/fast_mlsirm/report.py", encoding="utf-8") + strix_report_scope.validate(tmp_path, ["python/fast_mlsirm/report.py"]) + + run2 = tmp_path / "another-scan" + run2.mkdir(parents=True) + with pytest.raises(ValueError, match="expected exactly one current scan report"): + strix_report_scope.validate(tmp_path, []) diff --git a/tests/test_verify_release_distribution_set.py b/tests/test_verify_release_distribution_set.py index d8b8b08eae..4859dcd126 100644 --- a/tests/test_verify_release_distribution_set.py +++ b/tests/test_verify_release_distribution_set.py @@ -460,7 +460,7 @@ def kill(self): processes: list[Process] = [] - def popen(_args, stdout): + def popen(_args, stdout, shell=False): assert stdout is subprocess.PIPE process = processes.pop(0) return process @@ -539,7 +539,7 @@ def wait() -> int: def poll() -> int: return 0 - def popen(args, stdout): + def popen(args, stdout, shell=False): assert stdout is subprocess.PIPE artifact_id = int(args[2].split("/")[-2]) return Process(case["archives"][artifact_id]) diff --git a/tests/test_verify_release_scope_evidence_set.py b/tests/test_verify_release_scope_evidence_set.py index 449754b2b3..c83c7cd3d9 100644 --- a/tests/test_verify_release_scope_evidence_set.py +++ b/tests/test_verify_release_scope_evidence_set.py @@ -1458,7 +1458,7 @@ def wait() -> int: def poll() -> int: return 0 - def popen(args, stdout): + def popen(args, stdout, shell=False): assert stdout is subprocess.PIPE artifact_id = int(args[2].split("/")[-2]) return Process(case["archives"][artifact_id])