"""collect-findings.py — produce a audit-code manifest.json.""" from __future__ import annotations import argparse import glob import json import subprocess import sys from collections import Counter from pathlib import Path _HERE = Path(__file__).resolve().parent if str(_HERE.parent) not in sys.path: sys.path.insert(0, str(_HERE.parent)) from scripts.adapters import ( bandit as ad_bandit, ruff as ad_ruff, ruff_idiom as ad_ruff_idiom, mypy as ad_mypy, eslint as ad_eslint, tsc as ad_tsc, opengrep as ad_opengrep, gitleaks as ad_gitleaks, pip_audit as ad_pip_audit, osv_scanner as ad_osv, dotnet as ad_dotnet, vulture as ad_vulture, radon as ad_radon, interrogate as ad_interrogate, lizard as ad_lizard, knip as ad_knip, jscpd as ad_jscpd, selene as ad_selene, luac as ad_luac, psscriptanalyzer as ad_pssa, actionlint as ad_actionlint, zizmor as ad_zizmor, ) from scripts.diff_filter import filter_findings_by_diff from scripts.git_diff import ( changed_files_with_ranges, resolve_base_ref, resolve_default_branch, ) from scripts.language_detect import ( detect_language, is_dep_manifest, is_gha_file, is_supported_source, known_unsupported_language, ) from scripts.manifest import ( ChangedFile, Finding, LanguageBreakdown, Manifest, PackageDiff, ToolStat, ) from scripts.package_diff import diff_package_json, diff_requirements_txt from scripts.runner import run_tool, tool_available from scripts.slicing import slice_for_agent # Anything not listed here is installed by the skill's own bootstrap script. _INSTALL_TOOLS = f"bash {_HERE / 'install-tools.sh'} --user-only" _INSTALL_COMMANDS = { "eslint": "npm i -D eslint eslint-plugin-security", "tsc": "npm i -D typescript", "knip": "npm i -D knip", "jscpd": "npm i -D jscpd", "dotnet": "curl -fsSL https://dot.net/v1/dotnet-install.sh | bash", "selene": "cargo install selene", "luac": "install lua (ships luac) with your OS package manager", "actionlint": "go install github.com/rhysd/actionlint/cmd/actionlint@latest", "zizmor": "uv tool install zizmor", "gitleaks": "go install github.com/zricethezav/gitleaks/v8@latest", "osv-scanner": "go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest", } def _git_show(repo: str, ref: str, path: str) -> str: """Return file content at `ref`, or empty string if not present (e.g. new file).""" r = subprocess.run( ["git", "-C", repo, "show", f"{ref}:{path}"], capture_output=True, text=True, check=False, ) return r.stdout if r.returncode == 0 else "" def _build_package_diffs( repo: str, base: str, dep_manifest_paths: list[str], ) -> dict[str, PackageDiff]: diffs: dict[str, PackageDiff] = { "python": PackageDiff(), "javascript": PackageDiff(), "csharp": PackageDiff(), } for path in dep_manifest_paths: name = Path(path).name before = _git_show(repo, base, path) after_path = Path(repo) / path after = after_path.read_text() if after_path.is_file() else "" if name.endswith(".txt") and "requirements" in name: pd = diff_requirements_txt(before, after) _merge_package_diff(diffs["python"], pd) elif name == "package.json": pd = diff_package_json(before, after) _merge_package_diff(diffs["javascript"], pd) return diffs def _merge_package_diff(into: PackageDiff, src: PackageDiff) -> None: into.added.extend(src.added) into.removed.extend(src.removed) into.upgraded.extend(src.upgraded) def _build_breakdown(changed_files: list[ChangedFile], skipped: list[str]) -> LanguageBreakdown: n = Counter(cf.language for cf in changed_files) return LanguageBreakdown( python=n["python"], javascript=n["javascript"], typescript=n["typescript"], csharp=n["csharp"], lua=n["lua"], powershell=n["powershell"], github_actions=n["github-actions"], skipped_files=skipped, ) def _run_python_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} for binary, args, adapter in [ ("bandit", ["bandit", "-r", ".", "-f", "json", "-q"], ad_bandit.parse_bandit_output), ("ruff", ["ruff", "check", "--output-format=json", "."], ad_ruff.parse_ruff_output), ("ruff-idiom", [ "ruff", "check", "--select", "SIM,PERF,UP,RET,PLR,C90,B", "--output-format=json", "--isolated", ".", ], ad_ruff_idiom.parse_ruff_idiom_output), ("mypy", ["mypy", "."], ad_mypy.parse_mypy_output), ]: if not tool_available(binary): stats[binary] = ToolStat(ran=False, reason="not on PATH") continue r = run_tool(args, cwd=repo) parsed = adapter(r.stdout) stats[binary] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) return findings, stats def _run_js_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if tool_available("eslint"): r = run_tool(["eslint", ".", "-f", "json"], cwd=repo) parsed = ad_eslint.parse_eslint_output(r.stdout, repo_root=repo) stats["eslint"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["eslint"] = ToolStat(ran=False, reason="not on PATH") if tool_available("tsc"): r = run_tool(["tsc", "--noEmit"], cwd=repo) parsed = ad_tsc.parse_tsc_output(r.stdout) stats["tsc"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["tsc"] = ToolStat(ran=False, reason="not on PATH") return findings, stats def _run_dotnet_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if tool_available("dotnet"): r = run_tool(["dotnet", "build", "--no-incremental"], cwd=repo) parsed = ad_dotnet.parse_dotnet_build_output(r.stdout, repo_root=repo) stats["dotnet"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["dotnet"] = ToolStat(ran=False, reason="not on PATH") return findings, stats def _run_lua_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if tool_available("selene"): r = run_tool(["selene", "--display-style=json", "."], cwd=repo) parsed = ad_selene.parse_selene_output(r.stdout) stats["selene"] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) else: stats["selene"] = ToolStat(ran=False, reason="not on PATH") if tool_available("luac"): lua_files = sorted(glob.glob(f"{repo}/**/*.lua", recursive=True)) combined_stderr = "" for lua_file in lua_files: r = run_tool(["luac", "-p", lua_file], cwd=repo) if r.stderr: combined_stderr += r.stderr parsed = ad_luac.parse_luac_output(combined_stderr, repo_root=repo) stats["luac"] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) else: stats["luac"] = ToolStat(ran=False, reason="not on PATH") return findings, stats _PSSA_PROJECT = ( " | Select-Object RuleName," "@{n='Severity';e={$_.Severity.ToString()}}," "ScriptPath," "@{n='Line';e={$_.Extent.StartLineNumber}}," "@{n='EndLine';e={$_.Extent.EndLineNumber}}," "Message | ConvertTo-Json -Depth 3 -AsArray" ) _PSSA_COMMAND = "Invoke-ScriptAnalyzer -Path . -Recurse -ErrorAction SilentlyContinue" _INJECTION_HUNTER_COMMAND = ( "$m = (Get-Module -ListAvailable -Name InjectionHunter | Select-Object -First 1).Path; " "Invoke-ScriptAnalyzer -Path . -Recurse -CustomRulePath $m -ErrorAction SilentlyContinue" ) def _pwsh_module_available(repo: str, module: str) -> bool: r = run_tool( ["pwsh", "-NoProfile", "-NonInteractive", "-Command", f"if (Get-Module -ListAvailable -Name {module}) {{ exit 0 }} else {{ exit 1 }}"], cwd=repo, ) return r.ran and r.exit_code == 0 def _run_powershell_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if not tool_available("pwsh"): for name in ("psscriptanalyzer", "injectionhunter"): stats[name] = ToolStat(ran=False, reason="pwsh not on PATH") return findings, stats for tool, module, command in [ ("psscriptanalyzer", "PSScriptAnalyzer", _PSSA_COMMAND), ("injectionhunter", "InjectionHunter", _INJECTION_HUNTER_COMMAND), ]: if not _pwsh_module_available(repo, module): stats[tool] = ToolStat(ran=False, reason=f"{module} module not installed") continue r = run_tool( ["pwsh", "-NoProfile", "-NonInteractive", "-Command", command + _PSSA_PROJECT], cwd=repo, ) parsed = ad_pssa.parse_psscriptanalyzer_output(r.stdout, repo_root=repo, tool=tool) stats[tool] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) return findings, stats def _run_gha_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if tool_available("actionlint"): r = run_tool(["actionlint", "-format", "{{json .}}", ".github/"], cwd=repo) parsed = ad_actionlint.parse_actionlint_output(r.stdout) stats["actionlint"] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) else: stats["actionlint"] = ToolStat(ran=False, reason="not on PATH") if tool_available("zizmor"): r = run_tool(["zizmor", "--format", "sarif", ".github/"], cwd=repo) parsed = ad_zizmor.parse_zizmor_output(r.stdout) stats["zizmor"] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) else: stats["zizmor"] = ToolStat(ran=False, reason="not on PATH") return findings, stats def _run_polyglot_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if tool_available("opengrep"): lua_rules = _HERE / "rules" / "lua-security.yaml" r = run_tool( ["opengrep", "--config=auto", f"--config={lua_rules}", "--json", "--quiet"], cwd=repo, ) parsed = ad_opengrep.parse_opengrep_output(r.stdout) stats["opengrep"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["opengrep"] = ToolStat(ran=False, reason="not on PATH") if tool_available("gitleaks"): r = run_tool(["gitleaks", "detect", "--report-format=json", "--report-path=/dev/stdout", "--no-banner"], cwd=repo) parsed = ad_gitleaks.parse_gitleaks_output(r.stdout) stats["gitleaks"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["gitleaks"] = ToolStat(ran=False, reason="not on PATH") return findings, stats def _run_maintainability_tools( repo: str, languages_present: set[str] ) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} is_python = "python" in languages_present is_js = "javascript" in languages_present or "typescript" in languages_present for binary, args, adapter, should_run in [ ("vulture", ["vulture", "."], ad_vulture.parse_vulture_output, is_python), ("radon", ["radon", "cc", "-j", "."], ad_radon.parse_radon_output, is_python), ("interrogate", ["interrogate", "--quiet", "--output-format=json", "."], ad_interrogate.parse_interrogate_output, is_python), ("lizard", ["lizard", "--csv", "."], ad_lizard.parse_lizard_output, True), ("npx", ["npx", "knip", "--reporter", "json"], ad_knip.parse_knip_output, is_js), ("jscpd", ["jscpd", "--reporters", "json", "--silent", "."], ad_jscpd.parse_jscpd_output, is_js), ]: tool_name = "knip" if binary == "npx" else binary if not should_run: continue if not tool_available(binary): stats[tool_name] = ToolStat(ran=False, reason="not on PATH") continue r = run_tool(args, cwd=repo) parsed = adapter(r.stdout) stats[tool_name] = ToolStat(ran=True, pre_filter=len(parsed), post_filter=0) findings.extend(parsed) return findings, stats def _run_dep_tools(repo: str) -> tuple[list[Finding], dict[str, ToolStat]]: findings: list[Finding] = [] stats: dict[str, ToolStat] = {} if tool_available("pip-audit"): r = run_tool(["pip-audit", "-f", "json"], cwd=repo) parsed = ad_pip_audit.parse_pip_audit_output(r.stdout) stats["pip-audit"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["pip-audit"] = ToolStat(ran=False, reason="not on PATH") if tool_available("osv-scanner"): r = run_tool(["osv-scanner", "--recursive", "--format=json", "."], cwd=repo) parsed = ad_osv.parse_osv_scanner_output(r.stdout) stats["osv-scanner"] = ToolStat(ran=True, pre_filter=len(parsed)) findings.extend(parsed) else: stats["osv-scanner"] = ToolStat(ran=False, reason="not on PATH") return findings, stats def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--repo", required=True) parser.add_argument("--base", default=None) parser.add_argument("--head", default="HEAD") parser.add_argument("--output-dir", required=True) parser.add_argument("--mode", choices=("local", "ref"), required=True) args = parser.parse_args(argv) repo = str(Path(args.repo).resolve()) out_dir = Path(args.output_dir).resolve() out_dir.mkdir(parents=True, exist_ok=True) default_branch = resolve_default_branch(repo) base = args.base or resolve_base_ref(repo, default_branch) errors: list[str] = [] try: changed = changed_files_with_ranges(repo, base, args.head) except RuntimeError as e: manifest = Manifest( mode=args.mode, base_ref=base, head_ref=args.head, default_branch=default_branch, language_breakdown=LanguageBreakdown(), changed_files=[], findings=[], package_diffs={}, tool_stats={}, tools_unavailable={}, errors=[str(e)], ) (out_dir / "manifest.json").write_text(manifest.to_json()) return 1 changed_files: list[ChangedFile] = [] skipped: list[str] = [] dep_manifest_paths: list[str] = [] unsupported_by_language: dict[str, list[str]] = {} for path, ranges in changed: if is_supported_source(path): changed_files.append(ChangedFile( path=path, language=detect_language(path), added_lines=ranges, )) continue if is_gha_file(path): changed_files.append(ChangedFile( path=path, language="github-actions", added_lines=ranges, )) continue if is_dep_manifest(path): dep_manifest_paths.append(path) continue unsupported = known_unsupported_language(path) if unsupported is not None: unsupported_by_language.setdefault(unsupported, []).append(path) skipped.append(path) for language, paths in sorted(unsupported_by_language.items()): errors.append( f"unsupported language not reviewed: {language} ({', '.join(paths)})" ) breakdown = _build_breakdown(changed_files, skipped) if not changed_files and not dep_manifest_paths: errors.append("no supported source files in change set; skipped: " + ", ".join(skipped)) manifest = Manifest( mode=args.mode, base_ref=base, head_ref=args.head, default_branch=default_branch, language_breakdown=breakdown, changed_files=[], findings=[], package_diffs={}, tool_stats={}, tools_unavailable={}, errors=errors, ) (out_dir / "manifest.json").write_text(manifest.to_json()) return 1 findings: list[Finding] = [] tool_stats: dict[str, ToolStat] = {} def collect(runner, *args) -> None: f, s = runner(*args) findings.extend(f) tool_stats.update(s) languages_present = {cf.language for cf in changed_files} if "python" in languages_present: collect(_run_python_tools, repo) if "javascript" in languages_present or "typescript" in languages_present: collect(_run_js_tools, repo) if "csharp" in languages_present: collect(_run_dotnet_tools, repo) collect(_run_polyglot_tools, repo) collect(_run_dep_tools, repo) collect(_run_maintainability_tools, repo, languages_present) if "lua" in languages_present: collect(_run_lua_tools, repo) if "powershell" in languages_present: collect(_run_powershell_tools, repo) if "github-actions" in languages_present: collect(_run_gha_tools, repo) filtered = filter_findings_by_diff(findings, changed_files, repo_root=repo) for tool_name, stat in tool_stats.items(): if stat.ran: stat.post_filter = sum(1 for f in filtered if f.tool == tool_name) tools_unavailable = { name: _INSTALL_COMMANDS.get(name, _INSTALL_TOOLS) for name, s in tool_stats.items() if not s.ran } package_diffs = _build_package_diffs(repo, base, dep_manifest_paths) manifest = Manifest( mode=args.mode, base_ref=base, head_ref=args.head, default_branch=default_branch, language_breakdown=breakdown, changed_files=changed_files, findings=filtered, package_diffs=package_diffs, tool_stats=tool_stats, tools_unavailable=tools_unavailable, errors=errors, ) (out_dir / "manifest.json").write_text(manifest.to_json()) manifest_dict = manifest.to_dict() for agent in ("security-triage", "type-safety", "dependency", "consistency", "secrets", "maintainability", "walkthrough", "gha-reviewer"): sliced = slice_for_agent(manifest_dict, agent) (out_dir / f"manifest-{agent}.json").write_text(json.dumps(sliced, indent=2)) return 0 if __name__ == "__main__": sys.exit(main())