/** * WAW063: IAM Policy Denies An Action It Also Allows * * IAM evaluates an explicit Deny as an absolute veto: it wins over any * Allow granted to the same principal, from any attached policy, no matter * how the Allow got there. When a role ends up with both an Allow and a * Deny for the same action (over an overlapping resource scope) — * typically because a broad guardrail policy and a feature-specific policy * were attached to the same role independently — CloudFormation deploys * the stack without complaint and the contradiction only surfaces as a * runtime 403, the most expensive class of error to trace back to its * source. This is IAM-reasoning static analysis over the declared graph: * collect every Allow/Deny statement attached to each declared IAM::Role * (inline, standalone IAM::Policy, and IAM::ManagedPolicy) and flag a * literal action+resource collision between an Allow and a Deny. * * Deliberately conservative to avoid flagging the common intentional * "broad Allow + narrow safety-Deny" guardrail pattern (e.g. `Allow ec2:*` * plus `Deny ec2:TerminateInstances`): * - both statements' Action lists are compared string-by-string; a match * requires either the literal same action on both sides, or a * wildcarded Deny action that matches a literal (non-wildcarded) Allow * action. A wildcarded Allow paired with a literal Deny is exactly the * guardrail pattern and is never flagged; * - resources are compared the same way: `Resource: "*"` on the Deny * side always overlaps, or a literal exact match; a wildcarded Allow * resource paired with a narrower literal Deny is not flagged; * - any statement carrying a Condition, NotAction, NotResource, * Principal, or an intrinsic Action/Resource is skipped — the rule * can't prove those statically, so it stays quiet. */ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth"; import { parseCFTemplate, walkPolicyStatements, findResourceRefs, isIntrinsic, type CFTemplate, type CFResource, } from "./cf-refs"; interface StatementEntry { effect: string; actions: string[]; resources: string[]; source: string; } function toStringArray(value: unknown): string[] | null { if (typeof value === "string") return [value]; if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value as string[]; return null; } function statementEntries(stmts: Array>, source: string): StatementEntry[] { const out: StatementEntry[] = []; for (const stmt of stmts) { if (stmt.Effect !== "Allow" && stmt.Effect !== "Deny") continue; if ("Condition" in stmt || "NotAction" in stmt || "NotResource" in stmt) continue; if ("Principal" in stmt || "NotPrincipal" in stmt) continue; if (isIntrinsic(stmt.Action) || isIntrinsic(stmt.Resource)) continue; const actions = toStringArray(stmt.Action); const resources = toStringArray(stmt.Resource); if (!actions || !resources || actions.length === 0 || resources.length === 0) continue; out.push({ effect: stmt.Effect, actions, resources, source }); } return out; } /** Every Allow/Deny statement attached to `roleId`, tagged with a human-readable source. */ function collectRoleStatements(roleId: string, roleResource: CFResource, template: CFTemplate): StatementEntry[] { const resources = template.Resources ?? {}; const entries: StatementEntry[] = []; // Inline policies declared directly on the role. const inline = roleResource.Properties?.Policies; if (Array.isArray(inline)) { for (const policy of inline) { if (typeof policy !== "object" || policy === null) continue; const p = policy as Record; const name = typeof p.PolicyName === "string" ? p.PolicyName : "(unnamed)"; const wrapped: CFResource = { Type: roleResource.Type, Properties: { PolicyDocument: p.PolicyDocument } }; entries.push(...statementEntries(walkPolicyStatements(wrapped), `inline policy "${name}" on role "${roleId}"`)); } } // Standalone AWS::IAM::Policy resources attached via their Roles list. for (const [polId, polResource] of Object.entries(resources)) { if (polResource.Type !== "AWS::IAM::Policy") continue; const rolesProp = polResource.Properties?.Roles; if (!Array.isArray(rolesProp)) continue; if (!findResourceRefs(rolesProp).has(roleId)) continue; entries.push(...statementEntries(walkPolicyStatements(polResource), `standalone policy "${polId}"`)); } // Managed policies attached via the role's ManagedPolicyArns. for (const managedId of findResourceRefs(roleResource.Properties?.ManagedPolicyArns)) { const mp = resources[managedId]; if (mp?.Type !== "AWS::IAM::ManagedPolicy") continue; entries.push(...statementEntries(walkPolicyStatements(mp), `managed policy "${managedId}"`)); } return entries; } function hasWildcard(s: string): boolean { return s.includes("*"); } function globMatches(pattern: string, literal: string): boolean { const re = new RegExp( "^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$", "i", ); return re.test(literal); } /** A Deny action nullifies an Allow action: literal-vs-literal match, or a wildcarded Deny over a literal Allow. */ function actionCollides(denyAction: string, allowAction: string): boolean { if (!hasWildcard(denyAction) && !hasWildcard(allowAction)) { return denyAction.toLowerCase() === allowAction.toLowerCase(); } if (hasWildcard(denyAction) && !hasWildcard(allowAction)) { return globMatches(denyAction, allowAction); } return false; // wildcarded Allow vs literal/wildcard Deny — the guardrail-exception pattern; stay quiet. } /** A Deny resource overlaps an Allow resource: Deny "*" always overlaps, or a literal exact match. */ function resourceCollides(denyResource: string, allowResource: string): boolean { if (denyResource === "*") return true; return denyResource === allowResource; } export function checkDenyAllowContradiction(ctx: PostSynthContext): PostSynthDiagnostic[] { const diagnostics: PostSynthDiagnostic[] = []; for (const [_lexicon, output] of ctx.outputs) { const template = parseCFTemplate(output); if (!template?.Resources) continue; for (const [roleId, roleResource] of Object.entries(template.Resources)) { if (roleResource.Type !== "AWS::IAM::Role") continue; const entries = collectRoleStatements(roleId, roleResource, template); const denies = entries.filter((e) => e.effect === "Deny"); const allows = entries.filter((e) => e.effect === "Allow"); if (denies.length === 0 || allows.length === 0) continue; const flagged = new Set(); for (const deny of denies) { for (const allow of allows) { for (const denyAction of deny.actions) { for (const allowAction of allow.actions) { if (!actionCollides(denyAction, allowAction)) continue; const resourcePair = deny.resources.find((dr) => allow.resources.some((ar) => resourceCollides(dr, ar)), ); if (resourcePair === undefined) continue; const dedupeKey = `${allowAction}${resourcePair}${deny.source}${allow.source}`; if (flagged.has(dedupeKey)) continue; flagged.add(dedupeKey); diagnostics.push({ checkId: "WAW063", severity: "error", message: `IAM role "${roleId}" ${deny.source} denies "${denyAction}" on ${JSON.stringify(resourcePair)}, but ${allow.source} allows "${allowAction}" on the same resource — the explicit Deny wins and the grant is a no-op at runtime`, entity: roleId, lexicon: "aws", }); } } } } } } return diagnostics; } export const waw063: PostSynthCheck = { id: "WAW063", description: "IAM policy denies an action another attached policy on the same role allows — explicit Deny wins, runtime 403", check(ctx: PostSynthContext): PostSynthDiagnostic[] { return checkDenyAllowContradiction(ctx); }, };