diff --git a/cve-fix/SKILL.md b/cve-fix/SKILL.md index a330d98..7868ca3 100644 --- a/cve-fix/SKILL.md +++ b/cve-fix/SKILL.md @@ -1,6 +1,6 @@ --- name: cve-fix -version: 0.3.1 +version: 0.5.0 description: >- Automated CVE remediation that reads vulnerability details from Jira vulnerability tickets, applies multi-strategy dependency fixes, validates @@ -10,6 +10,7 @@ description: >- Jira vulnerability tickets. Activated by commands: /start, /scan, /patch, /validate, /pr, /backport, /close, /report. --- + # CVE Fix Workflow Orchestrator ## Quick Start diff --git a/cve-fix/scripts/scan.py b/cve-fix/scripts/scan.py index 0e7f325..fb28b15 100755 --- a/cve-fix/scripts/scan.py +++ b/cve-fix/scripts/scan.py @@ -19,6 +19,14 @@ Go version from go.mod to prevent false negatives from a newer local toolchain. +Environment: + LANGUAGE Explicit ecosystem: go, node, or python. When set, skips + auto-detection (use the CVE ticket's ecosystem in polyglot + repos). When unset, falls back to detect_language(). + FIXED_VERSION Known fixed version for Go tool-module version compares + SCAN_TIMEOUT Seconds before scan times out (default: 300) + OUTPUT_DIR Directory for JSON output (default: cwd) + Requires: ecosystem-specific scanner on PATH (govulncheck / npm / pip-audit). Exit codes: 0 — scan completed (verdict is in the JSON output) @@ -39,9 +47,26 @@ write_json, ) +GO_MOD_SKIP_DIRS = {".git", "vendor", "node_modules"} +SUPPORTED_LANGUAGES = frozenset({"go", "node", "python"}) + +VERDICT_PRIORITY = { + "present": 0, + "present_by_version": 1, + "scan_failed": 2, + "in_base_image": 3, + "informational": 4, + "absent": 5, +} + def detect_language(work_dir: Path) -> str: - """Detect project language from manifest files.""" + """Detect project language from manifest files. + + Root manifests win over nested Go modules so polyglot repos (e.g. root + package.json plus proxy/go.mod) stay Node/Python unless LANGUAGE is set. + Nested go.mod alone (tools-only / proxy-only) still detects as Go. + """ if (work_dir / "go.mod").is_file(): return "go" if (work_dir / "package.json").is_file(): @@ -49,9 +74,28 @@ def detect_language(work_dir: Path) -> str: for manifest in ("requirements.txt", "pyproject.toml", "setup.py"): if (work_dir / manifest).is_file(): return "python" + # Nested-only layouts (e.g. tools/go.mod or proxy/go.mod with no root module) + if find_go_module_dirs(work_dir): + return "go" return "unknown" +def resolve_language(work_dir: Path) -> str: + """Return LANGUAGE from the environment, or auto-detect from manifests. + + Raises ValueError when LANGUAGE is set to an unsupported value. + """ + explicit = os.environ.get("LANGUAGE", "").strip().lower() + if not explicit: + return detect_language(work_dir) + if explicit not in SUPPORTED_LANGUAGES: + allowed = ", ".join(sorted(SUPPORTED_LANGUAGES)) + raise ValueError( + f"Invalid LANGUAGE={explicit!r}; expected one of: {allowed}" + ) + return explicit + + def extract_go_version(work_dir: Path) -> str: """Extract Go version from go.mod, preferring toolchain directive.""" gomod = work_dir / "go.mod" @@ -162,8 +206,292 @@ def scan_python(work_dir: Path, cve_id: str, package: str) -> dict: } -def check_manifests(work_dir: Path, lang: str, package: str) -> str: +def find_go_module_dirs(repo_dir: Path) -> list[Path]: + """Find Go module roots under repo_dir, with the repo root listed first.""" + repo_dir = repo_dir.resolve() + modules: list[Path] = [] + for gomod in sorted(repo_dir.rglob("go.mod")): + relative_parts = gomod.relative_to(repo_dir).parts[:-1] + if any(part in GO_MOD_SKIP_DIRS for part in relative_parts): + continue + modules.append(gomod.parent) + modules.sort(key=lambda path: (path.resolve() != repo_dir, str(path))) + return modules + + +def _manifest_path(repo_dir: Path, mod_dir: Path) -> str: + rel = mod_dir.resolve().relative_to(repo_dir.resolve()) + if rel == Path("."): + return "go.mod" + return str(rel / "go.mod") + + +def check_single_go_manifest(mod_dir: Path, package: str) -> str: + """Return the first matching require line from one go.mod, if any.""" + gomod = mod_dir / "go.mod" + if not gomod.is_file(): + return "" + try: + content = gomod.read_text() + except OSError: + return "" + pkg_lower = package.lower() + for line in content.splitlines(): + if pkg_lower in line.lower(): + return line.strip() + return "" + + +def check_all_go_manifests(repo_dir: Path, package: str) -> list[dict]: + """Check a package across every go.mod in the repository.""" + matches: list[dict] = [] + for mod_dir in find_go_module_dirs(repo_dir): + line = check_single_go_manifest(mod_dir, package) + if not line: + continue + rel = mod_dir.resolve().relative_to(repo_dir.resolve()) + matches.append({ + "module_dir": "." if rel == Path(".") else str(rel), + "manifest_path": _manifest_path(repo_dir, mod_dir), + "line": line, + }) + return matches + + +def resolve_go_package_version(mod_dir: Path, package: str) -> tuple[str, str]: + """Resolve a module version with go list -m.""" + exit_code, output = run( + ["go", "list", "-m", "-f", "{{.Version}}", package], + cwd=mod_dir, + ) + version = output.strip() + if exit_code != 0 or not version: + return "", output.strip() + return version, "" + + +def scan_go_tool_module(mod_dir: Path, package: str) -> dict: + """Scan a tool-only Go module where govulncheck has no packages to analyze.""" + manifest_line = check_single_go_manifest(mod_dir, package) + version, err = resolve_go_package_version(mod_dir, package) + output_parts = [ + f"Module: {mod_dir}", + f"Manifest: {manifest_line or 'not found'}", + f"Resolved version: {version or err or 'unknown'}", + "Note: govulncheck not applicable (tool-only module)", + ] + return { + "scan_tool": "go_list_m", + "scan_exit_code": 0 if version else 1, + "scan_output": "\n".join(output_parts), + "toolchain_matched": None, + "target_go_version": extract_go_version(mod_dir) or None, + "resolved_version": version or None, + "manifest_line": manifest_line, + } + + +def _parse_semver( + version: str, +) -> tuple[int, int, int, tuple[int | str, ...]] | None: + """Parse a Go-module-style semver into (major, minor, patch, prerelease). + + prerelease is an empty tuple for a release version. Dot-separated + identifiers are ints when purely numeric, otherwise strings, for + SemVer precedence. Build metadata (+...) is accepted and ignored. + """ + match = re.fullmatch( + r"v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?", + version.strip(), + ) + if not match: + return None + major = int(match.group(1)) + minor = int(match.group(2)) + patch = int(match.group(3)) + pre_raw = match.group(4) + if not pre_raw: + return major, minor, patch, () + parts: list[int | str] = [] + for part in pre_raw.split("."): + if not part: + return None + if part.isdigit(): + parts.append(int(part)) + else: + parts.append(part) + return major, minor, patch, tuple(parts) + + +def _prerelease_less( + left: tuple[int | str, ...], right: tuple[int | str, ...] +) -> bool: + """Return True if left has lower SemVer precedence than right.""" + for i in range(max(len(left), len(right))): + if i >= len(left): + return True + if i >= len(right): + return False + a, b = left[i], right[i] + if a == b: + continue + if isinstance(a, int) and isinstance(b, int): + return a < b + if isinstance(a, int) != isinstance(b, int): + return isinstance(a, int) # numeric identifiers have lower precedence + return str(a) < str(b) + return False + + +def _semver_gte( + left: tuple[int, int, int, tuple[int | str, ...]], + right: tuple[int, int, int, tuple[int | str, ...]], +) -> bool: + """Return True if left has equal or higher SemVer precedence than right.""" + if left[:3] != right[:3]: + return left[:3] > right[:3] + left_pre, right_pre = left[3], right[3] + if not left_pre and not right_pre: + return True + if not left_pre: + return True # release > pre-release + if not right_pre: + return False # pre-release < release + return not _prerelease_less(left_pre, right_pre) + + +def compare_go_versions(version: str, fixed_version: str) -> str | None: + """Return 'patched', 'vulnerable', or None when versions cannot be parsed.""" + current = _parse_semver(version) + fixed = _parse_semver(fixed_version) + if current is None or fixed is None: + return None + if _semver_gte(current, fixed): + return "patched" + return "vulnerable" + + +def tool_module_verdict(manifest_line: str, resolved_version: str, + fixed_version: str | None) -> str: + """Classify a tool-only Go module using resolved and fixed versions.""" + if not manifest_line: + return "absent" + if fixed_version and resolved_version: + status = compare_go_versions(resolved_version, fixed_version) + if status == "patched": + return "absent" + if status == "vulnerable": + return "present_by_version" + if resolved_version: + return "present_by_version" + return "scan_failed" + + +def _is_tool_only_go_failure(output: str) -> bool: + lowered = output.lower() + return ( + "no packages matched the provided patterns" in lowered + or "build constraints exclude all go files" in lowered + or "govulncheck not applicable" in lowered + ) + + +def aggregate_verdict(verdicts: list[str]) -> str: + """Return the most severe verdict across module scans.""" + if not verdicts: + return "scan_failed" + return min(verdicts, key=lambda verdict: VERDICT_PRIORITY.get(verdict, 99)) + + +def scan_go_repository(repo_dir: Path, cve_id: str, package: str, + fixed_version: str | None = None) -> dict: + """Scan every Go module in a repository and aggregate the results.""" + modules = find_go_module_dirs(repo_dir) + if not modules: + return { + "language": "go", + "verdict": "scan_failed", + "scan_tool": "govulncheck", + "scan_exit_code": 1, + "scan_output": "No go.mod files found", + "toolchain_matched": None, + "target_go_version": None, + "manifest_match": "", + "modules_scanned": [], + } + + module_results: list[dict] = [] + for mod_dir in modules: + rel = mod_dir.resolve().relative_to(repo_dir.resolve()) + module_dir = "." if rel == Path(".") else str(rel) + manifest_line = check_single_go_manifest(mod_dir, package) + + scan_result = scan_go(mod_dir, cve_id, package) + if ( + not is_successful_scan(scan_result["scan_exit_code"], "govulncheck") + and _is_tool_only_go_failure(scan_result["scan_output"]) + ): + scan_result = scan_go_tool_module(mod_dir, package) + + if scan_result.get("scan_tool") == "go_list_m": + module_verdict = tool_module_verdict( + manifest_line, + scan_result.get("resolved_version") or "", + fixed_version, + ) + else: + module_verdict = determine_verdict( + "go", cve_id, package, scan_result, manifest_line, "", + ) + module_results.append({ + "module_dir": module_dir, + "manifest_path": _manifest_path(repo_dir, mod_dir), + "manifest_line": manifest_line, + "resolved_version": scan_result.get("resolved_version"), + "scan_tool": scan_result.get("scan_tool"), + "scan_exit_code": scan_result.get("scan_exit_code"), + "verdict": module_verdict, + "scan_output_summary": _extract_cve_context( + scan_result.get("scan_output", ""), cve_id, max_lines=10, + ), + }) + + all_manifests = check_all_go_manifests(repo_dir, package) + manifest_summary = "; ".join( + f"{match['manifest_path']}: {match['line']}" for match in all_manifests + ) + overall_verdict = aggregate_verdict([m["verdict"] for m in module_results]) + primary_idx = next( + i for i, m in enumerate(module_results) if m["verdict"] == overall_verdict + ) + primary = module_results[primary_idx] + combined_output = "\n\n".join( + f"[{m['module_dir']}] verdict={m['verdict']}\n{m['scan_output_summary']}" + for m in module_results + ) + + return { + "language": "go", + "verdict": overall_verdict, + "scan_tool": primary.get("scan_tool"), + "scan_exit_code": primary.get("scan_exit_code"), + "scan_output": combined_output, + "toolchain_matched": None, + "target_go_version": extract_go_version(modules[primary_idx]) or None, + "manifest_match": manifest_summary, + "modules_scanned": module_results, + } + + +def check_manifests(work_dir: Path, lang: str, package: str, + repo_dir: Path | None = None) -> str: """Check if the package appears in any manifest file (case-insensitive).""" + if lang == "go" and repo_dir is not None and work_dir.resolve() == repo_dir.resolve(): + matches = check_all_go_manifests(repo_dir, package) + if not matches: + return "" + return "; ".join(f"{match['manifest_path']}: {match['line']}" for match in matches) + manifests: dict[str, list[str]] = { "go": ["go.mod"], "node": ["package.json", "package-lock.json"], @@ -300,7 +628,7 @@ def determine_verdict(lang: str, cve_id: str, package: str, return "present" if manifest_match and not scanner_success: tool_crashed = exit_code != 0 and not is_successful_scan(exit_code, tool) - if tool_crashed: + if tool_crashed and not _is_tool_only_go_failure(output): return "scan_failed" return "present_by_version" if base_images and not manifest_match and not scanner_success: @@ -351,7 +679,7 @@ def check_manifest_command(args: list) -> int: print(f"Directory does not exist: {repo_dir}", file=sys.stderr) return 1 lang = detect_language(repo_dir) - match = check_manifests(repo_dir, lang, package) + match = check_manifests(repo_dir, lang, package, repo_dir=repo_dir) if not match: return 1 print(match) @@ -375,6 +703,9 @@ def main() -> int: " build_location Subdirectory within repo_dir to scan (default: .)\n" "\n" "Environment:\n" + " LANGUAGE Explicit ecosystem: go, node, or python\n" + " (skips auto-detection when set)\n" + " FIXED_VERSION Known fixed version for Go tool-module compares\n" " SCAN_TIMEOUT Seconds before scan times out (default: 300)\n" " OUTPUT_DIR Directory for JSON output (default: cwd)", file=sys.stderr, @@ -400,32 +731,55 @@ def main() -> int: _write_error(f"Directory does not exist: {work_dir}", cve_id, package) return 1 - lang = detect_language(work_dir) + try: + lang = resolve_language(work_dir) + except ValueError as exc: + _write_error(str(exc), cve_id, package) + return 1 - scanners = {"go": scan_go, "node": scan_node, "python": scan_python} - if lang in scanners: - scan_result = scanners[lang](work_dir, cve_id, package) - else: + modules_scanned: list[dict] = [] + if lang == "go" and work_dir.resolve() == repo_dir.resolve(): + fixed_version = os.environ.get("FIXED_VERSION", "").strip() or None + repo_scan = scan_go_repository(repo_dir, cve_id, package, fixed_version) scan_result = { - "scan_tool": "none", - "scan_exit_code": 1, - "scan_output": "Unknown project language — no supported manifest found", - "toolchain_matched": None, - "target_go_version": None, + "scan_tool": repo_scan["scan_tool"], + "scan_exit_code": repo_scan["scan_exit_code"], + "scan_output": repo_scan["scan_output"], + "toolchain_matched": repo_scan["toolchain_matched"], + "target_go_version": repo_scan["target_go_version"], } - - manifest_match = check_manifests(work_dir, lang, package) - base_images = check_base_images(work_dir) - verdict = determine_verdict( - lang, cve_id, package, scan_result, manifest_match, base_images, - ) - - output_summary = _extract_cve_context(scan_result["scan_output"], cve_id) - - vex = assess_vex( - verdict, lang, package, manifest_match, - scan_result["scan_output"], - ) + manifest_match = repo_scan["manifest_match"] + verdict = repo_scan["verdict"] + modules_scanned = repo_scan["modules_scanned"] + base_images = check_base_images(work_dir) + if verdict in ("absent", "scan_failed") and base_images and not manifest_match: + verdict = "in_base_image" + output_summary = _extract_cve_context(scan_result["scan_output"], cve_id) + vex = assess_vex( + verdict, lang, package, manifest_match, scan_result["scan_output"], + ) + else: + scanners = {"go": scan_go, "node": scan_node, "python": scan_python} + if lang in scanners: + scan_result = scanners[lang](work_dir, cve_id, package) + else: + scan_result = { + "scan_tool": "none", + "scan_exit_code": 1, + "scan_output": "Unknown project language — no supported manifest found", + "toolchain_matched": None, + "target_go_version": None, + } + + manifest_match = check_manifests(work_dir, lang, package, repo_dir=repo_dir) + base_images = check_base_images(work_dir) + verdict = determine_verdict( + lang, cve_id, package, scan_result, manifest_match, base_images, + ) + output_summary = _extract_cve_context(scan_result["scan_output"], cve_id) + vex = assess_vex( + verdict, lang, package, manifest_match, scan_result["scan_output"], + ) result = { "cve_id": cve_id, @@ -442,6 +796,8 @@ def main() -> int: "vex": vex, "timestamp": timestamp(), } + if modules_scanned: + result["modules_scanned"] = modules_scanned output_dir = Path(os.environ.get("OUTPUT_DIR", ".")) write_json(result, output_dir, "scan-result.json") diff --git a/cve-fix/scripts/test_scan.py b/cve-fix/scripts/test_scan.py new file mode 100644 index 0000000..cee93fe --- /dev/null +++ b/cve-fix/scripts/test_scan.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Unit tests for multi-module helpers in scan.py.""" + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scan import ( + aggregate_verdict, + check_all_go_manifests, + compare_go_versions, + detect_language, + find_go_module_dirs, + resolve_language, + tool_module_verdict, +) + + +class ScanHelpersTest(unittest.TestCase): + def test_compare_go_versions(self) -> None: + self.assertEqual(compare_go_versions("v0.53.0", "v0.52.0"), "patched") + self.assertEqual(compare_go_versions("v0.43.0", "v0.52.0"), "vulnerable") + self.assertIsNone(compare_go_versions("invalid", "v0.52.0")) + self.assertIsNone(compare_go_versions("v0.52.0junk", "v0.52.0")) + + def test_compare_go_versions_prerelease(self) -> None: + # Pre-release of the fixed X.Y.Z is still vulnerable. + self.assertEqual( + compare_go_versions("v0.52.0-rc.1", "v0.52.0"), "vulnerable" + ) + # Release is patched relative to a pre-release fixed version. + self.assertEqual( + compare_go_versions("v0.52.0", "v0.52.0-rc.1"), "patched" + ) + # Higher/lower pre-release identifiers. + self.assertEqual( + compare_go_versions("v0.52.0-rc.2", "v0.52.0-rc.1"), "patched" + ) + self.assertEqual( + compare_go_versions("v0.52.0-rc.1", "v0.52.0-rc.2"), "vulnerable" + ) + self.assertEqual( + compare_go_versions("v0.52.0-alpha", "v0.52.0-beta"), "vulnerable" + ) + # Core version still dominates pre-release. + self.assertEqual( + compare_go_versions("v0.53.0-rc.1", "v0.52.0"), "patched" + ) + # Equal versions (incl. build metadata) count as patched. + self.assertEqual( + compare_go_versions("v0.52.0-rc.1", "v0.52.0-rc.1"), "patched" + ) + self.assertEqual( + compare_go_versions("v0.52.0+incompatible", "v0.52.0"), "patched" + ) + + def test_tool_module_verdict_with_fixed_version(self) -> None: + line = "golang.org/x/crypto v0.43.0 // indirect" + self.assertEqual( + tool_module_verdict(line, "v0.43.0", "v0.52.0"), + "present_by_version", + ) + self.assertEqual( + tool_module_verdict(line, "v0.53.0", "v0.52.0"), + "absent", + ) + + def test_aggregate_verdict_prefers_vulnerable_module(self) -> None: + self.assertEqual( + aggregate_verdict(["absent", "present_by_version"]), + "present_by_version", + ) + self.assertEqual( + aggregate_verdict(["absent", "present"]), + "present", + ) + + def test_detect_language_nested_go_only(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + tools = repo / "tools" + tools.mkdir() + (tools / "go.mod").write_text( + "module example.com/tools\n\ngo 1.25.0\n" + ) + self.assertEqual(detect_language(repo), "go") + self.assertEqual(find_go_module_dirs(repo), [tools]) + + def test_detect_language_polyglot_root_node_wins(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / "package.json").write_text('{"name": "ui"}\n') + proxy = repo / "proxy" + proxy.mkdir() + (proxy / "go.mod").write_text( + "module example.com/proxy\n\ngo 1.25.0\n" + ) + self.assertEqual(detect_language(repo), "node") + self.assertEqual(find_go_module_dirs(repo), [proxy]) + + def test_resolve_language_explicit_override(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / "package.json").write_text('{"name": "ui"}\n') + proxy = repo / "proxy" + proxy.mkdir() + (proxy / "go.mod").write_text( + "module example.com/proxy\n\ngo 1.25.0\n" + ) + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("LANGUAGE", None) + self.assertEqual(resolve_language(repo), "node") + with mock.patch.dict(os.environ, {"LANGUAGE": "go"}): + self.assertEqual(resolve_language(repo), "go") + with mock.patch.dict(os.environ, {"LANGUAGE": "node"}): + self.assertEqual(resolve_language(repo), "node") + with mock.patch.dict(os.environ, {"LANGUAGE": "Go"}): + self.assertEqual(resolve_language(repo), "go") + + def test_resolve_language_invalid(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / "package.json").write_text('{"name": "ui"}\n') + with mock.patch.dict(os.environ, {"LANGUAGE": "rust"}): + with self.assertRaises(ValueError) as ctx: + resolve_language(repo) + self.assertIn("Invalid LANGUAGE", str(ctx.exception)) + + def test_find_and_check_all_go_manifests(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + (repo / "go.mod").write_text( + "module example.com/root\n\ngo 1.25.0\n\n" + "require golang.org/x/crypto v0.53.0\n" + ) + tools = repo / "tools" + tools.mkdir() + (tools / "go.mod").write_text( + "module example.com/tools\n\ngo 1.25.0\n\n" + "require golang.org/x/crypto v0.43.0 // indirect\n" + ) + # Add nested modules in ignored directories. + for ignored_dir in (repo / ".git" / "nested", repo / "vendor" / "nested"): + ignored_dir.mkdir(parents=True) + (ignored_dir / "go.mod").write_text( + "module example.com/ignored\n\n" + "require golang.org/x/crypto v0.43.0\n" + ) + + modules = find_go_module_dirs(repo) + self.assertEqual(len(modules), 2) + self.assertEqual(modules[0], repo) + self.assertNotIn(repo / ".git" / "nested", modules) + self.assertNotIn(repo / "vendor" / "nested", modules) + + matches = check_all_go_manifests(repo, "golang.org/x/crypto") + self.assertEqual(len(matches), 2) + self.assertEqual(matches[0]["manifest_path"], "go.mod") + self.assertEqual(matches[1]["manifest_path"], "tools/go.mod") + paths = [m["manifest_path"] for m in matches] + self.assertNotIn(".git/nested/go.mod", paths) + self.assertNotIn("vendor/nested/go.mod", paths) + + def test_find_go_module_dirs_skips_nested_vendor_not_ancestor(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + # Repo checked out under a parent named "vendor" must still be scanned. + vendor_parent = Path(tmp) / "vendor" / "myproject" + vendor_parent.mkdir(parents=True) + (vendor_parent / "go.mod").write_text( + "module example.com/root\n\ngo 1.25.0\n" + ) + nested_vendor = vendor_parent / "vendor" / "pkg" + nested_vendor.mkdir(parents=True) + (nested_vendor / "go.mod").write_text( + "module example.com/vendored\n\ngo 1.25.0\n" + ) + tools = vendor_parent / "tools" + tools.mkdir() + (tools / "go.mod").write_text( + "module example.com/tools\n\ngo 1.25.0\n" + ) + + modules = find_go_module_dirs(vendor_parent) + self.assertEqual(len(modules), 2) + self.assertEqual(modules[0], vendor_parent) + self.assertEqual(modules[1], tools) + + +if __name__ == "__main__": + unittest.main() diff --git a/cve-fix/skills/scan.md b/cve-fix/skills/scan.md index 2df950c..e79d68d 100644 --- a/cve-fix/skills/scan.md +++ b/cve-fix/skills/scan.md @@ -21,17 +21,65 @@ If `context.md` is missing, tell the user to run `/start` first. ### Step 1: Read Context -Read `context.md` for the CVE ID, affected package, detected ecosystem, and +Read `context.md` for the CVE ID, affected package, detected ecosystem(s), and build location. These are inputs to the scanner. +Map the **primary** ecosystem for this CVE to a scanner language: + +- Go → `go` +- Node.js → `node` +- Python → `python` + +When multiple ecosystems are listed, use the primary one (the ecosystem that +matches the affected package / ticket). Prefer this explicit value over +auto-detection so polyglot repos (e.g. root `package.json` plus `module/go.mod`) +scan the correct modules. + ### Step 2: Run the Vulnerability Scan Run `../scripts/scan.py` if available: ```bash -OUTPUT_DIR=.artifacts/cve-fix/{context} python3 scripts/scan.py {repo_dir} {CVE_ID} {package} {build_location} +OUTPUT_DIR=.artifacts/cve-fix/{context} \ +LANGUAGE={language} \ +python3 ../scripts/scan.py {repo_dir} {CVE_ID} {package} {build_location} +``` + +When a fixed version is known from `context.md` (for example `0.52.0`), +also set `FIXED_VERSION` so secondary Go modules (such as `tools/go.mod`) +can be compared by version: + +```bash +OUTPUT_DIR=.artifacts/cve-fix/{context} \ +LANGUAGE={language} \ +FIXED_VERSION={fixed_version} \ +python3 ../scripts/scan.py {repo_dir} {CVE_ID} {package} {build_location} ``` +Omit `FIXED_VERSION` (or leave it empty) when no fixed version is known. +Omit `LANGUAGE` only when context has no usable ecosystem — the script then +falls back to auto-detection. + +**Go repositories with multiple `go.mod` files:** Many Go projects keep a +root `go.mod` for shipped binaries and additional modules for dev tooling +(for example `tools/go.mod`, `test/scripts/go.mod`). When `build_location` +is `.` (the default), `scan.py` scans **every** `go.mod` in the repository, +not just the root module — excluding paths under `.git`, `vendor`, and +`node_modules`. The JSON output includes a `modules_scanned` array with +per-module verdicts and resolved versions. + +Typical layout: + +| Module | Purpose | Scanner | +|--------|---------|---------| +| `go.mod` | Application/runtime binaries | `govulncheck ./...` | +| `tools/go.mod` | Dev tools (`mockgen`, codegen, etc.) | `go list -m` + version compare | +| Other nested `go.mod` | Auxiliary tooling or subprojects | Same as tools when source-less | + +The overall verdict is the **most severe** result across all modules. A patched +root `go.mod` does **not** clear the scan when `tools/go.mod` still pins a +vulnerable version. + If the script is not available, run the scan commands inline based on ecosystem: | Ecosystem | Scan Command | Notes | @@ -52,7 +100,9 @@ exit is a real failure. If the scanner is not installed, fall back to version-based detection: check the dependency manifest for the package and compare the installed -version against the known fixed version from `context.md`. +version against the known fixed version from `context.md`. For Go projects, +run `go list -m {package}` in **each** module directory that contains a +matching `go.mod` line, not only the repository root. ### Step 3: Interpret the Verdict @@ -68,6 +118,9 @@ version against the known fixed version from `context.md`. Use judgment to validate the verdict: - If `present_by_version`: compare the manifest version against the CVE's affected version range to confirm it is actually vulnerable +- If `modules_scanned` is present: review each module independently. A VEX + closure is only appropriate when **all** modules are patched, unaffected, + or informational (vulnerable symbols not reachable) - If `in_base_image`: check whether a newer base image tag is available using `skopeo list-tags`, or note that the base image team needs to act - If `scan_failed`: check the output summary for the root cause and decide @@ -113,6 +166,12 @@ Write `.artifacts/cve-fix/{context}/scan-results.md`: {interpretation of what the verdict means for this specific case} +## Per-Module Results (Go multi-module repos) + +| Module | Manifest | Resolved Version | Verdict | +|--------|----------|------------------|---------| +| {module_dir} | {manifest_path} | {resolved_version} | {verdict} | + ## VEX Justification (if applicable) - Type: {justification_label} - Evidence: {evidence} diff --git a/cve-fix/skills/start.md b/cve-fix/skills/start.md index 2434050..778a8fe 100644 --- a/cve-fix/skills/start.md +++ b/cve-fix/skills/start.md @@ -86,6 +86,7 @@ Search for manifest files to identify the project's ecosystem(s). A project may | Manifest File | Ecosystem | Package Manager | |---|---|---| | `go.mod` | Go | `go` | +| Nested `go.mod` (e.g. `tools/go.mod`) | Go (secondary module) | `go` | | `package.json` | Node.js | `npm` / `yarn` / `pnpm` | | `requirements.txt` / `pyproject.toml` / `setup.py` / `setup.cfg` | Python | `pip` / `poetry` / `uv` | | `pom.xml` | Java (Maven) | `mvn` | @@ -105,6 +106,16 @@ For Python, check for: If no manifest files are found, stop and report: the project may not use a supported ecosystem, or the working directory may be wrong. +For Go projects, list every `go.mod` discovered in the repository (for example +`go.mod`, `tools/go.mod`, `proxy/go.mod`), excluding paths under `.git`, `vendor`, +and `node_modules`, and note which module is runtime vs dev-tooling. Record +nested Go modules even when the repository root is Node or Python (polyglot). + +When the repo is polyglot, list **all** ecosystems under Detected Ecosystem(s), +and mark the **primary** ecosystem for this CVE — the one that matches the +affected package from the ticket. `/scan` passes that primary value as +`LANGUAGE` so the scanner does not guess the wrong half of the repo. + ### Step 5: Read Project Build and Test Instructions Read the project's AI-friendly files (`CLAUDE.md`, `AGENTS.md`, `README.md`, @@ -148,7 +159,8 @@ Write `.artifacts/cve-fix/{context}/context.md` with: - {any hints about fix approach from related work} ## Detected Ecosystem(s) -- {ecosystem}: {package_manager} ({manifest_file}) +- {ecosystem}: {package_manager} ({manifest_file}) [primary] +- {ecosystem}: {package_manager} ({manifest_file}) (additional, if polyglot) ## Build and Test Commands - Build: {build_command} (from {source_file}) @@ -157,6 +169,7 @@ Write `.artifacts/cve-fix/{context}/context.md` with: ## When This Phase Is Done -Report the vulnerability details (from Jira research), detected ecosystem, -and any insights about the fix approach gathered from linked tickets/PRs. +Report the vulnerability details (from Jira research), detected ecosystem(s) +(and which is primary for this CVE), and any insights about the fix approach +gathered from linked tickets/PRs. Then re-read `controller.md` for next-step guidance.