Files
govoplan/tools/checks/check-security-audit.sh
Albrecht Degering 05ae81d641
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 16s
Security Audit / security-audit (push) Failing after 11s
intermediate commit
2026-07-14 13:32:09 +02:00

423 lines
10 KiB
Bash

#!/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=()
declare -a PY_PROD_ROOTS=()
declare -a PY_TEST_ROOTS=()
for repo in "${REPOS[@]}"; do
if [[ -d "$repo/src" ]]; then
PY_ROOTS+=("$repo/src")
PY_PROD_ROOTS+=("$repo/src")
fi
if [[ -d "$repo/tests" ]]; then
PY_ROOTS+=("$repo/tests")
PY_TEST_ROOTS+=("$repo/tests")
fi
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() {
local status=0
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
bandit -r "${PY_PROD_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit.json" || status=1
fi
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
bandit -r "${PY_TEST_ROOTS[@]}" -f json -o "$REPORTS_DIR/bandit-tests.json" || true
fi
return "$status"
}
run_ruff_security() {
local status=0
if [[ "${#PY_PROD_ROOTS[@]}" -gt 0 ]]; then
ruff check --select S --output-format json "${PY_PROD_ROOTS[@]}" > "$REPORTS_DIR/ruff-security.json" || status=1
fi
if [[ "${#PY_TEST_ROOTS[@]}" -gt 0 ]]; then
ruff check --select S --output-format json "${PY_TEST_ROOTS[@]}" > "$REPORTS_DIR/ruff-security-tests.json" || true
fi
return "$status"
}
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() {
local ignored_paths
ignored_paths=(
"**/.git/**"
"**/node_modules/**"
"**/.venv/**"
"**/dist/**"
"**/build/**"
"**/audit-reports/**"
"**/package-lock.json"
"**/package-lock.release.json"
"**/package.json"
"package.json"
"**/generatedTranslations.ts"
"**/docs/gitea-labels.json"
"**/*.md"
"**/*.md:*"
"*.md"
"*.md:*"
"**/*.svg"
"*.svg"
".gitea/workflows/*.yml"
"**/.gitea/workflows/*.yml"
"**/backend/schema/*.schema.json"
)
local ignored_path_pattern
ignored_path_pattern="$(printf '%s,' "${ignored_paths[@]}")"
ignored_path_pattern="${ignored_path_pattern%,}"
jscpd \
--silent \
--min-tokens 80 \
--reporters json \
--output "$REPORTS_DIR/jscpd" \
--ignore "$ignored_path_pattern" \
"${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"