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..ad2c624 --- /dev/null +++ b/src/lib/logs/log-retriever.iam.test.ts @@ -0,0 +1,55 @@ +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("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'\"]" + ); + 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..c0086d5 100644 --- a/src/lib/logs/log-retriever.ts +++ b/src/lib/logs/log-retriever.ts @@ -228,6 +228,23 @@ 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]; + if (explainableMatch[3]) { + 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) {