#!/usr/bin/env bash set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" MODE="${SECURITY_AUDIT_MODE:-ci}" SCOPE="${SECURITY_AUDIT_SCOPE:-current}" REPORTS_DIR="${SECURITY_AUDIT_REPORTS_DIR:-$ROOT/audit-reports}" FAIL_ON_FINDINGS="${SECURITY_AUDIT_FAIL_ON_FINDINGS:-0}" REQUIRE_TOOLS="${SECURITY_AUDIT_REQUIRE_TOOLS:-0}" while [[ $# -gt 0 ]]; do case "$1" in --mode) MODE="${2:?missing mode}" shift 2 ;; --scope) SCOPE="${2:?missing scope}" shift 2 ;; --reports-dir) REPORTS_DIR="${2:?missing reports dir}" shift 2 ;; --strict) FAIL_ON_FINDINGS=1 shift ;; --report-only) FAIL_ON_FINDINGS=0 shift ;; -h|--help) cat <<'EOF' Usage: tools/checks/check-security-audit.sh [--mode ci|quick|full] [--scope current|govoplan] [--reports-dir DIR] [--strict|--report-only] Runs GovOPlaN static/security audit tools and writes machine-readable reports. Modes: quick Semgrep local rules, Bandit, Ruff security rules, Gitleaks ci quick + Semgrep registry auto config, Trivy, dependency manifest scans full ci + OSV-Scanner, jscpd duplicate scan, Radon/Xenon complexity reports Scopes: current scan this repository checkout govoplan scan sibling govoplan-* repositories below GOVOPLAN_REPOS_ROOT or the parent of this repo By default findings are reported but do not fail the process. Use --strict or SECURITY_AUDIT_FAIL_ON_FINDINGS=1 once the baseline is clean. EOF exit 0 ;; *) echo "Unknown argument: $1" >&2 exit 2 ;; esac done case "$MODE" in quick|ci|full) ;; *) echo "Unknown SECURITY_AUDIT_MODE=$MODE; expected quick, ci, or full." >&2 exit 2 ;; esac case "$SCOPE" in current|govoplan) ;; *) echo "Unknown SECURITY_AUDIT_SCOPE=$SCOPE; expected current or govoplan." >&2 exit 2 ;; esac if [[ "$REPORTS_DIR" != /* ]]; then REPORTS_DIR="$ROOT/$REPORTS_DIR" fi mkdir -p "$REPORTS_DIR" declare -a REPOS=() if [[ "$SCOPE" == "govoplan" ]]; then REPOS_ROOT="${GOVOPLAN_REPOS_ROOT:-$(dirname "$ROOT")}" [[ -d "$ROOT/.git" ]] && REPOS+=("$ROOT") while IFS= read -r repo; do [[ -d "$repo/.git" ]] || continue REPOS+=("$repo") done < <(find "$REPOS_ROOT" -maxdepth 1 -type d \( -name 'govoplan-*' -o -name 'addideas-govoplan-*' \) | sort) else REPOS=("$ROOT") fi if [[ "${#REPOS[@]}" -eq 0 ]]; then echo "No repositories found for scope $SCOPE." >&2 exit 1 fi echo "Security audit mode: $MODE" echo "Security audit scope: $SCOPE" echo "Security audit reports: $REPORTS_DIR" echo "Security audit repositories: ${#REPOS[@]}" if [[ "${#REPOS[@]}" -le 12 ]]; then printf ' %s\n' "${REPOS[@]}" fi declare -a PY_ROOTS=() for repo in "${REPOS[@]}"; do [[ -d "$repo/src" ]] && PY_ROOTS+=("$repo/src") [[ -d "$repo/tests" ]] && PY_ROOTS+=("$repo/tests") done safe_name() { basename "$1" | tr -c 'A-Za-z0-9_.-' '_' } has_tool() { command -v "$1" >/dev/null 2>&1 } overall_status=0 finding_status=0 missing_status=0 run_step() { local name="$1" shift echo echo "==> $name" "$@" local status=$? if [[ "$status" -ne 0 ]]; then echo "Audit step reported findings or failed: $name (exit $status)" >&2 finding_status=1 if [[ "$FAIL_ON_FINDINGS" == "1" ]]; then overall_status=1 fi fi } skip_or_fail_missing() { local tool="$1" echo "Skipping $tool: command not found. Use tools/checks/security-audit/run.sh for the containerized toolbox." >&2 if [[ "$REQUIRE_TOOLS" == "1" ]]; then missing_status=1 overall_status=1 fi } write_manifest() { { printf '{\n' printf ' "mode": "%s",\n' "$MODE" printf ' "scope": "%s",\n' "$SCOPE" printf ' "fail_on_findings": "%s",\n' "$FAIL_ON_FINDINGS" printf ' "repositories": [\n' local index=0 for repo in "${REPOS[@]}"; do [[ "$index" -gt 0 ]] && printf ',\n' printf ' "%s"' "$repo" index=$((index + 1)) done printf '\n ]\n' printf '}\n' } > "$REPORTS_DIR/manifest.json" } run_semgrep() { local report="$REPORTS_DIR/semgrep.sarif" local config_args=(--config "$ROOT/tools/checks/security-audit/semgrep-govoplan.yml") if [[ "$MODE" != "quick" ]]; then config_args+=(--config p/default --config p/owasp-top-ten) fi semgrep scan \ --metrics=off \ --sarif \ --output "$report" \ --exclude node_modules \ --exclude .venv \ --exclude dist \ --exclude build \ --exclude runtime \ "${config_args[@]}" \ "${REPOS[@]}" } run_bandit() { [[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0 bandit -r "${PY_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json" } run_ruff_security() { [[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0 ruff check --select S --output-format json "${PY_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json" } run_gitleaks() { local status=0 for repo in "${REPOS[@]}"; do local name name="$(safe_name "$repo")" if gitleaks git --help >/dev/null 2>&1; then gitleaks git \ --config "$ROOT/.gitleaks.toml" \ --report-format json \ --report-path "$REPORTS_DIR/gitleaks-$name.json" \ --no-banner \ "$repo" || status=1 else gitleaks detect \ --source "$repo" \ --config "$ROOT/.gitleaks.toml" \ --report-format json \ --report-path "$REPORTS_DIR/gitleaks-$name.json" \ --no-banner || status=1 fi done return "$status" } run_trivy() { local status=0 for repo in "${REPOS[@]}"; do local name name="$(safe_name "$repo")" trivy fs \ --scanners vuln,secret,misconfig \ --skip-dirs node_modules \ --skip-dirs .venv \ --skip-dirs dist \ --skip-dirs build \ --format sarif \ --output "$REPORTS_DIR/trivy-$name.sarif" \ "$repo" || status=1 done return "$status" } run_pip_audit_manifests() { local status=0 for repo in "${REPOS[@]}"; do [[ -f "$repo/requirements.txt" ]] || continue local name name="$(safe_name "$repo")" pip-audit -r "$repo/requirements.txt" --progress-spinner off --format json --output "$REPORTS_DIR/pip-audit-$name.json" || status=1 done return "$status" } run_npm_audit_manifests() { local status=0 for repo in "${REPOS[@]}"; do [[ -f "$repo/webui/package-lock.json" ]] || continue local name name="$(safe_name "$repo")" (cd "$repo/webui" && npm audit --omit=dev --json > "$REPORTS_DIR/npm-audit-$name.json") || status=1 done return "$status" } run_osv_scanner() { local status=0 for repo in "${REPOS[@]}"; do local name name="$(safe_name "$repo")" if osv-scanner scan --help >/dev/null 2>&1; then osv-scanner scan -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1 else osv-scanner -r --format json --output "$REPORTS_DIR/osv-scanner-$name.json" "$repo" || status=1 fi done return "$status" } run_jscpd() { jscpd \ --silent \ --min-tokens 80 \ --reporters json \ --output "$REPORTS_DIR/jscpd" \ --ignore "**/.git/**,**/node_modules/**,**/.venv/**,**/dist/**,**/build/**,**/audit-reports/**,**/package-lock.json,**/package-lock.release.json,**/generatedTranslations.ts,**/docs/gitea-labels.json" \ "${REPOS[@]}" } run_radon() { [[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0 radon cc -s -a "${PY_ROOTS[@]}" > "$REPORTS_DIR/radon-cc.txt" } run_xenon() { [[ "${#PY_ROOTS[@]}" -gt 0 ]] || return 0 xenon --max-absolute C --max-modules B --max-average A "${PY_ROOTS[@]}" > "$REPORTS_DIR/xenon.txt" 2>&1 } write_manifest if has_tool semgrep; then run_step "Semgrep SAST" run_semgrep else skip_or_fail_missing semgrep fi if has_tool bandit; then run_step "Bandit Python security scan" run_bandit else skip_or_fail_missing bandit fi if has_tool ruff; then run_step "Ruff flake8-bandit security rules" run_ruff_security else skip_or_fail_missing ruff fi if has_tool gitleaks; then run_step "Gitleaks secret scan" run_gitleaks else skip_or_fail_missing gitleaks fi if [[ "$MODE" != "quick" ]]; then if has_tool trivy; then run_step "Trivy filesystem vulnerability/secret/misconfig scan" run_trivy else skip_or_fail_missing trivy fi if has_tool pip-audit; then run_step "pip-audit requirements scan" run_pip_audit_manifests else skip_or_fail_missing pip-audit fi if has_tool npm; then run_step "npm audit lockfile scan" run_npm_audit_manifests else skip_or_fail_missing npm fi fi if [[ "$MODE" == "full" ]]; then if has_tool osv-scanner; then run_step "OSV-Scanner recursive dependency scan" run_osv_scanner else skip_or_fail_missing osv-scanner fi if has_tool jscpd; then run_step "jscpd duplicate code scan" run_jscpd else skip_or_fail_missing jscpd fi if has_tool radon; then run_step "Radon complexity report" run_radon else skip_or_fail_missing radon fi if has_tool xenon; then run_step "Xenon complexity threshold scan" run_xenon else skip_or_fail_missing xenon fi fi echo echo "Security audit reports written to: $REPORTS_DIR" if [[ "$finding_status" -ne 0 && "$FAIL_ON_FINDINGS" != "1" ]]; then echo "Findings were reported, but this run is report-only. Re-run with --strict to fail on findings." fi if [[ "$missing_status" -ne 0 ]]; then echo "One or more required tools were missing." >&2 fi exit "$overall_status"