Find Lambda Functions With Over-Privileged Execution Roles

A bunch of metal keys hanging from a single key ring against a dark background

Photo by Jozsef Hocza on Unsplash

A Lambda execution role is too permissive when its policies allow Action: "*", a whole service such as s3:* on Resource: "*", NotAction, or iam:PassRole on every role. To find them, list functions with ListFunctions, read each role’s managed and inline policies, and check every Allow statement. IAM’s service last accessed data then shows what to remove.

Every Lambda function runs with the credentials of its execution role, and the runtime hands them to your code as environment variables. If a dependency is compromised or someone finds an injection bug, the attacker gets exactly what that role allows, for as long as the function keeps running.

This example is for engineers who inherited a Lambda estate where “just attach AdministratorAccess” was the fastest way to make a deploy work. It finds every function whose Lambda execution role is too permissive, marks roles shared by several functions, and optionally lists the services each flagged role has never used. It only reads.

What makes a Lambda execution role too permissive?

Least privilege, as NIST defines it, means each entity gets only the resources and authorizations it needs to do its job. For a Lambda function that job is usually small: write logs, read one table, put objects under one prefix. These are the patterns that break that rule, and how the script rates them:

Statement pattern Example Why it’s a problem Rating
Every action on every resource "Action": "*", "Resource": "*" (AdministratorAccess) The function can do anything the account can, including creating IAM users HIGH
Allow with NotAction "NotAction": "iam:*" (the PowerUserAccess pattern) Allows everything except the listed actions, including services added later HIGH
iam:* or iam:PassRole on * "Action": "iam:PassRole", "Resource": "*" The function can hand any role to a new resource and borrow its permissions HIGH
A whole service on every resource "Action": "dynamodb:*", "Resource": "*" Delete-table and every other write, on every table in the account MEDIUM
"*" limited to specific resources "Action": "*", "Resource": "arn:aws:s3:::uploads/*" Narrower, but still more actions than any function needs MEDIUM

A Condition block can narrow any of these, so the script notes it next to the finding instead of skipping the statement. The CIS AWS Foundations Benchmark treats full *:* administrative policies as a finding in their own right, which makes the HIGH rows the ones auditors ask about first.

Why do shared execution roles make it worse?

When ten functions share one role, the role has to allow the union of what all ten need. The smallest function inherits the permissions of the largest, and removing an action means testing all ten. The script prints “N functions (shared)” for these roles, because splitting them is usually the first fix.

What does the script do?

  1. Maps functions to rolespaginateListFunctions in each Region from --regions, grouping function names by the Role ARN.
  2. Reads each role onceListAttachedRolePolicies plus GetPolicy and GetPolicyVersion for managed policies, ListRolePolicies plus GetRolePolicy for inline ones. IAM returns documents URL-encoded, so the script decodes them before parsing.
  3. Checks Allow statementsFlags the patterns in the table above and notes any Condition.
  4. Optionally checks usageWith --last-accessed, calls GenerateServiceLastAccessedDetails for each flagged role and polls GetServiceLastAccessedDetails until the report is ready.
  5. Reports onlyPrints HIGH findings first and writes a CSV with --csv. No policy is changed.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus @aws-sdk/client-lambda and @aws-sdk/client-iam.
  • An AWS profile configured as in the guide to AWS SDK v3 credential providers.
  • Every Region where you run Lambda functions. IAM is global, but ListFunctions is Regional.

Which IAM permissions does it need?

lambda-role-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListFunctions",
      "Effect": "Allow",
      "Action": "lambda:ListFunctions",
      "Resource": "*"
    },
    {
      "Sid": "ReadRolePolicies",
      "Effect": "Allow",
      "Action": [
        "iam:ListAttachedRolePolicies",
        "iam:ListRolePolicies",
        "iam:GetRolePolicy",
        "iam:GenerateServiceLastAccessedDetails"
      ],
      "Resource": "arn:aws:iam::123456789012:role/*"
    },
    {
      "Sid": "ReadManagedPolicies",
      "Effect": "Allow",
      "Action": [
        "iam:GetPolicy",
        "iam:GetPolicyVersion"
      ],
      "Resource": "arn:aws:iam::*:policy/*"
    },
    {
      "Sid": "ReadLastAccessedReport",
      "Effect": "Allow",
      "Action": "iam:GetServiceLastAccessedDetails",
      "Resource": "*"
    }
  ]
}

Replace 123456789012 with your account ID. The arn:aws:iam::*:policy/* resource covers both your own policies and AWS managed ones, which live under the aws account. Drop the last-accessed actions if you won’t use --last-accessed. For a second opinion, paste the script into the free IAM policy generator for TypeScript code.

The script to find Lambda functions with admin roles

find-lambda-functions-with-admin-roles.ts

// find-lambda-functions-with-admin-roles.ts
// Report only. Maps every Lambda function to its execution role, reads the role's managed and inline
// policies, and flags statements that allow "*" actions, whole services ("s3:*") on every resource,
// NotAction, or iam:PassRole on "*". Roles shared by several functions are marked.
// With --last-accessed it also asks IAM which granted services each flagged role has never used.
// Usage:
//   npx tsx find-lambda-functions-with-admin-roles.ts [--regions us-east-1,eu-west-1] [--last-accessed] [--csv lambda-roles.csv]
import { writeFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { LambdaClient, paginateListFunctions } from "@aws-sdk/client-lambda";
import {
  GenerateServiceLastAccessedDetailsCommand,
  GetPolicyCommand,
  GetPolicyVersionCommand,
  GetRolePolicyCommand,
  GetServiceLastAccessedDetailsCommand,
  IAMClient,
  paginateListAttachedRolePolicies,
  paginateListRolePolicies,
} from "@aws-sdk/client-iam";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const lastAccessed = args.includes("--last-accessed");
const csvPath = flag("--csv");
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1")
  .split(",")
  .map((r) => r.trim())
  .filter(Boolean);

const iam = new IAMClient({ region: "us-east-1" }); // IAM is global

interface Statement {
  Effect?: string;
  Action?: string | string[];
  NotAction?: string | string[];
  Resource?: string | string[];
  Condition?: unknown;
}

interface Row {
  Role: string;
  Functions: string;
  Policy: string;
  Severity: "HIGH" | "MEDIUM";
  Finding: string;
  NeverUsed: string;
}

const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);

// Policy documents come back URL-encoded (RFC 3986).
function parse(doc: string | undefined): Statement[] {
  if (!doc) return [];
  const json = JSON.parse(decodeURIComponent(doc)) as { Statement?: Statement | Statement[] };
  return Array.isArray(json.Statement) ? json.Statement : json.Statement ? [json.Statement] : [];
}

function check(statements: Statement[]): { severity: "HIGH" | "MEDIUM"; finding: string }[] {
  const out: { severity: "HIGH" | "MEDIUM"; finding: string }[] = [];
  for (const s of statements) {
    if (s.Effect !== "Allow") continue;
    const actions = list(s.Action);
    const allResources = list(s.Resource).includes("*");
    const cond = s.Condition ? " (with Condition)" : "";
    if (actions.includes("*") || actions.includes("*:*")) {
      out.push({ severity: allResources ? "HIGH" : "MEDIUM", finding: `Action "*"${allResources ? ' on Resource "*"' : ""}${cond}` });
      continue;
    }
    if (s.NotAction) out.push({ severity: "HIGH", finding: `Allow with NotAction ${list(s.NotAction).join(",")}${cond}` });
    const wildcardServices = actions.filter((a) => /^[a-z0-9-]+:\*$/i.test(a));
    if (wildcardServices.length && allResources) {
      const iamWide = wildcardServices.some((a) => a.toLowerCase() === "iam:*");
      out.push({ severity: iamWide ? "HIGH" : "MEDIUM", finding: `${wildcardServices.join(", ")} on Resource "*"${cond}` });
    }
    if (allResources && actions.some((a) => a.toLowerCase() === "iam:passrole")) {
      out.push({ severity: "HIGH", finding: `iam:PassRole on Resource "*"${cond}` });
    }
  }
  return out;
}

async function policiesFor(roleName: string): Promise<{ name: string; statements: Statement[] }[]> {
  const out: { name: string; statements: Statement[] }[] = [];
  for await (const page of paginateListAttachedRolePolicies({ client: iam }, { RoleName: roleName })) {
    for (const p of page.AttachedPolicies ?? []) {
      const { Policy } = await iam.send(new GetPolicyCommand({ PolicyArn: p.PolicyArn }));
      const { PolicyVersion } = await iam.send(
        new GetPolicyVersionCommand({ PolicyArn: p.PolicyArn, VersionId: Policy?.DefaultVersionId }),
      );
      out.push({ name: p.PolicyName ?? "?", statements: parse(PolicyVersion?.Document) });
    }
  }
  for await (const page of paginateListRolePolicies({ client: iam }, { RoleName: roleName })) {
    for (const policyName of page.PolicyNames ?? []) {
      const { PolicyDocument } = await iam.send(new GetRolePolicyCommand({ RoleName: roleName, PolicyName: policyName }));
      out.push({ name: `${policyName} (inline)`, statements: parse(PolicyDocument) });
    }
  }
  return out;
}

// Services the role's policies allow but that IAM has never seen the role use in its tracking window.
async function neverUsedServices(roleArn: string): Promise<string[]> {
  const { JobId } = await iam.send(new GenerateServiceLastAccessedDetailsCommand({ Arn: roleArn }));
  for (let attempt = 0; attempt < 30; attempt++) {
    const res = await iam.send(new GetServiceLastAccessedDetailsCommand({ JobId }));
    if (res.JobStatus === "COMPLETED") {
      return (res.ServicesLastAccessed ?? []).filter((s) => !s.LastAuthenticated).map((s) => s.ServiceNamespace ?? "?");
    }
    if (res.JobStatus === "FAILED") return ["(job failed)"];
    await sleep(2000);
  }
  return ["(timed out)"];
}

async function main(): Promise<void> {
  // 1. Group functions by execution role across the chosen Regions.
  const byRole = new Map<string, string[]>();
  for (const region of regions) {
    const lambda = new LambdaClient({ region });
    try {
      for await (const page of paginateListFunctions({ client: lambda }, {})) {
        for (const fn of page.Functions ?? []) {
          if (!fn.Role) continue;
          byRole.set(fn.Role, [...(byRole.get(fn.Role) ?? []), `${region}/${fn.FunctionName}`]);
        }
      }
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }

  // 2. Read each role's policies once, however many functions share it.
  const rows: Row[] = [];
  for (const [roleArn, functions] of byRole) {
    const roleName = roleArn.split("/").pop() ?? roleArn; // role ARNs can include a path
    try {
      const found: Row[] = [];
      for (const policy of await policiesFor(roleName)) {
        for (const f of check(policy.statements)) {
          found.push({
            Role: roleName,
            Functions: functions.length > 1 ? `${functions.length} functions (shared)` : functions[0],
            Policy: policy.name,
            Severity: f.severity,
            Finding: f.finding,
            NeverUsed: "",
          });
        }
      }
      if (found.length && lastAccessed) {
        const unused = await neverUsedServices(roleArn);
        const text = unused.length > 8 ? `${unused.slice(0, 8).join(",")} +${unused.length - 8}` : unused.join(",");
        for (const row of found) row.NeverUsed = text;
      }
      rows.push(...found);
    } catch (err) {
      console.error(`${roleName}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }

  rows.sort((a, b) => (a.Severity === b.Severity ? a.Role.localeCompare(b.Role) : a.Severity === "HIGH" ? -1 : 1));
  if (rows.length) console.table(rows);
  const roles = new Set(rows.map((r) => r.Role));
  console.log(`${byRole.size} execution roles checked, ${roles.size} with over-broad statements`);
  if (csvPath && rows.length) {
    const cols = Object.keys(rows[0]) as (keyof Row)[];
    const cell = (v: string) => `"${v.replace(/"/g, '""')}"`;
    writeFileSync(csvPath, [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n");
    console.log(`Wrote ${rows.length} rows to ${csvPath}`);
  }
  console.log("Report only: nothing was modified.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-lambda @aws-sdk/client-iam
npm install --save-dev tsx typescript @types/node

# Policy check only, two Regions
AWS_PROFILE=readonly npx tsx find-lambda-functions-with-admin-roles.ts --regions us-east-1,eu-west-1 --csv lambda-roles.csv

# Add the "never used" services for each flagged role
AWS_PROFILE=readonly npx tsx find-lambda-functions-with-admin-roles.ts --regions us-east-1 --last-accessed

Sample output

Output

┌─────────┬──────────────────────┬───────────────────────────┬───────────────────────┬──────────┬────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Role                 │ Functions                 │ Policy                │ Severity │ Finding                        │ NeverUsed                                                                       │
├─────────┼──────────────────────┼───────────────────────────┼───────────────────────┼──────────┼────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────┤
│ 0       │ 'lambda-shared-role' │ '14 functions (shared)'   │ 'AdministratorAccess' │ 'HIGH'   │ 'Action "*" on Resource "*"'   │ 'access-analyzer,account,acm,acm-pca,amplify,apigateway,appconfig,appflow +391' │
│ 1       │ 'image-resizer-role' │ 'us-east-1/image-resizer' │ 'resizer (inline)'    │ 'HIGH'   │ 'iam:PassRole on Resource "*"' │ 'iam'                                                                           │
│ 2       │ 'image-resizer-role' │ 'us-east-1/image-resizer' │ 'resizer (inline)'    │ 'MEDIUM' │ 's3:* on Resource "*"'         │ 'iam'                                                                           │
│ 3       │ 'orders-api-role'    │ 'eu-west-1/orders-api'    │ 'orders-dynamo'       │ 'MEDIUM' │ 'dynamodb:* on Resource "*"'   │ ''                                                                              │
└─────────┴──────────────────────┴───────────────────────────┴───────────────────────┴──────────┴────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────┘
23 execution roles checked, 3 with over-broad statements
Wrote 4 rows to lambda-roles.csv
Report only: nothing was modified.

Names and counts are illustrative. lambda-shared-role is the one to start with: fourteen functions run with administrator access, and the last-accessed report shows hundreds of services none of them has touched. image-resizer-role never used IAM at all, so its iam:PassRole statement is dead weight you can delete today.

How do you right-size an over-privileged execution role?

  1. Split shared rolesGive each function its own role, starting from the AWS managed AWSLambdaBasicExecutionRole policy for CloudWatch Logs, then add only what that function calls.
  2. Start from the codeList the SDK calls the function makes. The guide to find the IAM actions your AWS SDK for JavaScript code needs covers the mapping, and the IAM policy generator for Python code does the same for boto3 functions.
  3. Check against real usageIAM reports service last accessed data for at least the last 400 days. Use --last-accessed, or IAM Access Analyzer policy generation, which reads up to 90 days of CloudTrail events and drafts a policy from them. It needs a CloudTrail trail, and it leaves out iam:PassRole and data events such as S3 object reads, so add those by hand.
  4. Scope resourcesReplace "Resource": "*" with the table, bucket or queue ARNs. Then review the generated IAM policy for least privilege before you attach it.
  5. Swap and watchAttach the new policy, remove the old one, and watch the function’s errors for a full business cycle. The guide to investigate Lambda errors with CloudWatch shows where AccessDenied failures surface.

Warning: last-accessed data counts attempts, including denied ones, and it only evaluates identity policies. A service that shows as used may have been denied every time; confirm in CloudTrail before you keep a permission because of it.

The same over-broad policies often sit on other identities. The account-wide script to find IAM policies that grant admin access covers users, groups and non-Lambda roles, and finding unused IAM roles with RoleLastUsed catches roles left behind by deleted functions.

Troubleshooting

  • NoSuchEntity for a role. The function points to a role that was deleted. The function can’t run until you give it a valid role.
  • The last-accessed column shows “(timed out)”. Large accounts can take longer than the script’s minute of polling. Run it again for that role, or raise the attempt count.
  • A finding appears for a policy you thought was scoped. Check every statement: one broad statement is enough, even when the others are tight.
  • AccessDenied on GetPolicyVersion. Your policy may cover only your own account’s policies. The guide to troubleshoot IAM access denied errors shows how to read the denial.

An over-privileged role is most dangerous on a function that’s easy to reach, so pair this with the checks to find public Lambda function URLs and to find secrets in Lambda environment variables.

Ask ChatWithCloud instead

For a quick answer, ask ChatWithCloud “Which Lambda functions in us-east-1 use a role with AdministratorAccess?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result, one profile and Region per session; how ChatWithCloud runs AWS SDK code locally explains the loop. It can be wrong and runs code without a confirmation step, so connect ChatWithCloud through a read-only AWS profile, not the admin role you’re trying to reduce.

Frequently asked questions

What permissions does a Lambda execution role need at minimum?

Permission to write logs: logs:CreateLogGroup, logs:CreateLogStream and logs:PutLogEvents, which the AWS managed AWSLambdaBasicExecutionRole policy grants. Add only the actions your code calls, on the resources it uses.

Is it bad to use AdministratorAccess for a Lambda function?

Yes. The function’s credentials are available to its code, so any bug or compromised dependency gets full control of the account. Replace it with a policy scoped to the function’s actual calls.

Should each Lambda function have its own execution role?

Usually. A shared role has to allow what every function needs, so each function ends up with more than it uses. One role per function keeps policies small and easy to review.

How do I see which permissions a Lambda role actually uses?

Use IAM service last accessed data (GenerateServiceLastAccessedDetails) for services, or IAM Access Analyzer policy generation, which reads up to 90 days of CloudTrail events and drafts a policy.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud