Files
boshu2__agentops/scripts/toolchain-validate.sh
2026-07-15 00:21:02 -04:00

1086 lines
37 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Toolchain Validate - Run all available linters/scanners
# Outputs structured findings to $TOOLCHAIN_OUTPUT_DIR (default: $TMPDIR/agentops-tooling/)
#
# Usage: ./scripts/toolchain-validate.sh [OPTIONS]
#
# Options:
# --quick Skip slow tools (tests, comprehensive scans)
# --json Output summary as JSON to stdout
# --gate Exit non-zero on CRITICAL or HIGH findings
#
# Exit Codes:
# 0 - Pass (no critical/high findings, or --gate not specified)
# 1 - Script error
# 2 - CRITICAL findings found (with --gate)
# 3 - HIGH findings only (with --gate)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
OUTPUT_DIR="${TOOLCHAIN_OUTPUT_DIR:-${TMPDIR:-/tmp}/agentops-tooling}"
# Parse arguments
QUICK=false
JSON_OUTPUT=false
GATE=false
for arg in "$@"; do
case $arg in
--quick) QUICK=true ;;
--json) JSON_OUTPUT=true ;;
--gate) GATE=true ;;
--help|-h)
head -20 "$0" | grep "^#" | sed 's/^# *//'
exit 0
;;
*)
echo "Unknown option: $arg" >&2
exit 1
;;
esac
done
# Initialize output directory
mkdir -p "$OUTPUT_DIR"
# Determine scope (for --gate, default to changed files only)
TARGET_FILES=()
in_git_repo() {
git rev-parse --git-dir >/dev/null 2>&1
}
collect_target_files() {
if ! in_git_repo; then
return 0
fi
local files=""
# Prefer staged changes (pre-commit gate)
files="$(git diff --name-only --cached 2>/dev/null || true)"
if [[ -n "$files" ]]; then
printf "%s\n" "$files"
return 0
fi
# Then unstaged changes
files="$(git diff --name-only 2>/dev/null || true)"
if [[ -n "$files" ]]; then
printf "%s\n" "$files"
return 0
fi
# Finally, most recent commit (post-commit gate)
files="$(git show --name-only --pretty=format: HEAD 2>/dev/null || true)"
if [[ -n "$files" ]]; then
printf "%s\n" "$files"
return 0
fi
return 0
}
if [[ "$GATE" == "true" ]]; then
while IFS= read -r f; do
[[ -z "$f" ]] && continue
TARGET_FILES+=("$REPO_ROOT/$f")
done < <(collect_target_files)
fi
target_has_ext() {
local ext="$1"
if [[ "${#TARGET_FILES[@]}" -eq 0 ]]; then
return 1
fi
local f
for f in "${TARGET_FILES[@]}"; do
[[ "$f" == *".$ext" ]] && return 0
done
return 1
}
target_has_any_ext() {
local ext
for ext in "$@"; do
if target_has_ext "$ext"; then
return 0
fi
done
return 1
}
# Counters
CRITICAL_COUNT=0
HIGH_COUNT=0
MEDIUM_COUNT=0
LOW_COUNT=0
SECURITY_HIGH_COUNT=0 # Only security tools (gosec, gitleaks, semgrep, trivy, hadolint)
QUALITY_HIGH_COUNT=0 # Code quality tools (golangci-lint, radon, ruff, shellcheck)
TOOLS_RUN=0
TOOLS_SKIPPED=0
# Tool output files and status
declare -A TOOL_STATUS
log() {
if [[ "$JSON_OUTPUT" != "true" ]]; then
echo "$1"
fi
}
run_tool() {
local name="$1"
local output_file="$OUTPUT_DIR/${name}.txt"
shift
if ! command -v "$1" &>/dev/null; then
log " [SKIP] $name - not installed"
echo "NOT_INSTALLED" > "$output_file"
TOOL_STATUS["$name"]="not_installed"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 1
fi
log " [RUN] $name"
TOOLS_RUN=$((TOOLS_RUN + 1))
return 0
}
discover_go_modules() {
find "$REPO_ROOT" -name go.mod -type f \
! -path "*/.tmp/*" ! -path "*/vendor/*" ! -path "*/.git/*" \
! -path "*/.claude/worktrees/*" \
-print0 2>/dev/null | xargs -0 -n1 dirname 2>/dev/null || true
}
ensure_json_or_error() {
local tool="$1"
local json_file="$2"
local stderr_file="$3"
if [[ -s "$stderr_file" ]] && [[ ! -s "$json_file" ]]; then
{
echo "ERROR"
echo ""
cat "$stderr_file"
} > "$json_file"
TOOL_STATUS["$tool"]="error"
return 1
fi
if [[ ! -s "$json_file" ]]; then
echo "ERROR: no output produced" > "$json_file"
TOOL_STATUS["$tool"]="error"
return 1
fi
if ! jq empty "$json_file" >/dev/null 2>&1; then
{
echo "ERROR: non-JSON output"
echo ""
cat "$json_file"
if [[ -s "$stderr_file" ]]; then
echo ""
echo "STDERR:"
cat "$stderr_file"
fi
} > "$json_file"
TOOL_STATUS["$tool"]="error"
return 1
fi
return 0
}
# ============================================================================
# TOOL: ruff (Python linting)
# ============================================================================
run_ruff() {
local output_file="$OUTPUT_DIR/ruff.txt"
if [[ "$GATE" == "true" ]] && ! target_has_ext "py"; then
echo "NO_PYTHON_FILES_IN_TARGET" > "$output_file"
TOOL_STATUS["ruff"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "ruff" ruff; then return 0; fi
# Check if there are Python files
if ! find "$REPO_ROOT" -name "*.py" -type f | head -1 | grep -q .; then
echo "NO_PYTHON_FILES" > "$output_file"
TOOL_STATUS["ruff"]="skipped"
return 0
fi
# Run ruff and capture output
if ruff check "$REPO_ROOT" --output-format=concise --exclude .tmp > "$output_file" 2>&1; then
echo "CLEAN" > "$output_file"
TOOL_STATUS["ruff"]="pass"
else
# Ruff concise/full output doesn't expose stable severities; count all issues.
local issues
issues=$(grep -cE "^[^:]+:[0-9]+:[0-9]+:" "$output_file" 2>/dev/null || true)
issues=${issues:-0}
issues=$(echo "$issues" | tr -d '[:space:]')
HIGH_COUNT=$((HIGH_COUNT + issues))
QUALITY_HIGH_COUNT=$((QUALITY_HIGH_COUNT + issues))
TOOL_STATUS["ruff"]="findings"
fi
}
# ============================================================================
# TOOL: golangci-lint (Go linting)
# ============================================================================
run_golangci() {
local output_file="$OUTPUT_DIR/golangci-lint.txt"
local golangci_cmd="$REPO_ROOT/scripts/golangci-lint-v2.sh"
if [[ "$GATE" == "true" ]] && ! target_has_any_ext go mod sum; then
echo "NO_GO_CHANGES_IN_TARGET" > "$output_file"
TOOL_STATUS["golangci-lint"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "golangci-lint" "$golangci_cmd"; then return 0; fi
local modules
modules="$(discover_go_modules)"
if [[ -z "$modules" ]]; then
echo "NO_GO_FILES" > "$output_file"
TOOL_STATUS["golangci-lint"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
local cache_dir="$OUTPUT_DIR/.golangci-cache"
local go_cache_dir="$OUTPUT_DIR/.go-cache"
mkdir -p "$cache_dir" "$go_cache_dir"
: > "$output_file"
local had_findings=false
while IFS= read -r module_dir; do
[[ -z "$module_dir" ]] && continue
{
echo "== golangci-lint: $module_dir =="
} >> "$output_file"
if (cd "$module_dir" && GOLANGCI_LINT_CACHE="$cache_dir" GOCACHE="$go_cache_dir" "$golangci_cmd" run ./...) >> "$output_file" 2>&1; then
echo "" >> "$output_file"
else
echo "" >> "$output_file"
had_findings=true
fi
done <<< "$modules"
local had_version_mismatch=false
if grep -qE "Go language version .* is lower than the targeted Go version" "$output_file" 2>/dev/null; then
had_version_mismatch=true
log " [WARN] golangci-lint: some modules skipped due to Go version mismatch"
fi
local issues
issues=$(grep -cE "^[^:]+:[0-9]+:[0-9]+:" "$output_file" 2>/dev/null || true)
issues=${issues:-0}
issues=$(echo "$issues" | tr -d '[:space:]')
if [[ "$issues" -gt 0 ]]; then
# Real lint findings from modules that ran successfully — count them
# even if other modules hit a version mismatch.
HIGH_COUNT=$((HIGH_COUNT + issues))
QUALITY_HIGH_COUNT=$((QUALITY_HIGH_COUNT + issues))
TOOL_STATUS["golangci-lint"]="findings"
elif [[ "$had_version_mismatch" == "true" ]]; then
# No real findings, but at least one module couldn't be linted.
TOOL_STATUS["golangci-lint"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
elif [[ "$had_findings" == "false" ]]; then
echo "CLEAN" > "$output_file"
TOOL_STATUS["golangci-lint"]="pass"
else
# Non-zero exit but no parseable findings (e.g. config error).
TOOL_STATUS["golangci-lint"]="findings"
fi
}
# ============================================================================
# TOOL: gitleaks (secret scanning)
# ============================================================================
run_gitleaks() {
local output_file="$OUTPUT_DIR/gitleaks.txt"
local gitleaks_mode="${TOOLCHAIN_GITLEAKS_MODE:-full}"
local gitleaks_range="${TOOLCHAIN_GITLEAKS_RANGE:-origin/main..HEAD}"
local -a gitleaks_config=()
local -a gitleaks_prefix=()
if [[ "${TOOLCHAIN_DISABLE_GITLEAKS:-}" == "true" || "${TOOLCHAIN_DISABLE_GITLEAKS:-}" == "1" ]]; then
echo "SKIPPED_DISABLED_BY_CALLER" > "$output_file"
TOOL_STATUS["gitleaks"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if [[ "$QUICK" == "true" ]]; then
echo "SKIPPED_QUICK_MODE" > "$output_file"
TOOL_STATUS["gitleaks"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "gitleaks" gitleaks; then return 0; fi
# Use repo config if available, --no-color to avoid ANSI codes
if [[ -f "$REPO_ROOT/.gitleaks.toml" ]]; then
gitleaks_config=(--config "$REPO_ROOT/.gitleaks.toml")
fi
# Optional CPU cap for local scans
if [[ -n "${TOOLCHAIN_GITLEAKS_GOMAXPROCS:-}" ]]; then
gitleaks_prefix=(env "GOMAXPROCS=${TOOLCHAIN_GITLEAKS_GOMAXPROCS}")
fi
local rc=0
case "$gitleaks_mode" in
range)
"${gitleaks_prefix[@]}" gitleaks detect --log-opts="$gitleaks_range" --no-color "${gitleaks_config[@]}" > "$output_file" 2>&1 || rc=$?
# Fallback for repos without expected remote refs/range
if [[ "$rc" -ne 0 ]] && grep -qiE "bad revision|unknown revision|not a valid object name|ambiguous argument" "$output_file"; then
rc=0
"${gitleaks_prefix[@]}" gitleaks protect --staged --no-color "${gitleaks_config[@]}" > "$output_file" 2>&1 || rc=$?
fi
;;
staged)
"${gitleaks_prefix[@]}" gitleaks protect --staged --no-color "${gitleaks_config[@]}" > "$output_file" 2>&1 || rc=$?
;;
full)
"${gitleaks_prefix[@]}" gitleaks detect --source="$REPO_ROOT" --no-git --no-color "${gitleaks_config[@]}" > "$output_file" 2>&1 || rc=$?
;;
*)
echo "INVALID_GITLEAKS_MODE:$gitleaks_mode (expected full|range|staged)" > "$output_file"
TOOL_STATUS["gitleaks"]="error"
return 1
;;
esac
if [[ "$rc" -eq 0 ]]; then
echo "CLEAN" > "$output_file"
TOOL_STATUS["gitleaks"]="pass"
else
# Count leaks - gitleaks outputs one block per finding
local leaks
leaks=$(grep -c "Secret:" "$output_file" 2>/dev/null || true)
leaks=${leaks:-0}
leaks=$(echo "$leaks" | tr -d '[:space:]')
CRITICAL_COUNT=$((CRITICAL_COUNT + leaks))
TOOL_STATUS["gitleaks"]="findings"
fi
}
# ============================================================================
# TOOL: shellcheck (shell script linting)
# ============================================================================
run_shellcheck() {
local output_file="$OUTPUT_DIR/shellcheck.txt"
if [[ "$GATE" == "true" ]] && ! target_has_ext "sh"; then
echo "NO_SHELL_FILES_IN_TARGET" > "$output_file"
TOOL_STATUS["shellcheck"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "shellcheck" shellcheck; then return 0; fi
# Find all shell scripts
local scripts
if [[ "$GATE" == "true" ]] && [[ "${#TARGET_FILES[@]}" -gt 0 ]]; then
scripts="$(printf "%s\n" "${TARGET_FILES[@]}" | grep -E '\\.sh$' || true)"
else
scripts="$(find "$REPO_ROOT" -name "*.sh" -type f ! -path "*/.git/*" ! -path "*/.claude/worktrees/*" 2>/dev/null || true)"
fi
if [[ -z "$scripts" ]]; then
echo "NO_SHELL_FILES_IN_TARGET" > "$output_file"
TOOL_STATUS["shellcheck"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
# Run shellcheck
echo "$scripts" | xargs shellcheck -x -f gcc > "$output_file" 2>&1 || true
if [[ ! -s "$output_file" ]]; then
echo "CLEAN" > "$output_file"
TOOL_STATUS["shellcheck"]="pass"
else
# Count by severity (shellcheck gcc format: "file:line:col: error: message")
local errors warnings
errors=$(grep -cE ": error:" "$output_file" 2>/dev/null || true)
errors=${errors:-0}
errors=$(echo "$errors" | tr -d '[:space:]')
warnings=$(grep -cE ": warning:" "$output_file" 2>/dev/null || true)
warnings=${warnings:-0}
warnings=$(echo "$warnings" | tr -d '[:space:]')
HIGH_COUNT=$((HIGH_COUNT + errors))
QUALITY_HIGH_COUNT=$((QUALITY_HIGH_COUNT + errors))
MEDIUM_COUNT=$((MEDIUM_COUNT + warnings))
if [[ $errors -gt 0 || $warnings -gt 0 ]]; then
TOOL_STATUS["shellcheck"]="findings"
else
TOOL_STATUS["shellcheck"]="pass"
fi
fi
}
# ============================================================================
# TOOL: radon (Python complexity)
# ============================================================================
run_radon() {
local output_file="$OUTPUT_DIR/radon.txt"
if [[ "$GATE" == "true" ]] && ! target_has_ext "py"; then
echo "NO_PYTHON_FILES_IN_TARGET" > "$output_file"
TOOL_STATUS["radon"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "radon" radon; then return 0; fi
# Check if there are Python files
if ! find "$REPO_ROOT" -name "*.py" -type f | head -1 | grep -q .; then
echo "NO_PYTHON_FILES" > "$output_file"
TOOL_STATUS["radon"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
# Run radon for cyclomatic complexity (min E = 26+, aligns with Go hard-fail at 25)
radon cc "$REPO_ROOT" -a -s --min E --exclude ".tmp/*,.claude/worktrees/*,skills-codex/*,*/reverse_engineer_rpi.py" > "$output_file" 2>&1 || true
if [[ ! -s "$output_file" ]]; then
echo "CLEAN" > "$output_file"
TOOL_STATUS["radon"]="pass"
else
# Count high complexity functions
local complex
complex=$(grep -cE "^\s+[A-Z] " "$output_file" 2>/dev/null || true)
complex=${complex:-0}
complex=$(echo "$complex" | tr -d '[:space:]')
HIGH_COUNT=$((HIGH_COUNT + complex))
QUALITY_HIGH_COUNT=$((QUALITY_HIGH_COUNT + complex))
if [[ $complex -gt 0 ]]; then
TOOL_STATUS["radon"]="findings"
else
TOOL_STATUS["radon"]="pass"
fi
fi
}
# ============================================================================
# TOOL: pytest (Python tests) - skipped in quick mode
# ============================================================================
run_pytest() {
if [[ "$QUICK" == "true" ]]; then
log " [SKIP] pytest - quick mode"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
echo "SKIPPED_QUICK_MODE" > "$OUTPUT_DIR/pytest.txt"
TOOL_STATUS["pytest"]="skipped"
return 0
fi
local output_file="$OUTPUT_DIR/pytest.txt"
if ! run_tool "pytest" pytest; then return 0; fi
# Check if there are test files
if ! find "$REPO_ROOT" -name "test_*.py" -o -name "*_test.py" | head -1 | grep -q .; then
echo "NO_TEST_FILES" > "$output_file"
TOOL_STATUS["pytest"]="skipped"
return 0
fi
# Run pytest with minimal output
if pytest "$REPO_ROOT" --tb=short -q > "$output_file" 2>&1; then
echo "PASS" >> "$output_file"
TOOL_STATUS["pytest"]="pass"
else
local failures
failures=$(grep -cE "^FAILED" "$output_file" 2>/dev/null || true)
failures=${failures:-0}
failures=$(echo "$failures" | tr -d '[:space:]')
CRITICAL_COUNT=$((CRITICAL_COUNT + failures))
TOOL_STATUS["pytest"]="findings"
fi
}
# ============================================================================
# TOOL: go test - skipped in quick mode
# ============================================================================
run_gotest() {
if [[ "$QUICK" == "true" ]]; then
log " [SKIP] go test - quick mode"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
echo "SKIPPED_QUICK_MODE" > "$OUTPUT_DIR/gotest.txt"
TOOL_STATUS["go-test"]="skipped"
return 0
fi
local output_file="$OUTPUT_DIR/gotest.txt"
if [[ "$GATE" == "true" ]] && ! target_has_any_ext go mod sum; then
echo "NO_GO_CHANGES_IN_TARGET" > "$output_file"
TOOL_STATUS["go-test"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "go-test" go; then return 0; fi
local modules
modules="$(discover_go_modules)"
if [[ -z "$modules" ]]; then
echo "NO_GO_MODULES" > "$output_file"
TOOL_STATUS["go-test"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
local go_cache_dir="$OUTPUT_DIR/.go-cache"
mkdir -p "$go_cache_dir"
: > "$output_file"
local had_failures=false
local failures=0
while IFS= read -r module_dir; do
[[ -z "$module_dir" ]] && continue
{
echo "== go test: $module_dir =="
} >> "$output_file"
if (cd "$module_dir" && GOCACHE="$go_cache_dir" go test ./... -short) >> "$output_file" 2>&1; then
echo "" >> "$output_file"
else
echo "" >> "$output_file"
had_failures=true
fi
done <<< "$modules"
failures=$(grep -c "^--- FAIL" "$output_file" 2>/dev/null || true)
failures=${failures:-0}
failures=$(echo "$failures" | tr -d '[:space:]')
if [[ "$failures" -eq 0 ]] && [[ "$had_failures" == "false" ]]; then
echo "PASS" >> "$output_file"
TOOL_STATUS["go-test"]="pass"
else
CRITICAL_COUNT=$((CRITICAL_COUNT + failures))
TOOL_STATUS["go-test"]="findings"
fi
}
# ============================================================================
# TOOL: semgrep (SAST security patterns)
# ============================================================================
run_semgrep() {
local output_file="$OUTPUT_DIR/semgrep.txt"
local stderr_file="$OUTPUT_DIR/semgrep.stderr.txt"
if [[ "$GATE" == "true" ]] && ! target_has_any_ext go py js ts tsx jsx java rb php cs; then
echo "NO_CODE_FILES_IN_TARGET" > "$output_file"
TOOL_STATUS["semgrep"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "semgrep" semgrep; then return 0; fi
: > "$stderr_file"
local ssl_cert_file=""
if command -v python3 >/dev/null 2>&1; then
ssl_cert_file="$(python3 -c 'import certifi; print(certifi.where())' 2>/dev/null || true)"
fi
# Exclude rules expected in CLI/DevOps tooling:
# dangerous-exec-command: CLI tool runs subprocesses by design
# detected-pgp-private-key-block: pattern in security scanning script, not an actual key
# path-join-resolve-traversal: skills installer uses path joins with user input by design
# import-text-template: CLI uses text/template for output formatting
# unsafe-deserialization-interface: standard Go JSON unmarshal into interface{}
# dynamic-urllib-use-detected: reverse-engineer scripts fetch URLs by design
local exclude_rules=(
--exclude-rule go.lang.security.audit.dangerous-exec-command.dangerous-exec-command
--exclude-rule generic.secrets.security.detected-pgp-private-key-block.detected-pgp-private-key-block
--exclude-rule javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal
--exclude-rule go.lang.security.audit.xss.import-text-template.import-text-template
--exclude-rule go.lang.security.deserialization.unsafe-deserialization-interface.go-unsafe-deserialization-interface
--exclude-rule python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
--exclude-rule bash.lang.security.ifs-tampering.ifs-tampering
)
if [[ -n "$ssl_cert_file" ]]; then
SSL_CERT_FILE="$ssl_cert_file" semgrep scan --config=auto "$REPO_ROOT" --json --quiet "${exclude_rules[@]}" > "$output_file" 2> "$stderr_file" || true
else
semgrep scan --config=auto "$REPO_ROOT" --json --quiet "${exclude_rules[@]}" > "$output_file" 2> "$stderr_file" || true
fi
if ! ensure_json_or_error "semgrep" "$output_file" "$stderr_file"; then
return 0
fi
local critical advisory
critical=$(jq '[.results[]? | select(.extra.severity == "ERROR")] | length' "$output_file" 2>/dev/null || echo 0)
advisory=$(jq '[.results[]? | select(.extra.severity == "WARNING" or .extra.severity == "MEDIUM")] | length' "$output_file" 2>/dev/null || echo 0)
critical=${critical:-0}
advisory=${advisory:-0}
critical=$(echo "$critical" | tr -d '[:space:]')
advisory=$(echo "$advisory" | tr -d '[:space:]')
CRITICAL_COUNT=$((CRITICAL_COUNT + critical))
MEDIUM_COUNT=$((MEDIUM_COUNT + advisory))
# Semgrep WARNING/MEDIUM results are advisory findings, not security HIGH.
# They remain in the report without blocking an otherwise green candidate.
TOOL_STATUS["semgrep"]=$([[ "$critical" -gt 0 || "$advisory" -gt 0 ]] && echo "findings" || echo "pass")
}
# ============================================================================
# TOOL: trivy (dependency vulnerabilities)
# ============================================================================
run_trivy() {
local output_file="$OUTPUT_DIR/trivy.txt"
local stderr_file="$OUTPUT_DIR/trivy.stderr.txt"
if [[ "$GATE" == "true" ]] && ! target_has_any_ext go mod sum json lock yaml yml; then
echo "NO_DEPENDENCY_CHANGES_IN_TARGET" > "$output_file"
TOOL_STATUS["trivy"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "trivy" trivy; then return 0; fi
: > "$stderr_file"
local docker_cfg
docker_cfg="$(mktemp -d)"
local cache_dir="$OUTPUT_DIR/.trivy-cache"
mkdir -p "$cache_dir"
local db_repo="${TRIVY_DB_REPOSITORY:-ghcr.io/aquasecurity/trivy-db:2}"
local db_flag=()
if trivy fs --help 2>/dev/null | grep -q -- '--db-repository'; then
db_flag=(--db-repository "$db_repo")
fi
local trivy_skip_args=(
--skip-dirs "$REPO_ROOT/.tmp"
--skip-dirs "$REPO_ROOT/.claude/worktrees"
--skip-dirs "$REPO_ROOT/.git"
)
DOCKER_CONFIG="$docker_cfg" TRIVY_CACHE_DIR="$cache_dir" trivy fs "$REPO_ROOT" \
--severity CRITICAL,HIGH \
--format json \
"${db_flag[@]}" \
"${trivy_skip_args[@]}" \
> "$output_file" 2> "$stderr_file" || true
rm -rf "$docker_cfg"
# In sandboxed / offline environments, allow trivy to be skipped gracefully.
if [[ -s "$stderr_file" ]] && grep -qiE 'no such host|dial tcp|lookup .*: no such host' "$stderr_file"; then
{
echo "SKIPPED: network unavailable"
echo ""
cat "$stderr_file"
} > "$output_file"
TOOL_STATUS["trivy"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! ensure_json_or_error "trivy" "$output_file" "$stderr_file"; then
return 0
fi
local critical high
critical=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' "$output_file" 2>/dev/null || echo 0)
high=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' "$output_file" 2>/dev/null || echo 0)
critical=${critical:-0}
high=${high:-0}
critical=$(echo "$critical" | tr -d '[:space:]')
high=$(echo "$high" | tr -d '[:space:]')
if [[ "$critical" -gt 0 ]] || [[ "$high" -gt 0 ]]; then
CRITICAL_COUNT=$((CRITICAL_COUNT + critical))
HIGH_COUNT=$((HIGH_COUNT + high))
SECURITY_HIGH_COUNT=$((SECURITY_HIGH_COUNT + high))
TOOL_STATUS["trivy"]="findings"
else
TOOL_STATUS["trivy"]="pass"
fi
}
# ============================================================================
# TOOL: gosec (Go security)
# ============================================================================
run_gosec() {
local output_file="$OUTPUT_DIR/gosec.txt"
local stderr_file="$OUTPUT_DIR/gosec.stderr.txt"
if [[ "$GATE" == "true" ]] && ! target_has_any_ext go mod sum; then
echo "NO_GO_CHANGES_IN_TARGET" > "$output_file"
TOOL_STATUS["gosec"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "gosec" gosec; then return 0; fi
local modules
modules="$(discover_go_modules)"
if [[ -z "$modules" ]]; then
echo "NO_GO_MODULES" > "$output_file"
TOOL_STATUS["gosec"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
: > "$stderr_file"
: > "$output_file"
while IFS= read -r module_dir; do
[[ -z "$module_dir" ]] && continue
local module_json
module_json="$(mktemp)"
local module_stderr
module_stderr="$(mktemp)"
{
echo "== gosec: $module_dir =="
} >> "$output_file"
# Exclude rules expected in CLI tools:
# G104: unhandled errors (common in deferred cleanup)
# G115: integer overflow uintptr->int (f.Fd() safe on all platforms)
# G204: subprocess execution (CLI tool runs commands by design)
# G301: dir perms (CLI creates user-owned dirs)
# G302: file mode bits (CLI creates user-owned files)
# G304: file path from variable (CLI takes paths as arguments)
# G306: file perms (CLI creates user-owned files)
# G702: command injection via taint (CLI runs user-specified commands)
# G703: path traversal via taint (CLI operates on user-specified paths)
# G704: SSRF via taint (CLI makes HTTP requests to configured endpoints)
# G118: context cancel func not called at site (false positive — funcs that return cancel to caller)
(cd "$module_dir" && gosec -quiet -fmt json -exclude=G104,G115,G204,G301,G302,G304,G306,G702,G703,G704,G118 ./... > "$module_json" 2> "$module_stderr") || true
if jq empty "$module_json" >/dev/null 2>&1; then
cat "$module_json" >> "$output_file"
echo "" >> "$output_file"
else
{
echo "ERROR: gosec produced non-JSON output"
if [[ -s "$module_stderr" ]]; then
echo ""
cat "$module_stderr"
fi
} >> "$stderr_file"
fi
rm -f "$module_json" "$module_stderr"
done <<< "$modules"
# If we have errors, keep tool status but don't treat as findings.
if [[ -s "$stderr_file" ]]; then
TOOL_STATUS["gosec"]="error"
cat "$stderr_file" >> "$output_file"
return 0
fi
# Count issues by severity across combined JSON blocks (best-effort).
# gosec JSON uses "severity": "HIGH"|"MEDIUM"|"LOW" per finding.
# Only HIGH findings should contribute to the security gate.
local high_issues medium_issues low_issues
high_issues=$(grep -c '"severity": "HIGH"' "$output_file" 2>/dev/null || true)
high_issues=${high_issues:-0}
high_issues=$(echo "$high_issues" | tr -d '[:space:]')
medium_issues=$(grep -c '"severity": "MEDIUM"' "$output_file" 2>/dev/null || true)
medium_issues=${medium_issues:-0}
medium_issues=$(echo "$medium_issues" | tr -d '[:space:]')
low_issues=$(grep -c '"severity": "LOW"' "$output_file" 2>/dev/null || true)
low_issues=${low_issues:-0}
low_issues=$(echo "$low_issues" | tr -d '[:space:]')
local total_issues=$((high_issues + medium_issues + low_issues))
if [[ "$total_issues" -gt 0 ]]; then
HIGH_COUNT=$((HIGH_COUNT + high_issues))
SECURITY_HIGH_COUNT=$((SECURITY_HIGH_COUNT + high_issues))
MEDIUM_COUNT=$((MEDIUM_COUNT + medium_issues))
LOW_COUNT=$((LOW_COUNT + low_issues))
TOOL_STATUS["gosec"]="findings"
else
TOOL_STATUS["gosec"]="pass"
fi
}
# ============================================================================
# TOOL: govulncheck (known-CVE reachability: module graph + Go stdlib)
# ============================================================================
# Sweep 2026-07-09-claude M-2 (age-govulncheck-standing-lane-eg2n): gosec/
# semgrep/gitleaks cover own-code classes; NOTHING checked the module graph or
# the pinned toolchain's stdlib against the vulnerability database — exactly
# why GO-2026-4970 (an os.Root escape, this repo's containment primitive) sat
# undetected for a week after a clean scan. Every reachable finding counts as
# SECURITY HIGH: govulncheck's default callgraph mode only reports vulns whose
# code paths are actually reachable, so a finding here is a known CVE on a live
# path — the gate must BLOCK, not warn.
run_govulncheck() {
local output_file="$OUTPUT_DIR/govulncheck.txt"
local stderr_file="$OUTPUT_DIR/govulncheck.stderr.txt"
if [[ "$GATE" == "true" ]] && ! target_has_any_ext go mod sum; then
echo "NO_GO_CHANGES_IN_TARGET" > "$output_file"
TOOL_STATUS["govulncheck"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
if ! run_tool "govulncheck" govulncheck; then return 0; fi
local modules
modules="$(discover_go_modules)"
if [[ -z "$modules" ]]; then
echo "NO_GO_MODULES" > "$output_file"
TOOL_STATUS["govulncheck"]="skipped"
TOOLS_SKIPPED=$((TOOLS_SKIPPED + 1))
return 0
fi
: > "$stderr_file"
: > "$output_file"
local vuln_count=0
while IFS= read -r module_dir; do
[[ -z "$module_dir" ]] && continue
local module_out module_stderr module_rc
module_out="$(mktemp)"
module_stderr="$(mktemp)"
echo "== govulncheck: $module_dir ==" >> "$output_file"
module_rc=0
# GOTOOLCHAIN=auto: scan the stdlib version the module DECLARES it ships
# with (go.mod `toolchain` directive), not whatever `go` happens to sit on
# PATH. CI's actions/setup-go pins a fixed go-version and sets
# GOTOOLCHAIN=local, which makes govulncheck scan an older stdlib than the
# repo actually builds against (go.mod declares a newer, patched toolchain).
# That skew made CI report standard-library CVEs (GO-2026-4970/5037/5039/5856)
# already fixed in the declared toolchain while local (GOTOOLCHAIN=auto)
# scanned clean. Forcing `auto` here aligns the scan with the shipped
# toolchain so local == CI. This is NOT a suppression: a genuinely unfixed
# stdlib CVE (no fix in the declared toolchain) still blocks.
(cd "$module_dir" && GOTOOLCHAIN=auto govulncheck ./... > "$module_out" 2> "$module_stderr") || module_rc=$?
cat "$module_out" >> "$output_file"
echo "" >> "$output_file"
# Exit 0 = clean, 3 = vulns found (both are successful RUNS); anything
# else (network/vulndb/toolchain failure) is a tool error — fail loud,
# never silently green.
if [[ "$module_rc" -ne 0 && "$module_rc" -ne 3 ]]; then
{
echo "ERROR: govulncheck exited $module_rc in $module_dir"
[[ -s "$module_stderr" ]] && cat "$module_stderr"
} >> "$stderr_file"
else
local module_vulns
module_vulns=$(grep -c '^Vulnerability #' "$module_out" 2>/dev/null || true)
module_vulns=${module_vulns:-0}
module_vulns=$(echo "$module_vulns" | tr -d '[:space:]')
vuln_count=$((vuln_count + module_vulns))
fi
rm -f "$module_out" "$module_stderr"
done <<< "$modules"
if [[ -s "$stderr_file" ]]; then
TOOL_STATUS["govulncheck"]="error"
cat "$stderr_file" >> "$output_file"
return 0
fi
if [[ "$vuln_count" -gt 0 ]]; then
HIGH_COUNT=$((HIGH_COUNT + vuln_count))
SECURITY_HIGH_COUNT=$((SECURITY_HIGH_COUNT + vuln_count))
TOOL_STATUS["govulncheck"]="findings"
else
TOOL_STATUS["govulncheck"]="pass"
fi
}
# ============================================================================
# TOOL: hadolint (Dockerfile)
# ============================================================================
run_hadolint() {
local output_file="$OUTPUT_DIR/hadolint.txt"
if ! run_tool "hadolint" hadolint; then return 0; fi
local dockerfiles
dockerfiles=$(find "$REPO_ROOT" -name "Dockerfile*" -type f \
! -path "*/.tmp/*" ! -path "*/vendor/*" ! -path "*/.git/*" \
! -path "*/.claude/worktrees/*" 2>/dev/null)
if [[ -z "$dockerfiles" ]]; then
echo "NO_DOCKERFILES" > "$output_file"
TOOL_STATUS["hadolint"]="skipped"
return 0
fi
if echo "$dockerfiles" | xargs hadolint --format json > "$output_file" 2>&1; then
TOOL_STATUS["hadolint"]="pass"
else
local errors warnings
errors=$(jq '[.[] | select(.level == "error")] | length' "$output_file" 2>/dev/null || echo 0)
warnings=$(jq '[.[] | select(.level == "warning")] | length' "$output_file" 2>/dev/null || echo 0)
errors=${errors:-0}
warnings=${warnings:-0}
errors=$(echo "$errors" | tr -d '[:space:]')
warnings=$(echo "$warnings" | tr -d '[:space:]')
HIGH_COUNT=$((HIGH_COUNT + errors))
SECURITY_HIGH_COUNT=$((SECURITY_HIGH_COUNT + errors))
MEDIUM_COUNT=$((MEDIUM_COUNT + warnings))
TOOL_STATUS["hadolint"]="findings"
fi
}
# ============================================================================
# MAIN EXECUTION
# ============================================================================
log ""
log "Toolchain Validation"
log "===================="
log "Target: $REPO_ROOT"
log "Output: $OUTPUT_DIR"
if [[ "$GATE" == "true" ]] && [[ "${#TARGET_FILES[@]}" -gt 0 ]]; then
log "Scope: changed files only"
fi
log ""
# Run all tools
log "Running tools..."
run_ruff
run_golangci
run_gitleaks
run_shellcheck
run_radon
run_semgrep
run_trivy
run_gosec
run_govulncheck
run_hadolint
run_pytest
run_gotest
log ""
# Compute gate status — only security findings block
if [[ $CRITICAL_COUNT -gt 0 ]]; then
GATE_STATUS="BLOCKED_CRITICAL"
elif [[ $SECURITY_HIGH_COUNT -gt 0 ]]; then
GATE_STATUS="BLOCKED_HIGH"
elif [[ $QUALITY_HIGH_COUNT -gt 0 ]]; then
GATE_STATUS="WARN_QUALITY"
else
GATE_STATUS="PASS"
fi
# Build tools JSON object
TOOLS_JSON="{"
first=true
for tool in ruff golangci-lint gitleaks shellcheck radon semgrep trivy gosec govulncheck hadolint pytest go-test; do
status="${TOOL_STATUS[$tool]:-not_run}"
if [[ "$first" == "true" ]]; then
first=false
else
TOOLS_JSON="$TOOLS_JSON,"
fi
TOOLS_JSON="$TOOLS_JSON \"$tool\": \"$status\""
done
TOOLS_JSON="$TOOLS_JSON }"
# Generate summary
SUMMARY=$(cat <<EOF
{
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"target": "$REPO_ROOT",
"tools_run": $TOOLS_RUN,
"tools_skipped": $TOOLS_SKIPPED,
"tools": $TOOLS_JSON,
"findings": {
"critical": $CRITICAL_COUNT,
"high": $HIGH_COUNT,
"security_high": $SECURITY_HIGH_COUNT,
"quality_high": $QUALITY_HIGH_COUNT,
"medium": $MEDIUM_COUNT,
"low": $LOW_COUNT
},
"gate_status": "$GATE_STATUS",
"output_dir": "$OUTPUT_DIR"
}
EOF
)
# Write summary file
echo "$SUMMARY" > "$OUTPUT_DIR/summary.json"
# Output based on mode
if [[ "$JSON_OUTPUT" == "true" ]]; then
echo "$SUMMARY"
else
log "Summary"
log "-------"
log " Tools run: $TOOLS_RUN"
log " Tools skipped: $TOOLS_SKIPPED"
log ""
log " Findings:"
log " CRITICAL: $CRITICAL_COUNT"
log " HIGH (security): $SECURITY_HIGH_COUNT"
log " HIGH (quality): $QUALITY_HIGH_COUNT"
log " MEDIUM: $MEDIUM_COUNT"
log " LOW: $LOW_COUNT"
log ""
if [[ "$GATE_STATUS" == "BLOCKED_CRITICAL" ]]; then
log " Gate: BLOCKED - ${CRITICAL_COUNT} critical findings"
elif [[ "$GATE_STATUS" == "BLOCKED_HIGH" ]]; then
log " Gate: BLOCKED - ${SECURITY_HIGH_COUNT} security high findings"
elif [[ "$GATE_STATUS" == "WARN_QUALITY" ]]; then
log " Gate: PASS (${QUALITY_HIGH_COUNT} quality warnings, non-blocking)"
else
log " Gate: PASS"
fi
log ""
log "Full output: $OUTPUT_DIR"
fi
# Exit code logic
if [[ "$GATE" == "true" ]]; then
if [[ "$GATE_STATUS" == "BLOCKED_CRITICAL" ]]; then
exit 2
elif [[ "$GATE_STATUS" == "BLOCKED_HIGH" ]]; then
exit 3
fi
fi
exit 0