Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Security

Security #82

Workflow file for this run

# Security Scanning Workflow
# Runs SAST, dependency scanning, container scanning, and DAST on PRs
# Policy: Vulnerabilities rated MEDIUM or higher block merges to main
# Configure branch protection to require "Security Status" check
name: Security
on:
pull_request:
branches: [main]
paths-ignore:
- '**/*.md'
push:
branches: [main]
paths-ignore:
- '**/*.md'
schedule:
# Weekly scan on Monday at midnight UTC to catch new CVEs
- cron: "0 0 * * 1"
workflow_dispatch:
# Allow other workflows to call this one
workflow_call:
inputs:
severity:
description: 'Minimum severity to fail on (high or medium)'
required: false
default: 'medium'
type: string
skip_dast:
description: 'Skip DAST scan for faster execution'
required: false
default: false
type: boolean
skip_codeql:
description: 'Skip CodeQL scan (for cases where code was already scanned)'
required: false
default: false
type: boolean
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
permissions: read-all
jobs:
# ============================================================================
# Detect which files changed - skip most scans if no security-relevant files
# Note: CodeQL always runs (required by Code Scanning ruleset)
# ============================================================================
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
security_relevant: ${{ steps.result.outputs.security_relevant }}
steps:
- uses: actions/checkout@v6
- name: Check if explicit trigger
id: explicit
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \
[ "${{ github.event_name }}" = "workflow_call" ] || \
[ "${{ github.event_name }}" = "schedule" ]; then
echo "is_explicit=true" >> $GITHUB_OUTPUT
else
echo "is_explicit=false" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter
if: steps.explicit.outputs.is_explicit == 'false'
with:
filters: |
security_relevant:
- '**.ts'
- '**.tsx'
- '**.js'
- '**.mjs'
- '**/package*.json'
- 'Dockerfile'
- name: Determine if scans should run
id: result
run: |
if [ "${{ steps.explicit.outputs.is_explicit }}" = "true" ]; then
echo "security_relevant=true" >> $GITHUB_OUTPUT
echo "Explicit trigger - running all scans"
else
echo "security_relevant=${{ steps.filter.outputs.security_relevant }}" >> $GITHUB_OUTPUT
echo "Paths filter result: ${{ steps.filter.outputs.security_relevant }}"
fi
# ============================================================================
# CodeQL - Static Application Security Testing (SAST)
# Scans for SQL injection, XSS, command injection, path traversal, etc.
# Results appear in GitHub Security tab
# ============================================================================
codeql:
name: CodeQL
if: ${{ !inputs.skip_codeql }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
security-events: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: javascript-typescript
queries: security-extended
config-file: ./.github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:javascript-typescript"
# ============================================================================
# Secret Scanning with Gitleaks
# Detects hardcoded secrets, API keys, passwords in code
# ============================================================================
secret-scan:
name: Secret Scan
needs: changes
if: needs.changes.outputs.security_relevant == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar xz
sudo mv gitleaks /usr/local/bin/
- name: Run Gitleaks
run: gitleaks detect --source . --verbose --redact
# ============================================================================
# Dependency Scanning
# Checks npm packages for known vulnerabilities
# ============================================================================
dependency-scan:
name: Dependency Scan
needs: changes
if: needs.changes.outputs.security_relevant == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install dependencies
run: npm ci
- name: Run npm audit (production dependencies)
run: |
LEVEL="${{ inputs.severity || 'medium' }}"
if [ "$LEVEL" = "high" ]; then
npm audit --audit-level=high --omit=dev
else
npm audit --audit-level=moderate --omit=dev
fi
# ============================================================================
# Container Scanning with Trivy
# Scans Docker image for OS and application vulnerabilities
# ============================================================================
container-scan:
name: Container Scan
needs: changes
if: needs.changes.outputs.security_relevant == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
security-events: write
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
push: false
load: true
tags: swapify:scan
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Determine severity threshold
id: severity
run: |
LEVEL="${{ inputs.severity || 'medium' }}"
if [ "$LEVEL" = "high" ]; then
echo "trivy_severity=HIGH,CRITICAL" >> $GITHUB_OUTPUT
else
echo "trivy_severity=MEDIUM,HIGH,CRITICAL" >> $GITHUB_OUTPUT
fi
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: swapify:scan
format: "table"
exit-code: "1"
severity: ${{ steps.severity.outputs.trivy_severity }}
ignore-unfixed: true
trivy-config: config/trivy.yaml
- name: Run Trivy and upload SARIF
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
if: always()
with:
image-ref: swapify:scan
format: "sarif"
output: "trivy-results.sarif"
severity: ${{ steps.severity.outputs.trivy_severity }}
trivy-config: config/trivy.yaml
- name: Upload Trivy SARIF to GitHub Security tab
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3
if: always() && github.event_name != 'workflow_call'
with:
sarif_file: "trivy-results.sarif"
category: "trivy-container-scan"
# ============================================================================
# DAST - Dynamic Application Security Testing with OWASP ZAP
# Runs penetration testing against the running application
# Only runs on non-fork PRs to prevent abuse
# ============================================================================
dast:
name: DAST (OWASP ZAP)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [changes, container-scan]
if: |
needs.changes.outputs.security_relevant == 'true' &&
inputs.skip_dast != true && (
github.event_name == 'push' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
github.event_name == 'workflow_call' ||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
)
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
push: false
load: true
tags: swapify:test
cache-from: type=gha
- name: Start application
run: |
echo "Starting container..."
docker run -d --name swapify -p 3000:3000 \
-e IRON_SESSION_PASSWORD=test-password-that-is-at-least-32-characters-long \
-e POLL_SECRET=test-poll-secret-16chars \
-e NEXT_PUBLIC_APP_URL=http://localhost:3000 \
swapify:test
echo "Container started with ID: $(docker ps -q -f name=swapify)"
- name: Wait for application to be healthy
run: |
echo "=== Container status ==="
docker ps -a
echo ""
echo "=== Waiting for application to start ==="
for i in {1..30}; do
echo "Attempt $i/30..."
if ! docker ps -q -f name=swapify | grep -q .; then
echo "ERROR: Container is not running!"
echo ""
echo "=== Container logs ==="
docker logs swapify 2>&1 || echo "No logs available"
exit 1
fi
if curl -sf http://localhost:3000/api/health; then
echo ""
echo "Application is healthy!"
exit 0
fi
echo " Health check failed, waiting 2s..."
sleep 2
done
echo ""
echo "=== TIMEOUT: Application failed to become healthy ==="
echo ""
echo "=== Container logs ==="
docker logs swapify 2>&1
exit 1
- name: Run OWASP ZAP Baseline Scan
uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 # v0.15.0
with:
target: "http://localhost:3000"
rules_file_name: ".zap/rules.tsv"
fail_action: true
cmd_options: "-a"
- name: Stop application
if: always()
run: docker stop swapify || true
# ============================================================================
# Security Status - Aggregates all security job results
# Configure branch protection to require this check
# ============================================================================
security-status:
name: Security Status
runs-on: ubuntu-latest
timeout-minutes: 5
needs: [changes, codeql, secret-scan, dependency-scan, container-scan, dast]
if: always()
steps:
- name: Check security scan results
run: |
echo "=== Security Scan Results ==="
echo "Security-relevant files changed: ${{ needs.changes.outputs.security_relevant }}"
echo "CodeQL: ${{ needs.codeql.result }}"
echo "Secret Scan: ${{ needs.secret-scan.result }}"
echo "Dependency Scan: ${{ needs.dependency-scan.result }}"
echo "Container Scan: ${{ needs.container-scan.result }}"
echo "DAST: ${{ needs.dast.result }}"
echo ""
# CodeQL can be skipped via workflow_call input
if [ "${{ needs.codeql.result }}" = "failure" ] || [ "${{ needs.codeql.result }}" = "cancelled" ]; then
echo "::error::CodeQL scan failed"
exit 1
fi
# If no security-relevant files changed, other scans were skipped - that's OK
if [ "${{ needs.changes.outputs.security_relevant }}" != "true" ]; then
if [ "${{ needs.codeql.result }}" = "skipped" ]; then
echo "All scans skipped (no security-relevant files)"
else
echo "CodeQL passed, other scans skipped (no security-relevant files)"
fi
exit 0
fi
# Check core scans (DAST is allowed to be skipped for fork PRs)
results="${{ needs.secret-scan.result }} ${{ needs.dependency-scan.result }} ${{ needs.container-scan.result }}"
dast_result="${{ needs.dast.result }}"
if echo "$results" | grep -qE '(failure|cancelled)'; then
echo "::error::One or more security scans failed"
exit 1
fi
# DAST failure should also fail, but skipped is OK
if [ "$dast_result" = "failure" ] || [ "$dast_result" = "cancelled" ]; then
echo "::error::DAST scan failed or was cancelled"
exit 1
fi
echo "All security scans passed"