Files
claude-plugin/plugins/reviews/skills/audit-code/scripts/collect-findings.py
T
mroberts 37fc3fb291 Fix audit tool bootstrap and add per-run preflight
audit-code install-tools.sh:
- Buffer the opengrep release JSON before grep -m1; curl died with (23)
  under pipefail when grep quit early.
- Use ${m}: in the PowerShell block; $m: parsed as a scope-qualified var.
- On Arch, skip paru/yay when pacman -Q shows every package installed,
  since --needed still invokes sudo.
- Add --check-only (fast, installs nothing, non-zero naming missing tools)
  and --user-only (no system package managers, no sudo).

log-run.py (both skills): put the skill dir on sys.path so running it as
a script from any cwd no longer raises ModuleNotFoundError.

audit-terraform: move deps from requirements.txt into pyproject
dependency groups and add scripts/install-tools.sh (uv sync --group tools,
then check trivy, tflint, tofu, terragrunt, gh).

Both SKILL.md files gain a 0.5 Preflight step and call scripts through
uv run --project ${SKILL_DIR}. tools_unavailable is now a map of tool to
exact install command; audit-terraform skips trivy when absent and stops
with an install hint instead of crashing when tofu/terragrunt is missing.
2026-09-22 15:21:28 -05:00

485 lines
18 KiB
Python
Executable File

"""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())