diff --git a/.github/CI_SECURITY.md b/.github/CI_SECURITY.md new file mode 100644 index 00000000..27f417e0 --- /dev/null +++ b/.github/CI_SECURITY.md @@ -0,0 +1,36 @@ +# CI/CD Security + +## How It Works + +**ALL PRs require the `deploy-preview` label to deploy.** + +Only repository maintainers can add labels, so this prevents unauthorized deployments. + +``` +PR opened β†’ No deployment +Maintainer adds 'deploy-preview' label β†’ Deployment starts +``` + +## Why Attackers Can't Bypass This + +The workflow uses `pull_request_target` which means: +- **Workflow code runs from main branch**, not from the PR +- Even if an attacker modifies the workflow file to remove the label check, the main branch version executes +- They can't bypass security by changing `.github/workflows/*` in their PR + +## For Maintainers + +Before adding the `deploy-preview` label, review: + +1. **Code changes** - Look for suspicious network calls, env var access, or dynamic code execution +2. **Dependency changes** - Check `package.json` for new/changed packages +3. **The contributor** - Verify their GitHub profile if unfamiliar + +## If Something Goes Wrong + +1. Remove the `deploy-preview` label +2. Delete the Cloud Run service: + ```bash + gcloud run services delete api- --region=europe-west2 --quiet + ``` +3. Rotate any potentially compromised secrets diff --git a/.github/project-config.yml b/.github/project-config.yml index b59b50fd..ed7bc1f5 100644 --- a/.github/project-config.yml +++ b/.github/project-config.yml @@ -53,11 +53,11 @@ project: environment_prefix: "VITE_" # GitHub Secrets Used (for documentation) -# These secrets must be configured in your GitHub repository: secrets: required: - GCP_SA_KEY # Google Cloud service account JSON key - - GCP_PROJECT_ID # Google Cloud project ID (should match project.gcp.project_id) - FIREBASE_SERVICE_ACCOUNT_KEY # Firebase service account JSON key - optional: - - GITHUB_TOKEN # Automatically provided by GitHub Actions \ No newline at end of file + - JWT_SECRET # JWT authentication secret + - REFRESH_SECRET # Refresh token secret + - NEON_DATABASE_URL # Database connection string + - GEMINI_API_KEY # Google Gemini API key \ No newline at end of file diff --git a/.github/workflows/deploy-branch-preview.yml b/.github/workflows/deploy-branch-preview.yml index 120dcb2a..211fa0a0 100644 --- a/.github/workflows/deploy-branch-preview.yml +++ b/.github/workflows/deploy-branch-preview.yml @@ -1,5 +1,8 @@ name: Deploy Branch Preview +# SECURITY: Using pull_request_target means this workflow code ALWAYS runs from main branch +# Even if an attacker modifies this file in their PR, the main branch version executes +# This protects the label check from being bypassed on: pull_request_target: types: [opened, synchronize, reopened, labeled] @@ -15,99 +18,51 @@ permissions: jobs: check-authorization: runs-on: ubuntu-latest - # Skip this job if triggered by a label event that's not the deploy-preview label + # Only run on label events if it's the deploy-preview label if: github.event_name != 'labeled' || github.event.label.name == 'deploy-preview' outputs: authorized: ${{ steps.check.outputs.authorized }} steps: - - name: πŸ” Check if deployment is authorized + - name: πŸ” Check for deploy-preview label id: check uses: actions/github-script@v7 with: script: | const prNumber = context.payload.pull_request.number; - const prAuthor = context.payload.pull_request.user.login; - // Check if author is a collaborator - let isCollaborator = false; - try { - const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: prAuthor - }); - // Collaborators have 'admin', 'write', or 'read' permission - isCollaborator = ['admin', 'write', 'read'].includes(permission.permission); - console.log(`πŸ‘€ User ${prAuthor} permission level: ${permission.permission}`); - } catch (error) { - console.log(`πŸ‘€ User ${prAuthor} is not a collaborator`); - } - - // If user is a collaborator, authorize immediately - if (isCollaborator) { - console.log(`βœ… Authorized: ${prAuthor} is a repository collaborator`); - core.setOutput('authorized', 'true'); - return; - } - - // For external contributors, check for the deploy-preview label - console.log(`πŸ” External contributor detected: ${prAuthor}`); const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber }); - const hasDeployLabel = labels.some(label => label.name === 'deploy-preview'); if (hasDeployLabel) { - console.log(`βœ… Authorized: PR #${prNumber} has 'deploy-preview' label`); + console.log(`βœ… PR #${prNumber} has deploy-preview label`); core.setOutput('authorized', 'true'); } else { - console.log(`⏸️ Not authorized: External contributor without 'deploy-preview' label`); - console.log(`ℹ️ A maintainer must add the 'deploy-preview' label to trigger deployment`); + console.log(`⏸️ Missing deploy-preview label`); core.setOutput('authorized', 'false'); - // Post a comment explaining the situation - try { - const { data: comments } = await github.rest.issues.listComments({ + // Post comment if not already posted + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + if (!comments.find(c => c.body.includes(''))) { + await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: prNumber - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('') - ); - - const commentBody = `## πŸ” Deployment Authorization Required - -Thank you for your contribution! πŸ™Œ + issue_number: prNumber, + body: `## πŸ” Deployment Authorization Required -As an external contributor, your PR requires manual approval before deployment previews are created. +To deploy a preview, a maintainer must add the \`deploy-preview\` label. -**For maintainers:** Add the \`deploy-preview\` label to this PR to trigger the deployment workflow. - ---- -*This is an automated security measure to prevent abuse of CI/CD resources.* - -`; - - if (!botComment) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: commentBody - }); - } - } catch (error) { - console.log('Failed to post comment:', error.message); +` + }); } - - // Don't fail the job - just set authorized to false and let deploy job skip - core.notice('⏸️ Deployment paused - waiting for deploy-preview label'); } deploy: @@ -119,9 +74,6 @@ As an external contributor, your PR requires manual approval before deployment p deployment_type: 'branch' branch_name: ${{ github.head_ref }} pr_number: ${{ github.event.number }} - # Use GitHub Environment for one-time approval per PR - # After initial approval, all subsequent commits auto-deploy - environment: 'preview-deployments' test: needs: deploy diff --git a/.github/workflows/reusable-deploy.yml b/.github/workflows/reusable-deploy.yml index 949bd52c..b8778686 100644 --- a/.github/workflows/reusable-deploy.yml +++ b/.github/workflows/reusable-deploy.yml @@ -193,9 +193,6 @@ jobs: fi fi - # Deploy the service with environment variables - # Note: CORS is handled by fallback Firebase patterns in server.js - # TODO: Implement proper CORS_ORIGINS env var handling (JSON array escaping issue) gcloud run deploy $SERVICE_NAME \ --image $IMAGE_NAME \ --platform managed \