AI Code Review Bot IAM: Least-Privilege Secrets and Pipelines

AI Code Review Bot IAM: Least-Privilege Secrets and Pipelines
6 views
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. Production AI code review bot IAM 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: Treat AI code review bot IAM as a security product: 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:InvokeModel on specific model ARNs, secretsmanager:GetSecretValue on 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.
Related hardening: IAM for AI agents, Lambda coding agent sandboxes, and Bedrock prompt caching for static rubrics.

AI code review bot IAM: 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

text
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)

yaml
# .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"
hcl
# 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

javascript
// 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.
javascript
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

javascript
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

javascript
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

Last updated on September 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

3 Comments

Leave a comment

No account needed. Name and email are optional.