From cc4705529956631dcdc09a6473c56111cd5532b2 Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Mon, 5 Oct 2026 22:01:41 +0530 Subject: [PATCH 1/2] fix(iam): parse Explainable-IAM denial logs in policy analyzer The IAM policy analyzer only recognised the legacy denial log line ("Request for service '..' by principal '..' for operation '..' denied."). Current LocalStack (2026.x) emits the Explainable-IAM format instead: User: is not authorized to perform: on resource: because ... so analyze-policies reported "No IAM Denials Found" and generated nothing against a running LocalStack. This also affects denials raised for Lambda execution roles (assumed-role principals), which only appear in the new format. - log-retriever: detect the Explainable-IAM line (principal, action, resource, service) in addition to the legacy one. - iam-policy.logic: only fall back to the nearby-log resource when the denial line did not already carry one, so the richer new format is not overwritten. - tests: cover both formats, assumed-role principals, object-scoped S3 ARNs, and that the DEBUG 'Necessary permissions' lines are not themselves denials. --- src/lib/iam/iam-policy.logic.ts | 2 +- src/lib/logs/log-retriever.iam.test.ts | 45 ++++++++++++++++++++++++++ src/lib/logs/log-retriever.ts | 15 +++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/lib/logs/log-retriever.iam.test.ts diff --git a/src/lib/iam/iam-policy.logic.ts b/src/lib/iam/iam-policy.logic.ts index 5e234ad..06d07d5 100644 --- a/src/lib/iam/iam-policy.logic.ts +++ b/src/lib/iam/iam-policy.logic.ts @@ -27,7 +27,7 @@ export async function enrichWithResourceData( return log.iamAction === denial.iamAction && timeDiff <= 5000; }); - if (nearbyResourceLogs.length > 0) { + if (!enrichedDenial.iamResource && nearbyResourceLogs.length > 0) { enrichedDenial.iamResource = nearbyResourceLogs[0].iamResource; } } diff --git a/src/lib/logs/log-retriever.iam.test.ts b/src/lib/logs/log-retriever.iam.test.ts new file mode 100644 index 0000000..5b87181 --- /dev/null +++ b/src/lib/logs/log-retriever.iam.test.ts @@ -0,0 +1,45 @@ +import { LocalStackLogRetriever } from "./log-retriever"; + +describe("LocalStackLogRetriever IAM denial parsing", () => { + const parse = (line: string) => (new LocalStackLogRetriever() as any).parseLogLine(line); + + it("parses the legacy 'Request for service ... denied.' format", () => { + const entry = parse( + "2025-07-23T10:58:58.710 INFO --- [asgi_gw_1] l.p.c.s.i.p.handler : Request for service 's3' by principal 'arn:aws:iam::000000000000:user/test' for operation 'CreateBucket' denied." + ); + expect(entry.isIamDenial).toBe(true); + expect(entry.iamPrincipal).toBe("arn:aws:iam::000000000000:user/test"); + expect(entry.iamAction).toBe("s3:CreateBucket"); + }); + + it("parses the Explainable IAM 'is not authorized to perform' format (ENFORCED and SOFT_MODE)", () => { + const entry = parse( + "2026-10-05T15:16:27.431 INFO --- [et.reactor-2] l.p.c.s.i.p.handler : User: arn:aws:iam::000000000000:user/probe-user is not authorized to perform: dynamodb:Scan on resource: arn:aws:dynamodb:us-east-1:000000000000:table/probe-table because no identity-based policy allows the dynamodb:Scan action" + ); + expect(entry.isIamDenial).toBe(true); + expect(entry.isError).toBe(true); + expect(entry.service).toBe("dynamodb"); + expect(entry.iamPrincipal).toBe("arn:aws:iam::000000000000:user/probe-user"); + expect(entry.iamAction).toBe("dynamodb:Scan"); + expect(entry.iamResource).toBe("arn:aws:dynamodb:us-east-1:000000000000:table/probe-table"); + }); + + it("parses assumed-role principals and object-level S3 resources", () => { + const entry = parse( + "2026-10-05T15:16:27.039 INFO --- [et.reactor-1] l.p.c.s.i.p.handler : User: arn:aws:sts::000000000000:assumed-role/my-fn-role/my-fn is not authorized to perform: s3:PutObject on resource: arn:aws:s3:::probe-bucket/hello.txt because no identity-based policy allows the s3:PutObject action" + ); + expect(entry.isIamDenial).toBe(true); + expect(entry.iamPrincipal).toBe("arn:aws:sts::000000000000:assumed-role/my-fn-role/my-fn"); + expect(entry.iamAction).toBe("s3:PutObject"); + expect(entry.iamResource).toBe("arn:aws:s3:::probe-bucket/hello.txt"); + }); + + it("does not flag the DEBUG 'Necessary permissions' lines as denials but still extracts the resource", () => { + const entry = parse( + "2026-10-05T15:16:27.431 DEBUG --- [et.reactor-2] l.p.c.s.i.p.handler : Necessary permissions for this action: [\"Action 'dynamodb:Scan' for 'arn:aws:dynamodb:us-east-1:000000000000:table/probe-table'\"]" + ); + expect(entry.isIamDenial).toBeUndefined(); + expect(entry.iamAction).toBe("dynamodb:Scan"); + expect(entry.iamResource).toBe("arn:aws:dynamodb:us-east-1:000000000000:table/probe-table"); + }); +}); diff --git a/src/lib/logs/log-retriever.ts b/src/lib/logs/log-retriever.ts index 232d316..b6a9252 100644 --- a/src/lib/logs/log-retriever.ts +++ b/src/lib/logs/log-retriever.ts @@ -228,6 +228,21 @@ export class LocalStackLogRetriever { entry.isError = true; } + // Detect the newer Explainable IAM denial format + if (!entry.isIamDenial) { + const explainableDenialPattern = + /User: (\S+) is not authorized to perform: ([A-Za-z0-9-]+:[A-Za-z0-9*]+) on resource: "?([^"\s]+)"? because/; + const explainableMatch = line.match(explainableDenialPattern); + if (explainableMatch) { + entry.isIamDenial = true; + entry.iamPrincipal = explainableMatch[1]; + entry.iamAction = explainableMatch[2]; + entry.iamResource = explainableMatch[3]; + entry.service = explainableMatch[2].split(":")[0].toLowerCase(); + entry.isError = true; + } + } + const iamResourcePattern = /Action '([^']*)' for '([^']*)'/g; let resourceMatch; while ((resourceMatch = iamResourcePattern.exec(line)) !== null) { From de15273fd011a5de63a237bc2ec6d718ba496f77 Mon Sep 17 00:00:00 2001 From: Harsh Mishra Date: Tue, 6 Oct 2026 14:04:01 +0530 Subject: [PATCH 2/2] fix(iam): parse denials for actions without a resource ARN Actions such as dynamodb:ListTables log no "on resource:" clause, so the Explainable IAM pattern did not match them. Make the clause optional. --- src/lib/logs/log-retriever.iam.test.ts | 10 ++++++++++ src/lib/logs/log-retriever.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/logs/log-retriever.iam.test.ts b/src/lib/logs/log-retriever.iam.test.ts index 5b87181..ad2c624 100644 --- a/src/lib/logs/log-retriever.iam.test.ts +++ b/src/lib/logs/log-retriever.iam.test.ts @@ -34,6 +34,16 @@ describe("LocalStackLogRetriever IAM denial parsing", () => { expect(entry.iamResource).toBe("arn:aws:s3:::probe-bucket/hello.txt"); }); + it("parses denials for actions that have no resource ARN", () => { + const entry = parse( + "2026-10-06T08:33:16.832 INFO --- [et.reactor-1] l.p.c.s.i.p.handler : User: arn:aws:sts::000000000000:assumed-role/nd-role/nd is not authorized to perform: dynamodb:ListTables because no identity-based policy allows the dynamodb:ListTables action" + ); + expect(entry.isIamDenial).toBe(true); + expect(entry.iamPrincipal).toBe("arn:aws:sts::000000000000:assumed-role/nd-role/nd"); + expect(entry.iamAction).toBe("dynamodb:ListTables"); + expect(entry.iamResource).toBeUndefined(); + }); + it("does not flag the DEBUG 'Necessary permissions' lines as denials but still extracts the resource", () => { const entry = parse( "2026-10-05T15:16:27.431 DEBUG --- [et.reactor-2] l.p.c.s.i.p.handler : Necessary permissions for this action: [\"Action 'dynamodb:Scan' for 'arn:aws:dynamodb:us-east-1:000000000000:table/probe-table'\"]" diff --git a/src/lib/logs/log-retriever.ts b/src/lib/logs/log-retriever.ts index b6a9252..c0086d5 100644 --- a/src/lib/logs/log-retriever.ts +++ b/src/lib/logs/log-retriever.ts @@ -231,13 +231,15 @@ export class LocalStackLogRetriever { // Detect the newer Explainable IAM denial format if (!entry.isIamDenial) { const explainableDenialPattern = - /User: (\S+) is not authorized to perform: ([A-Za-z0-9-]+:[A-Za-z0-9*]+) on resource: "?([^"\s]+)"? because/; + /User: (\S+) is not authorized to perform: ([A-Za-z0-9-]+:[A-Za-z0-9*]+)(?: on resource: "?([^"\s]+)"?)? because/; const explainableMatch = line.match(explainableDenialPattern); if (explainableMatch) { entry.isIamDenial = true; entry.iamPrincipal = explainableMatch[1]; entry.iamAction = explainableMatch[2]; - entry.iamResource = explainableMatch[3]; + if (explainableMatch[3]) { + entry.iamResource = explainableMatch[3]; + } entry.service = explainableMatch[2].split(":")[0].toLowerCase(); entry.isError = true; }