This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Security #82
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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" |