diff --git a/cve-fix/SKILL.md b/cve-fix/SKILL.md index a330d98..f4022ef 100644 --- a/cve-fix/SKILL.md +++ b/cve-fix/SKILL.md @@ -1,6 +1,6 @@ --- name: cve-fix -version: 0.3.1 +version: 0.4.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..c7a7771 100755 --- a/cve-fix/scripts/scan.py +++ b/cve-fix/scripts/scan.py @@ -9,10 +9,16 @@ scan.py check-manifest Output: JSON written to OUTPUT_DIR/scan-result.json (also printed to stdout). +For repository-root Go scans, the JSON may include a `modules_scanned` array +with per-module verdicts; that field is omitted when no modules were scanned. +`FIXED_VERSION` (env) applies only to repository-root Go scans and is used +to compare tool-only modules that govulncheck cannot analyze. The `check-manifest` subcommand runs just the manifest-match check (auto-detects ecosystem, prints the matching manifest line and exits 0, or prints nothing and exits 1) without doing a full vulnerability scan. +For Go repository roots, matching modules are printed as +`: ` entries joined by `; `. Supports Go (govulncheck with GOTOOLCHAIN), Node.js (npm audit), Python (pip-audit). For Go projects, GOTOOLCHAIN forces the exact @@ -39,11 +45,25 @@ write_json, ) +GO_MOD_SKIP_DIRS = {".git", "vendor", "node_modules"} + +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.""" if (work_dir / "go.mod").is_file(): return "go" + # Nested-only layouts (e.g. tools/go.mod with no root module) + if find_go_module_dirs(work_dir): + return "go" if (work_dir / "package.json").is_file(): return "node" for manifest in ("requirements.txt", "pyproject.toml", "setup.py"): @@ -162,8 +182,323 @@ 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. + + Matches the package as a full module-path token on require entries only. + Skips the module declaration and other directives so prefix collisions + (e.g. golang.org/x/crypto vs golang.org/x/cryptobyte) cannot match. + """ + gomod = mod_dir / "go.mod" + if not gomod.is_file(): + return "" + try: + content = gomod.read_text() + except OSError: + return "" + pkg_lower = package.lower() + skip_directives = { + "module", "go", "toolchain", "replace", "exclude", "retract", "use", + } + for line in content.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("//"): + continue + code = stripped.split("//", 1)[0].strip() + if not code or code in ("(", ")"): + continue + fields = code.split() + if fields[0] in skip_directives: + continue + if fields[0] == "require": + if len(fields) < 2 or fields[1] == "(": + continue + path = fields[1] + else: + path = fields[0] + if path.lower() == pkg_lower: + return stripped + 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, + module_label: str | None = None) -> dict: + """Scan a tool-only Go module where govulncheck has no packages to analyze. + + module_label is the repository-relative module directory (e.g. "tools") + written into scan output. Absolute filesystem paths must not appear in + scan artifacts. + """ + manifest_line = check_single_go_manifest(mod_dir, package) + version, err = resolve_go_package_version(mod_dir, package) + label = module_label if module_label is not None else "." + output_parts = [ + f"Module: {label}", + 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, module_dir) + + 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"), + "toolchain_matched": scan_result.get("toolchain_matched"), + "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": primary.get("toolchain_matched"), + "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 +635,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: @@ -331,7 +666,11 @@ def _write_error(message: str, cve_id: str, package: str) -> None: def check_manifest_command(args: list) -> int: """Standalone `check-manifest` subcommand: print the matching manifest - line for a package and exit 0, or print nothing and exit 1.""" + line for a package and exit 0, or print nothing and exit 1. + + For Go repository roots, a match may include multiple path-prefixed + entries joined by "; " (e.g. "go.mod: require ...; tools/go.mod: ..."). + """ if len(args) != 2 or args[0] == "--help": print( "Usage: scan.py check-manifest \n" @@ -341,6 +680,8 @@ def check_manifest_command(args: list) -> int: "requirements.txt, etc.).\n" "\n" "Prints the matching manifest line and exits 0 on a match.\n" + "For Go repository roots, prints every matching module as\n" + "': ' joined by '; '.\n" "Prints nothing and exits 1 if there is no match.", file=sys.stderr, ) @@ -351,7 +692,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) @@ -376,6 +717,9 @@ def main() -> int: "\n" "Environment:\n" " SCAN_TIMEOUT Seconds before scan times out (default: 300)\n" + " FIXED_VERSION Version that fixes the CVE (e.g., v0.52.0).\n" + " Used for Go tool-only modules that govulncheck\n" + " cannot analyze (repository-root Go scans only).\n" " OUTPUT_DIR Directory for JSON output (default: cwd)", file=sys.stderr, ) @@ -402,30 +746,49 @@ def main() -> int: lang = detect_language(work_dir) - 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 +805,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..4f2e2b5 --- /dev/null +++ b/cve-fix/scripts/test_scan.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Unit tests for multi-module helpers in scan.py.""" + +import tempfile +import unittest +from pathlib import Path + +from scan import ( + aggregate_verdict, + check_all_go_manifests, + check_single_go_manifest, + compare_go_versions, + detect_language, + find_go_module_dirs, + 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("", "", "v0.52.0"), + "absent", + ) + 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", + ) + self.assertEqual( + tool_module_verdict(line, "v0.43.0", None), + "present_by_version", + ) + self.assertEqual( + tool_module_verdict(line, "", "v0.52.0"), + "scan_failed", + ) + + def test_aggregate_verdict_prefers_vulnerable_module(self) -> None: + self.assertEqual(aggregate_verdict([]), "scan_failed") + self.assertEqual( + aggregate_verdict(["absent", "present_by_version"]), + "present_by_version", + ) + self.assertEqual( + aggregate_verdict(["absent", "present"]), + "present", + ) + + def test_check_single_go_manifest_exact_path(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + mod = Path(tmp) + (mod / "go.mod").write_text( + "module example.com/root\n\n" + "require (\n" + "\tgolang.org/x/cryptobyte v0.1.0\n" + "\tgolang.org/x/crypto v0.43.0 // indirect\n" + ")\n" + ) + self.assertEqual( + check_single_go_manifest(mod, "golang.org/x/crypto"), + "golang.org/x/crypto v0.43.0 // indirect", + ) + self.assertEqual( + check_single_go_manifest(mod, "golang.org/x/cryptobyte"), + "golang.org/x/cryptobyte v0.1.0", + ) + self.assertEqual( + check_single_go_manifest(mod, "example.com/root"), + "", + ) + + 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_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/report.md b/cve-fix/skills/report.md index 2d51793..0a81ac7 100644 --- a/cve-fix/skills/report.md +++ b/cve-fix/skills/report.md @@ -290,15 +290,17 @@ via the standalone subcommand: python3 ../scripts/scan.py check-manifest ``` -This auto-detects the ecosystem and runs a case-insensitive substring match -of the package name against the manifest files for that ecosystem -(`go.mod` for Go; `package.json` + `package-lock.json` for Node.js; -`requirements.txt` + `pyproject.toml`/`setup.py`/`setup.cfg` for Python; the -equivalent for other ecosystems). It prints the matching manifest line and -exits 0 on a match, or prints nothing and exits 1 otherwise — treat exit 1 -as "no match" below. If the script isn't available, run the equivalent -inline: grep the relevant manifest file(s) for the package name, -case-insensitively. +This auto-detects the ecosystem and checks whether the package appears in +the manifest files for that ecosystem (`go.mod` for Go — matching the module +path as a full require token; `package.json` + `package-lock.json` for +Node.js; `requirements.txt` + `pyproject.toml`/`setup.py`/`setup.cfg` for +Python; the equivalent for other ecosystems). It prints the matching +manifest line and exits 0 on a match, or prints nothing and exits 1 +otherwise — treat exit 1 as "no match" below. For Go repository roots, a +match may list every hit as `: ` joined by +semicolon-space (for example `go.mod: require ...; tools/go.mod: ...`). +If the script isn't available, run the equivalent inline: grep the relevant +manifest file(s) for the package name, case-insensitively. Classify each ticket: diff --git a/cve-fix/skills/scan.md b/cve-fix/skills/scan.md index 2df950c..93ea530 100644 --- a/cve-fix/skills/scan.md +++ b/cve-fix/skills/scan.md @@ -26,12 +26,54 @@ build location. These are inputs to the scanner. ### Step 2: Run the Vulnerability Scan -Run `../scripts/scan.py` if available: +Run `../scripts/scan.py` **exactly once** when available. Choose one of these +mutually exclusive forms: + +**Without a fixed version** (default — when `context.md` has no known fix): ```bash -OUTPUT_DIR=.artifacts/cve-fix/{context} python3 scripts/scan.py {repo_dir} {CVE_ID} {package} {build_location} +OUTPUT_DIR=.artifacts/cve-fix/{context} \ +python3 ../scripts/scan.py {repo_dir} {CVE_ID} {package} {build_location} ``` +**With a fixed version** — required for secondary Go module comparisons +(such as `tools/go.mod`) when `context.md` provides one (for example +`0.52.0`): + +```bash +OUTPUT_DIR=.artifacts/cve-fix/{context} \ +FIXED_VERSION={fixed_version} \ +python3 ../scripts/scan.py {repo_dir} {CVE_ID} {package} {build_location} +``` + +`FIXED_VERSION` applies only to repository-root Go scans (multi-module / +tool-only version compare). Do not set it for non-Go ecosystems or when +scanning a non-root `build_location`. + +**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`. When modules are scanned, the JSON output includes a +`modules_scanned` array with per-module verdicts and resolved versions; +the field is omitted when no modules were scanned. + +Each module scan uses the full `SCAN_TIMEOUT` budget (default 300s). Total +wall-clock time therefore scales with the number of modules. + +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 +94,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 +112,10 @@ 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 **every** module verdict is `absent` or + `informational`. Any module with `present`, `present_by_version`, + `in_base_image`, or `scan_failed` blocks VEX closure - 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 +161,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..be97b3e 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,11 @@ 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`), excluding paths under `.git`, `vendor`, and +`node_modules`, and note which module is runtime vs dev-tooling. The `/scan` +phase checks all of them. + ### Step 5: Read Project Build and Test Instructions Read the project's AI-friendly files (`CLAUDE.md`, `AGENTS.md`, `README.md`,