AI Code Review Bots: IAM, Secrets, and Least-Privilege Pipelines

AI Code Review Bots: IAM, Secrets, and Least-Privilege Pipelines

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: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; 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

Last updated on September 10, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply