Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/CI_SECURITY.md
Original file line number Diff line number Diff line change
@@ -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-<branch-name> --region=europe-west2 --quiet
```
3. Rotate any potentially compromised secrets
8 changes: 4 additions & 4 deletions .github/project-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
- JWT_SECRET # JWT authentication secret
- REFRESH_SECRET # Refresh token secret
- NEON_DATABASE_URL # Database connection string
- GEMINI_API_KEY # Google Gemini API key
90 changes: 21 additions & 69 deletions .github/workflows/deploy-branch-preview.yml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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('<!-- DEPLOY_AUTH -->'))) {
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('<!-- DEPLOY_AUTHORIZATION_NEEDED -->')
);

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.*

<!-- DEPLOY_AUTHORIZATION_NEEDED -->`;

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);
<!-- DEPLOY_AUTH -->`
});
}

// 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:
Expand All @@ -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
Expand Down
3 changes: 0 additions & 3 deletions .github/workflows/reusable-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down