name: Security Headers Validation on: workflow_call: # Called by release-gate.yml schedule: - cron: '0 3 * * *' # Daily scan for early detection workflow_dispatch: # Manual trigger for debugging/verification permissions: contents: read # NOTE: No concurrency block here. When called via workflow_call from # release-gate.yml, the caller's workflow name is used for the concurrency # group, which can cause this job to be cancelled if another release gate # run starts on the same ref. The parent workflow manages concurrency. jobs: validate-headers: runs-on: ubuntu-latest timeout-minutes: 15 services: postgres: image: postgres:13@sha256:4689940c683801b4ab839ab3b0a0a3555a5fe425371422310944e89eca7d8068 # v13 env: POSTGRES_USER: ldr_test POSTGRES_PASSWORD: ${{ github.run_id }}_test_pwd POSTGRES_DB: test_db options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python 3.12 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' - name: Cache pip packages uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-headers-${{ hashFiles('**/pyproject.toml') }} restore-keys: | ${{ runner.os }}-pip-headers- ${{ runner.os }}-pip- - name: Set up PDM uses: pdm-project/setup-pdm@973541a5febeafcfdadf8a51211435be6ecfd90f # v4.5 with: python-version: '3.12' - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libsqlcipher-dev build-essential - name: Install dependencies run: | pdm install --dev - name: Set up test environment run: | mkdir -p data mkdir -p research_outputs cp -r src/local_deep_research/defaults/.env.template .env.test - name: Start Flask application in background run: | # Use FLASK_DEBUG=0 (FLASK_ENV is deprecated) export FLASK_DEBUG=0 export DATABASE_URL=postgresql://ldr_test:${{ github.run_id }}_test_pwd@localhost:5432/test_db pdm run python -m local_deep_research.web.app & echo $! > flask.pid - name: Wait for application to be ready run: | timeout 60 bash -c 'until curl -fsS --connect-timeout 2 --max-time 5 http://localhost:5000/api/v1/health > /dev/null; do sleep 2; done' - name: Validate security headers run: | echo "=== Testing Security Headers ===" echo "" # Capture headers once for efficiency (with timeout) HEADERS=$(curl -sI --max-time 10 http://localhost:5000/) echo "Full response headers:" echo "$HEADERS" echo "" # Check for required security headers and validate values ERRORS=() # X-Frame-Options - must be SAMEORIGIN or DENY XFO=$(echo "$HEADERS" | grep -i "^X-Frame-Options:" | cut -d':' -f2- | tr -d '\r' | xargs) if [[ -z "$XFO" ]]; then ERRORS+=("X-Frame-Options: MISSING") elif [[ "$XFO" != "SAMEORIGIN" && "$XFO" != "DENY" ]]; then ERRORS+=("X-Frame-Options: Invalid value '$XFO' (expected SAMEORIGIN or DENY)") else echo "✅ X-Frame-Options: $XFO" fi # X-Content-Type-Options - must be nosniff XCTO=$(echo "$HEADERS" | grep -i "^X-Content-Type-Options:" | cut -d':' -f2- | tr -d '\r' | xargs) if [[ -z "$XCTO" ]]; then ERRORS+=("X-Content-Type-Options: MISSING") elif [[ "$XCTO" != "nosniff" ]]; then ERRORS+=("X-Content-Type-Options: Invalid value '$XCTO' (expected nosniff)") else echo "✅ X-Content-Type-Options: $XCTO" fi # Referrer-Policy - must not be unsafe RP=$(echo "$HEADERS" | grep -i "^Referrer-Policy:" | cut -d':' -f2- | tr -d '\r' | xargs) if [[ -z "$RP" ]]; then ERRORS+=("Referrer-Policy: MISSING") elif [[ "$RP" == "unsafe-url" || "$RP" == "no-referrer-when-downgrade" ]]; then ERRORS+=("Referrer-Policy: Insecure value '$RP'") else echo "✅ Referrer-Policy: $RP" fi # Permissions-Policy - must restrict dangerous features PP=$(echo "$HEADERS" | grep -i "^Permissions-Policy:" | cut -d':' -f2- | tr -d '\r' | xargs) if [[ -z "$PP" ]]; then ERRORS+=("Permissions-Policy: MISSING") elif [[ "$PP" != *"geolocation=()"* ]]; then ERRORS+=("Permissions-Policy: Should restrict geolocation") else echo "✅ Permissions-Policy: $PP" fi # Content-Security-Policy - must have default-src and not be overly permissive CSP=$(echo "$HEADERS" | grep -i "^Content-Security-Policy:" | cut -d':' -f2- | tr -d '\r' | xargs) if [[ -z "$CSP" ]]; then ERRORS+=("Content-Security-Policy: MISSING") elif [[ "$CSP" == *"default-src *"* || "$CSP" == *"default-src 'unsafe-inline' 'unsafe-eval'"* ]]; then ERRORS+=("Content-Security-Policy: Overly permissive policy detected") elif [[ "$CSP" != *"default-src"* ]]; then ERRORS+=("Content-Security-Policy: Missing default-src directive") else echo "✅ Content-Security-Policy: Present with default-src" fi # Report results echo "" if [ ${#ERRORS[@]} -eq 0 ]; then echo "✅ All required security headers are present and valid" else echo "❌ Security header issues found:" for error in "${ERRORS[@]}"; do echo " - $error" done exit 1 fi - name: Test API endpoint headers run: | echo "=== Testing Security Headers on API Endpoints ===" echo "" # Test health endpoint (with timeout) API_HEADERS=$(curl -sI --max-time 10 http://localhost:5000/api/health) if echo "$API_HEADERS" | grep -iq "^X-Frame-Options:"; then echo "✅ Security headers present on API endpoints" else echo "❌ Security headers missing on API endpoints" echo "$API_HEADERS" exit 1 fi - name: Cleanup if: always() run: | if [ -f flask.pid ]; then kill "$(cat flask.pid)" 2>/dev/null || true rm -f flask.pid fi