mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
d972fa2090
## What Prepare AgentOps 4.0.0 across the Claude plugin, Codex plugin, skills and CLI. Claude writers capture the supplied check status during its original invocation, and plugin conformance verifies exact skill membership and link destinations. Full release security now scans the repository and blocks on Python collection failures that previously produced a false green result. ## Why The 3.6.0-to-current interval removes published commands and 20 skill names, so this is a major release with migration instructions. Release validation also exposed stale skill assertions and test prerequisites that need to match the current product contracts without weakening acceptance. ## How I tested - Native Claude Opus/Haiku success, failing-check and direct-writer trials: each check ran once, and the direct child returned plain JSON. - Actual fresh installs and upgrades from 3.6.0 in isolated Codex and Claude homes: 34 skills, expected agents, and exact installed package bytes. - Exact candidate `b721d02559e1495be6095ad97b820e88ceb4a049`: all 73 full repository gates, regeneration parity, and the complete local release rehearsal passed. All 12 security tools ran with zero skips, tool errors, critical findings or high-severity security findings. The unchanged advisory policy reports 35 quality-high findings on unchanged files. - Python: 327 tests and 72 subtests passed. Hosted Bats: 1,509 passed, 31 environment-dependent skips, zero failures. Go lint/build/vet/race/shuffle checks and CLI smoke/integration passed. - All 11 hosted checks passed, including Windows correctness, macOS/Linux installation, security, and the six-target no-publish GoReleaser snapshot. Local archive checksums and a real macOS CLI initialization/status/version smoke also passed. - Fresh author-distinct review passed all four acceptance criteria and all 35 changed paths with no unchecked acceptance. Canonical subject and caller-intent verification passed; verdict digest `68af2c935ed0106cd91b3950f5d168e662f4071f660fcbd113c36b7cd0f0426e` binds manifest `7affc77e25eaff69ba36c5ce05582b4f0385c954b76b62c02b97f97041f489b2`. ## Checklist - [x] Breaking changes documented in the migration guide and complete release notes. - [x] No credentials or private runtime proof included. - [x] Final full release checks pass on the exact candidate. - [x] Fresh author-distinct final PASS is recorded before merge. This prepares the release candidate; it does not publish a tag or release. Coverage limits remain explicit: native plugin tests used isolated macOS homes and local marketplaces, guard installation remains opt-in, and reader instructions do not prove sandbox confinement. Semgrep retains pre-existing warning-level parser diagnostics. Snapshot metadata follows the existing 3.6.0 tag; this is a packaging rehearsal, not a published 4.0.0 archive.
1124 lines
39 KiB
Bash
Executable File
1124 lines
39 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
|
|
# --all Scan the full repository even with --gate (default: changed scope)
|
|
#
|
|
# 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
|
|
ALL_FILES=false
|
|
|
|
for arg in "$@"; do
|
|
case $arg in
|
|
--quick) QUICK=true ;;
|
|
--json) JSON_OUTPUT=true ;;
|
|
--gate) GATE=true ;;
|
|
--all) ALL_FILES=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 independently of whether findings should fail the command.
|
|
# Pre-commit/post-commit --gate callers retain their changed-file default.
|
|
SCOPE="all"
|
|
if [[ "$GATE" == "true" && "$ALL_FILES" != "true" ]]; then
|
|
SCOPE="changed"
|
|
fi
|
|
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 [[ "$SCOPE" == "changed" ]]; 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 [[ "$SCOPE" == "changed" ]] && ! 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 -print -quit | 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 [[ "$SCOPE" == "changed" ]] && ! 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 [[ "$SCOPE" == "changed" ]] && ! 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 [[ "$SCOPE" == "changed" ]] && [[ "${#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 [[ "$SCOPE" == "changed" ]] && ! 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 -print -quit | 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" -type f \( -name "test_*.py" -o -name "*_test.py" \) -print -quit | grep -q .; then
|
|
echo "NO_TEST_FILES" > "$output_file"
|
|
TOOL_STATUS["pytest"]="skipped"
|
|
return 0
|
|
fi
|
|
|
|
# Evidence CLI tests require the exact candidate, never an ambient ao on
|
|
# PATH. Honor an explicit caller candidate; otherwise build this checkout.
|
|
local ao_bin="${AO_BIN:-}"
|
|
local ao_build_dir=""
|
|
if [[ -z "$ao_bin" ]]; then
|
|
ao_build_dir="$(mktemp -d "${TMPDIR:-/tmp}/agentops-pytest-ao.XXXXXX")"
|
|
ao_bin="$ao_build_dir/ao"
|
|
local build_rc=0
|
|
(cd "$REPO_ROOT/cli" && go build -o "$ao_bin" ./cmd/ao) > "$OUTPUT_DIR/pytest-build.txt" 2>&1 || build_rc=$?
|
|
if [[ "$build_rc" -ne 0 || ! -x "$ao_bin" ]]; then
|
|
printf 'ERROR: candidate ao build failed (exit %s); pytest was not run\n' "$build_rc" > "$output_file"
|
|
cat "$OUTPUT_DIR/pytest-build.txt" >> "$output_file"
|
|
TOOL_STATUS["pytest"]="error"
|
|
CRITICAL_COUNT=$((CRITICAL_COUNT + 1))
|
|
rm -rf "$ao_build_dir"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Source skills and their generated projections can share test basenames.
|
|
# Importlib collects both without Python module-name collisions.
|
|
local pytest_rc=0
|
|
AO_BIN="$ao_bin" pytest "$REPO_ROOT" --import-mode=importlib --tb=short -q > "$output_file" 2>&1 || pytest_rc=$?
|
|
if [[ -n "$ao_build_dir" ]]; then rm -rf "$ao_build_dir"; fi
|
|
if [[ "$pytest_rc" -eq 0 ]]; 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:]')
|
|
# Collection errors, interrupted runs, and usage/internal errors may
|
|
# contain no FAILED lines. A nonzero run still blocks the gate.
|
|
if [[ "$failures" -lt 1 ]]; then failures=1; fi
|
|
CRITICAL_COUNT=$((CRITICAL_COUNT + failures))
|
|
if [[ "$pytest_rc" -eq 1 ]]; then
|
|
TOOL_STATUS["pytest"]="findings"
|
|
else
|
|
TOOL_STATUS["pytest"]="error"
|
|
fi
|
|
printf '\nPYTEST_EXIT_CODE=%s\n' "$pytest_rc" >> "$output_file"
|
|
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 [[ "$SCOPE" == "changed" ]] && ! 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 [[ "$SCOPE" == "changed" ]] && ! 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 [[ "$SCOPE" == "changed" ]] && ! 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 [[ "$SCOPE" == "changed" ]] && ! 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 [[ "$SCOPE" == "changed" ]] && ! 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"
|
|
log "Scope: $SCOPE"
|
|
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",
|
|
"scope": "$SCOPE",
|
|
"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
|