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.
485 lines
18 KiB
Python
Executable File
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())
|