Skip to content

Undo the Codebuild Pull Request Regex hack #107

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
Empty file.
51 changes: 51 additions & 0 deletions cicd/2-cicd/authorizer/authorizer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import os
import json
import boto3
from urllib import request

# Lambda handler for PR authorizer
# Fetches GitHub token from Secrets Manager, checks PR author permission,
# and starts CodeBuild if writer/maintainer/admin.

def handler(event, context):
# Load env vars
secret_arn = os.environ['GITHUB_TOKEN_SECRET_ARN']
owner = os.environ['GitHubOwner']
repo = os.environ['GitHubRepo']
branch = os.environ['GitHubBranch']
project = os.environ['CODEBUILD_PROJECT']

# Fetch GitHub PAT from Secrets Manager
sm = boto3.client('secretsmanager')
secret = sm.get_secret_value(SecretId=secret_arn)
token = json.loads(secret['SecretString'])['token']

# Extract PR event details
detail = event.get('detail', {})
pr = detail.get('pull_request', {})

# Only handle events on the configured branch
if pr.get('base', {}).get('ref') != branch:
return

login = pr.get('user', {}).get('login')
if not login:
return

# Build GitHub API URL for collaborator permission
url = f"https://api.github.com/repos/{owner}/{repo}/collaborators/{login}/permission"
req = request.Request(
url,
headers={
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
)
# Call GitHub
with request.urlopen(req) as resp:
data = json.loads(resp.read().decode())

# Only allow write/maintain/admin
if data.get('permission') in ['write', 'maintain', 'admin']:
cb = boto3.client('codebuild')
cb.start_build(projectName=project)
106 changes: 106 additions & 0 deletions cicd/2-cicd/authorizer/tests/test_authorizer_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import os
import json
import boto3
import pytest
from moto import mock_secretsmanager, mock_codebuild
from authorizer.authorizer import handler
from urllib.error import URLError

# Simulated HTTP response for GitHub API
class DummyResponse:
def __init__(self, data):
self._data = data
def read(self):
return json.dumps(self._data).encode('utf-8')
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
pass

@pytest.fixture(autouse=True)
def set_env_vars(monkeypatch):
monkeypatch.setenv('GITHUB_TOKEN_SECRET_ARN', 'arn:aws:secretsmanager:us-east-1:123456:secret:githtoken')
monkeypatch.setenv('GitHubOwner', 'code-dot-org')
monkeypatch.setenv('GitHubRepo', 'aiproxy')
monkeypatch.setenv('GitHubBranch', 'main')
monkeypatch.setenv('CODEBUILD_PROJECT', 'pr-build-project')

@mock_secretsmanager
@mock_codebuild
def test_integration_starts_codebuild(monkeypatch):
# Setup SecretsManager
sm = boto3.client('secretsmanager', region_name='us-east-1')
sm.create_secret(Name='githtoken', SecretString=json.dumps({'token':'fakepat'}))

# Create CodeBuild project
cb = boto3.client('codebuild', region_name='us-east-1')
cb.create_project(
name='pr-build-project',
source={'type':'CODEPIPELINE'},
artifacts={'type':'NO_ARTIFACTS'},
environment={'type':'LINUX_CONTAINER','computeType':'BUILD_GENERAL1_SMALL','image':'aws/codebuild/amazonlinux2-x86_64-standard:5.0'}
)

# Stub urlopen to return write permission
def fake_urlopen(req):
return DummyResponse({'permission':'maintain'})
monkeypatch.setattr('authorizer.authorizer.request.urlopen', fake_urlopen)

# Simulate event
event = { 'detail': { 'pull_request': { 'base': { 'ref': 'main' }, 'user': { 'login': 'octocat' } } } }
handler(event, None)

# List builds to confirm start
builds = cb.list_builds_for_project(projectName='pr-build-project')['ids']
assert len(builds) == 1

@mock_secretsmanager
@mock_codebuild
def test_integration_no_start_on_bad_permission(monkeypatch):
# Setup SecretsManager
sm = boto3.client('secretsmanager', region_name='us-east-1')
sm.create_secret(Name='githtoken', SecretString=json.dumps({'token':'fakepat'}))

# Create CodeBuild project
cb = boto3.client('codebuild', region_name='us-east-1')
cb.create_project(
name='pr-build-project',
source={'type':'CODEPIPELINE'},
artifacts={'type':'NO_ARTIFACTS'},
environment={'type':'LINUX_CONTAINER','computeType':'BUILD_GENERAL1_SMALL','image':'aws/codebuild/amazonlinux2-x86_64-standard:5.0'}
)

# Stub urlopen to return read permission
def fake_urlopen(req):
return DummyResponse({'permission':'read'})
monkeypatch.setattr('authorizer.authorizer.request.urlopen', fake_urlopen)

event = { 'detail': { 'pull_request': { 'base': { 'ref': 'main' }, 'user': { 'login': 'octocat' } } } }
handler(event, None)

builds = cb.list_builds_for_project(projectName='pr-build-project')['ids']
assert len(builds) == 0

@mock_secretsmanager
@mock_codebuild
def test_integration_no_start_on_wrong_branch(monkeypatch):
# Setup SecretsManager and CodeBuild
sm = boto3.client('secretsmanager', region_name='us-east-1')
sm.create_secret(Name='githtoken', SecretString=json.dumps({'token':'fakepat'}))
cb = boto3.client('codebuild', region_name='us-east-1')
cb.create_project(
name='pr-build-project',
source={'type':'CODEPIPELINE'},
artifacts={'type':'NO_ARTIFACTS'},
environment={'type':'LINUX_CONTAINER','computeType':'BUILD_GENERAL1_SMALL','image':'aws/codebuild/amazonlinux2-x86_64-standard:5.0'}
)

# Stub urlopen
monkeypatch.setattr('authorizer.authorizer.request.urlopen', lambda req: DummyResponse({'permission':'admin'}))

# Wrong branch
event = { 'detail': { 'pull_request': { 'base': { 'ref': 'feature' }, 'user': { 'login': 'octocat' } } } }
handler(event, None)

builds = cb.list_builds_for_project(projectName='pr-build-project')['ids']
assert len(builds) == 0
83 changes: 83 additions & 0 deletions cicd/2-cicd/authorizer/tests/test_authorizer_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import os
import json
import pytest
from unittest.mock import patch, MagicMock
from authorizer.authorizer import handler

# Helper to build a minimal PR event
def make_event(login, ref="main"):
return {
"detail": {
"pull_request": {
"base": {"ref": ref},
"user": {"login": login}
}
}
}

@pytest.fixture(autouse=True)
def set_env_vars(monkeypatch):
monkeypatch.setenv('GITHUB_TOKEN_SECRET_ARN', 'arn:aws:secretsmanager:us-east-1:123:secret:test')
monkeypatch.setenv('GitHubOwner', 'code-dot-org')
monkeypatch.setenv('GitHubRepo', 'aiproxy')
monkeypatch.setenv('GitHubBranch', 'main')
monkeypatch.setenv('CODEBUILD_PROJECT', 'pr-build-project')

@patch('authorizer.authorizer.boto3')
@patch('authorizer.authorizer.request.urlopen')
def test_handler_auth_start_build(mock_urlopen, mock_boto3):
# Mock SecretsManager get_secret_value
sm = MagicMock()
sm.get_secret_value.return_value = {'SecretString': json.dumps({'token': 'fake'})}
# Mock CodeBuild client
cb = MagicMock()
# Configure boto3.client side effects
def client_factory(name, **kwargs):
if name == 'secretsmanager':
return sm
if name == 'codebuild':
return cb
raise ValueError(f"Unexpected client {name}")
mock_boto3.client.side_effect = client_factory

# Mock GitHub API response: permission = write
response = MagicMock()
response.read.return_value = json.dumps({'permission': 'write'}).encode()
mock_urlopen.return_value.__enter__.return_value = response

# Invoke handler
evt = make_event(login='octocat', ref='main')
handler(evt, None)

# Assert start_build was called
cb.start_build.assert_called_once_with(projectName='pr-build-project')

@patch('authorizer.authorizer.boto3')
@patch('authorizer.authorizer.request.urlopen')
def test_handler_no_build_on_wrong_branch(mock_urlopen, mock_boto3):
# Wrong branch: should not call build
cb = MagicMock()
sm = MagicMock()
mock_boto3.client.side_effect = lambda name, **kwargs: sm if name=='secretsmanager' else cb

evt = make_event(login='octocat', ref='feature')
handler(evt, None)
cb.start_build.assert_not_called()

@patch('authorizer.authorizer.boto3')
@patch('authorizer.authorizer.request.urlopen')
def test_handler_no_build_on_insufficient_permission(mock_urlopen, mock_boto3):
# Insufficient GitHub permission: read only
sm = MagicMock()
sm.get_secret_value.return_value = {'SecretString': json.dumps({'token': 'fake'})}
cb = MagicMock()
mock_boto3.client.side_effect = lambda name, **kwargs: sm if name=='secretsmanager' else cb

# Mock GitHub API permission read
resp = MagicMock()
resp.read.return_value = json.dumps({'permission': 'read'}).encode()
mock_urlopen.return_value.__enter__.return_value = resp

evt = make_event(login='octocat', ref='main')
handler(evt, None)
cb.start_build.assert_not_called()
115 changes: 103 additions & 12 deletions cicd/2-cicd/cicd.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Parameters:
Description: A 'production' cicd stack includes automated tests in the pipeline and deploys 'test' and 'production' environments. Whereas a 'development' type will only deploy a development environment.
Default: production
AllowedValues: [development, production]
GitHubTokenSecretArn:
Type: String
Description: ARN of the SecretsManager secret containing a GitHub personal-access token with collaborator:read scope

Conditions:
TargetsMainBranch: !Equals [ !Ref GitHubBranch, main ]
Expand Down Expand Up @@ -140,18 +143,106 @@ Resources:
Artifacts:
Type: NO_ARTIFACTS
Triggers:
Webhook: true
FilterGroups:
- - Pattern: !Sub ^refs/heads/${GitHubBranch}$
Type: BASE_REF
- Pattern: PULL_REQUEST_CREATED,PULL_REQUEST_UPDATED,PULL_REQUEST_REOPENED
Type: EVENT
# Manual PAUSE button, to disable non-GitHib-maintainers from triggering (we need to find a replacement for CodeBuild for this repo's CI, or make it not public)
- Pattern: ^(31292421|113540108|10283727|105933103|16494556|11708250|11284819|8747128|25372625|46464143|2205926|131809324|7014619|7144482|5107622|68714964|8001765|1372238|5184438|2933346|137330041|208083|26844240|12300669|4108328|107423305|1859238|244100|37230822|82185575|8324574|38662275|137838584|95503833|117784268|9256643|24883357|22244040|25193259|8573958|29001621|113938636|66776217|43474485|33666587|5454101|98911841|8847422|5552007|65205145|108825710|1382374|126921802|85528507|769225|223277|2157034|14046120|1466175|137829631|142271809|56283563|146779710|124813947|31674)$
Type: ACTOR_ACCOUNT_ID

# The CodeBuild Project is used in the CodePipeline pipeline to prepare for a release.
# It will perform any steps defined in the referenced buildspec.yml file.
Webhook: false

# Add Lambda authorizer to call GitHub API and start build only for maintainers
PullRequestAuthorizerFunction:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: python3.9
InlineCode: |
# Lambda authorizer for PR builds
# 1. Import modules
import os
import json
import boto3
from urllib import request

def handler(event, context):
# Fetch GitHub token from Secrets Manager
sm = boto3.client('secretsmanager')
secret = sm.get_secret_value(SecretId=os.environ['GITHUB_TOKEN_SECRET_ARN'])
token = json.loads(secret['SecretString'])['token']

# Parse PR event details
detail = event.get('detail', {})
pr = detail.get('pull_request', {})

# Only process PRs against configured branch
if pr.get('base', {}).get('ref') != os.environ['GitHubBranch']:
return

# Get PR author login
login = pr.get('user', {}).get('login')

# Call GitHub API to check permission
url = (
f"https://api.github.com/repos/"
f"{os.environ['GitHubOwner']}/{os.environ['GitHubRepo']}/"
f"collaborators/{login}/permission"
)
req = request.Request(
url,
headers={
'Authorization': f"token {token}",
'Accept': 'application/vnd.github.v3+json'
}
)
with request.urlopen(req) as resp:
data = json.loads(resp.read().decode())

# If user has write/maintain/admin access, start CodeBuild
if data.get('permission') in ['write', 'maintain', 'admin']:
cb = boto3.client('codebuild')
cb.start_build(projectName=os.environ['CODEBUILD_PROJECT'])
Environment:
Variables:
GITHUB_TOKEN_SECRET_ARN: !Ref GitHubTokenSecretArn
GitHubOwner: !Ref GitHubOwner
GitHubRepo: !Ref GitHubRepo
GitHubBranch: !Ref GitHubBranch
CODEBUILD_PROJECT: !Ref PullRequestBuildProject
Policies:
- AWSLambdaBasicExecutionRole
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Ref GitHubTokenSecretArn
- Effect: Allow
Action: codebuild:StartBuild
Resource: !GetAtt PullRequestBuildProject.Arn
Timeout: 60

# Add EventBridge rule to trigger the authorizer on PR events
PullRequestEventRule:
Type: AWS::Events::Rule
Properties:
Name: !Sub "${AWS::StackName}-pr-event-rule"
EventPattern:
source:
- !Sub "aws.partner/github.com/${GitHubOwner}/${GitHubRepo}"
detail-type:
- "Pull Request State Change"
detail:
action: [opened,reopened,synchronize]
pull_request:
base:
ref: [!Ref GitHubBranch]
Targets:
- Id: PullRequestAuthorizer
Arn: !GetAtt PullRequestAuthorizerFunction.Arn

# Grant EventBridge permission to invoke the lambda
PermissionForEventsToInvokeLambda:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref PullRequestAuthorizerFunction
Action: lambda:InvokeFunction
Principal: events.amazonaws.com
SourceArn: !GetAtt PullRequestEventRule.Arn

AppBuildProject:
Type: AWS::CodeBuild::Project
Properties:
Expand Down
Loading