#!/usr/bin/env bash set -u ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$ROOT_DIR" BASE_URL="${1:-http://localhost:8081}" REPORT_DIR="${REPORT_DIR:-reports/security}" TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")" TS_FILE="$(date -u +"%Y%m%d-%H%M%S")" REPORT_FILE="${REPORT_DIR}/security-smoke-${TS_FILE}.md" mkdir -p "$REPORT_DIR" failures=() warnings=() passes=() add_pass() { passes+=("$1"); } add_warn() { warnings+=("$1"); } add_fail() { failures+=("$1"); } lower() { echo "$1" | tr '[:upper:]' '[:lower:]' } read_env_var() { local key="$1" if [[ ! -f ".env" ]]; then echo "" return 0 fi grep -E "^${key}=" .env | tail -n1 | cut -d= -f2- || true } is_truthy() { local value value="$(lower "${1:-}")" [[ "$value" == "true" || "$value" == "1" || "$value" == "yes" || "$value" == "on" ]] } http_status_ok() { local url="$1" local code code="$(curl -k -sS -o /dev/null -w "%{http_code}" "$url" || true)" [[ "$code" == "200" ]] } check_required_headers() { local url="$1" local head head="$(curl -k -sS -I "$url" || true)" local normalized normalized="$(echo "$head" | tr -d '\r' | tr '[:upper:]' '[:lower:]')" if [[ "$normalized" == *"x-content-type-options: nosniff"* ]]; then add_pass "header: X-Content-Type-Options=nosniff" else add_fail "missing/invalid header X-Content-Type-Options at ${url}" fi if [[ "$normalized" == *"x-frame-options:"* ]]; then add_pass "header: X-Frame-Options present" else add_fail "missing header X-Frame-Options at ${url}" fi if [[ "$normalized" == *"referrer-policy:"* ]]; then add_pass "header: Referrer-Policy present" else add_fail "missing header Referrer-Policy at ${url}" fi if [[ "$normalized" == *"content-security-policy:"* ]]; then add_pass "header: Content-Security-Policy present" else add_fail "missing header Content-Security-Policy at ${url}" fi } check_tls_cert() { local url="$1" if [[ "$url" != https://* ]]; then add_warn "tls check skipped (BASE_URL is not https): ${url}" return 0 fi local hostport host port cert not_after hostport="${url#https://}" hostport="${hostport%%/*}" host="${hostport%%:*}" port="443" if [[ "$hostport" == *:* ]]; then port="${hostport##*:}" fi cert="$(echo | openssl s_client -connect "${host}:${port}" -servername "${host}" 2>/dev/null || true)" if [[ "$cert" != *"BEGIN CERTIFICATE"* ]]; then add_fail "tls certificate is not available for ${host}:${port}" return 1 fi not_after="$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2- || true)" if [[ -z "$not_after" ]]; then add_fail "tls certificate enddate cannot be read for ${host}:${port}" return 1 fi local days_left days_left="$(python3 - </dev/null 2>&1; then add_warn "docker is not available: service checks skipped" return 0 fi local running running="$(docker compose ps --services --filter status=running 2>/dev/null || true)" if echo "$running" | grep -qx "$service"; then add_pass "service running: ${service}" return 0 fi add_fail "service is not running: ${service}" return 1 } check_db_security_audit_table() { if ! command -v docker >/dev/null 2>&1; then add_warn "docker is not available: DB checks skipped" return 0 fi if [[ ! -f ".env" ]]; then add_warn ".env not found: DB checks skipped" return 0 fi local pg_user pg_db pg_user="$(read_env_var POSTGRES_USER)" pg_db="$(read_env_var POSTGRES_DB)" pg_user="${pg_user:-postgres}" pg_db="${pg_db:-legal}" local exists exists="$(docker compose exec -T db psql -U "$pg_user" -d "$pg_db" -Atc "select to_regclass('public.security_audit_log') is not null;" 2>/dev/null || true)" if [[ "$exists" == "t" ]]; then add_pass "db table exists: security_audit_log" else add_fail "db table missing or inaccessible: security_audit_log" return 1 fi local recent recent="$(docker compose exec -T db psql -U "$pg_user" -d "$pg_db" -Atc "select count(*) from security_audit_log where created_at >= now() - interval '7 days';" 2>/dev/null || true)" if [[ "$recent" =~ ^[0-9]+$ ]]; then add_pass "db access: security_audit_log query ok (rows_7d=${recent})" else add_fail "db access error: cannot query security_audit_log" return 1 fi } check_attachment_scan_availability() { local scan_enabled clam_enabled scan_enabled="$(read_env_var ATTACHMENT_SCAN_ENABLED)" clam_enabled="$(read_env_var CLAMAV_ENABLED)" if is_truthy "$scan_enabled" || is_truthy "$clam_enabled"; then check_compose_service_running "clamav" else add_warn "attachment scan disabled by config (ATTACHMENT_SCAN_ENABLED/CLAMAV_ENABLED)" fi } run_smoke() { local health_url chat_health_url email_health_url health_url="${BASE_URL%/}/health" chat_health_url="${BASE_URL%/}/chat-health" email_health_url="${BASE_URL%/}/email-health" if http_status_ok "$health_url"; then add_pass "http 200: ${health_url}" else add_fail "http check failed: ${health_url}" fi if http_status_ok "$chat_health_url"; then add_pass "http 200: ${chat_health_url}" else add_fail "http check failed: ${chat_health_url}" fi if http_status_ok "$email_health_url"; then add_pass "http 200: ${email_health_url}" else add_fail "http check failed: ${email_health_url}" fi check_required_headers "$health_url" check_tls_cert "$BASE_URL" check_cookie_and_security_flags check_attachment_scan_availability check_db_security_audit_table } write_report() { { echo "# Security Smoke Report" echo echo "- Timestamp: ${TS_HUMAN}" echo "- Base URL: ${BASE_URL}" echo "- Result: $([[ ${#failures[@]} -eq 0 ]] && echo "PASS" || echo "FAIL")" echo echo "## Passed checks (${#passes[@]})" for item in "${passes[@]}"; do echo "- [x] ${item}" done echo echo "## Warnings (${#warnings[@]})" if [[ ${#warnings[@]} -eq 0 ]]; then echo "- none" else for item in "${warnings[@]}"; do echo "- [!] ${item}" done fi echo echo "## Failures (${#failures[@]})" if [[ ${#failures[@]} -eq 0 ]]; then echo "- none" else for item in "${failures[@]}"; do echo "- [ ] ${item}" done fi } > "$REPORT_FILE" } run_smoke write_report if [[ ${#failures[@]} -gt 0 ]]; then echo "[ALERT] Security smoke failed (${#failures[@]} failure(s)). Report: ${REPORT_FILE}" >&2 exit 2 fi echo "[OK] Security smoke passed. Report: ${REPORT_FILE}" exit 0