An AI review bot with a classic Personal Access Token (repo scope) and bedrock:* on the account is not a productivity win — it is a credential that can push to main, exfiltrate private repos, and run up a five-figure model bill. The production pattern is boring on purpose: short-lived GitHub App installation tokens, IAM roles that can invoke one model and write logs, and a pipeline that only ever sees the PR diff + allowlisted paths.
⚡ TL;DR: Run the bot as a GitHub App (not a user PAT). Exchange the App JWT for an installation token inside the job; never store that token in env files or DynamoDB. Give the AWS task role
bedrock:InvokeModelon specific model ARNs,secretsmanager:GetSecretValueon one secret, and CloudWatch put-metric/log — nothing else. Fetch only the PR files you need; redact secrets before the prompt; post reviews via the Checks/PR review API. Illustrative budget: review job wall clock 30–180s; Bedrock input dominated by diff hunks + rubric (~2–8k tokens) with prompt caching on the static rubric.
Threat model in one page
| Asset | Abuse if bot is over-privileged | Control |
|---|---|---|
| GitHub org repos | Force-push, exfil, workflow poisoning | GitHub App with contents:read, pull_requests:write only |
| Bedrock | Cost bomb / data to wrong model | IAM resource-level model ARNs + SCPs |
| Secrets | Cloud foothold | One secret ARN; no wildcard secretsmanager:* |
| Prompt content | Leaked API keys inside diffs | Pre-prompt secret scanning + Guardrails |
| CI identity | Lateral movement in AWS | Separate deploy role ≠ review role |
❌ “We’ll use my admin PAT in GitHub Actions secrets for now.”
✅ App installation token minted per workflow run, expires in ~1 hour.
Pipeline shape
pull_request opened/synchronize
→ GitHub Actions (OIDC) assumes AwsCodeReviewRole
→ Job reads App private key from Secrets Manager (or GH encrypted secret → still mint install token)
→ Fetch PR diff via GitHub API (installation token)
→ Redact + truncate
→ Bedrock Converse with cached review rubric
→ POST PR review comments
→ Emit cost + latency metrics
OIDC to AWS (no long-lived AKIA keys in Actions)
# .github/workflows/ai-review.yml
name: ai-code-review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
id-token: write # OIDC
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
.
fetch-depth: 0
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/AwsCodeReviewRole
aws-region: us-east-1
- name: Run reviewer
run: node scripts/ai-review.mjs
env:
GH_APP_SECRET_ARN: arn:aws:secretsmanager:us-east-1:123456789012:secret:github/code-review-app
BEDROCK_MODEL_ID: anthropic.claude-3-5-sonnet-...
MAX_DIFF_BYTES: "120000"
# terraform — trust GitHub OIDC for this repo only
resource "aws_iam_role" "code_review" {
name = "AwsCodeReviewRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:*"
}
}
}]
})
}
resource "aws_iam_role_policy" "code_review" {
name = "code-review-least-privilege"
role = aws_iam_role.code_review.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "InvokeOneModel"
Effect = "Allow"
Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"]
Resource = [
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-*"
]
},
{
Sid = "ReadAppSecret"
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = [aws_secretsmanager_secret.gh_app.arn]
},
{
Sid = "Logs"
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = ["arn:aws:logs:us-east-1:123456789012:log-group:/ai/code-review:*"]
}
]
})
}
Mint the GitHub token in-process
// scripts/ai-review.mjs (illustrative)
import { createSign } from "crypto";
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import {
BedrockRuntimeClient,
ConverseCommand,
} from "@aws-sdk/client-bedrock-runtime";
const sm = new SecretsManagerClient({});
const br = new BedrockRuntimeClient({});
const secret = JSON.parse(
(
await sm.send(
new GetSecretValueCommand({ SecretId: process.env.GH_APP_SECRET_ARN })
)
).SecretString
);
const installationToken = await mintInstallationToken(secret);
// ✅ use for GitHub API only; ❌ never console.log(installationToken)
// ✅ never pass token into Bedrock messages
const diff = await fetchPrDiff(installationToken, {
owner: process.env.GITHUB_REPOSITORY_OWNER,
repo: process.env.GITHUB_REPOSITORY.split("/")[1],
pull_number: Number(process.env.PR_NUMBER),
});
const safeDiff = redactSecrets(truncate(diff, Number(process.env.MAX_DIFF_BYTES)));
const review = await runBedrockReview(safeDiff);
await postReview(installationToken, review);
async function mintInstallationToken(secret) {
const now = Math.floor(Date.now() / 1000);
const payload = { iat: now - 60, exp: now + 540, iss: String(secret.app_id) };
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signer = createSign("RSA-SHA256");
signer.update(`${header}.${body}`);
const sig = signer.sign(secret.private_key, "base64url");
const appJwt = `${header}.${body}.${sig}`;
const res = await fetch(
`https://api.github.com/app/installations/${secret.installation_id}/access_tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${appJwt}`,
Accept: "application/vnd.github+json",
},
}
);
if (!res.ok) throw new Error(`token_mint_failed:${res.status}`);
const json = await res.json();
return json.token;
}
GitHub App permissions to prefer:
- ✅
Pull requests: Read & write(to post reviews) - ✅
Contents: Read(to read files if you must) - ✅
Checks: Write(optional status) - ❌
Administration,Actions: Write,Secrets,Workflows— not needed for review comments
Redact before the model sees the diff
Diffs routinely contain .env additions, service keys, and customer identifiers. Strip before Bedrock.
function redactSecrets(text) {
return text
// illustrative patterns — extend with your secret scanner
.replace(/(?<=AKIA)[0-9A-Z]{16}/g, "********")
.replace(/(?<=ghp_)[A-Za-z0-9]{20,}/g, "********")
.replace(/(?<=xox[baprs]-)[A-Za-z0-9-]{10,}/g, "********")
.replace(/(?<=Bearer\s+)[A-Za-z0-9._\-+/=]{20,}/gi, "********")
.replace(/(?<=password\s*[:=]\s*)\S+/gi, "********");
}
function truncate(text, maxBytes) {
const buf = Buffer.from(text, "utf8");
if (buf.length <= maxBytes) return text;
// ✅ keep the head of the diff; ❌ don’t silently drop security-sensitive files without noting truncation
return buf.subarray(0, maxBytes).toString("utf8") + "\n\n[TRUNCATED]";
}
Pair with Bedrock Guardrails on the invoke path for PII filters if reviews may include customer data from fixtures.
Bedrock call with a cached rubric
const RUBRIC = `You are a senior code reviewer. Focus on:
- correctness bugs, authz flaws, injection, unsafe deserialization
- missing tests for changed control flow
- Do NOT nitpick formatting.
Return JSON: { "summary": string, "findings": [{"path":string,"line":number,"severity":"high"|"medium"|"low","note":string}] }`;
async function runBedrockReview(diff) {
const out = await br.send(
new ConverseCommand({
modelId: process.env.BEDROCK_MODEL_ID,
system: [
{ text: RUBRIC },
{ cachePoint: { type: "default" } }, // ✅ static rubric cached across PRs
],
messages: [
{
role: "user",
content: [{ text: `Review this diff:\n\n${diff}` }],
},
],
inferenceConfig: { maxTokens: 2048, temperature: 0 },
})
);
const text = out.output.message.content.map((c) => c.text || "").join("");
return JSON.parse(text);
}
Illustrative cost control: cache the rubric (hundreds–thousands of tokens) across hundreds of PRs/day; bill the dynamic diff at full input rates only. Cap MAX_DIFF_BYTES so a 20MB generated-file PR cannot become a surprise invoice.
Posting reviews without over-sharing
async function postReview(token, review) {
// Map findings to GitHub pull request review comments API
// ✅ Only comment on lines in the diff hunk
// ❌ Don’t request changes for “style” — use severity gates
const event =
review.findings.some((f) => f.severity === "high") ? "REQUEST_CHANGES" : "COMMENT";
await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${pull}/reviews`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
body: JSON.stringify({
body: review.summary,
event,
comments: review.findings
.filter((f) => f.severity !== "low")
.slice(0, 20) // ✅ spam cap
.map((f) => ({
path: f.path,
line: f.line,
body: `**${f.severity}**: ${f.note}`,
})),
}),
});
}
Closing checklist
✅ Dos
– ✅ GitHub App + per-run installation tokens; OIDC into a review-only IAM role
– ✅ Resource-scoped bedrock:InvokeModel on known model ARNs
– ✅ Redact secrets and cap diff size before prompting
– ✅ Cache the static review rubric; log token usage per PR
– ✅ Separate the deploy/terraform role from the review role
❌ Don’ts
– ❌ Don’t put classic repo-scoped PATs in org secrets for bots
– ❌ Don’t grant the review role s3:*, iam:*, or bedrock:* wildcards
– ❌ Don’t send the GitHub token (or AWS creds) into the model context
– ❌ Don’t let the bot approve/merge its own PRs
– ❌ Don’t skip truncation — generated lockfiles and vendor trees will wreck cost and quality
Related reading
- Terraform for Developers: Infrastructure as Code From Zero to Production AWS
- AWS Lambda Best Practices: Write Functions That Scale and Never Time Out
- Hyper-Scale Serverless API Platform on AWS
- LLM Coding Agents on AWS: Safe Tool Sandboxes with Lambda (companion post)
- Bedrock Prompt Caching and Batch Inference (companion post)
- Amazon Bedrock Agents: Tool Use, Memory, and Production Guardrails (companion post)
Last updated on September 10, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
