Introduction
In my integ-runner deep dive, I promised a follow-up, because there was a second half to shipping those CDK constructs that deserved its own post: locking the whole thing down to least privilege, and keeping the stack clean with cdk-nag.
That's even a more useful part. One of these I solved cleanly and would set up from the beginning: cdk-nag. The other, least privilege, I'm still not fully happy with. I'll show both anyway, including where I got stuck, because with this kind of work we all learn most from.
Let's begin.
The problem
When you publish a CDK construct library, or really any CDK app you hand to someone else, two things are easy to skip and annoy later:
- The role that deploys and tests the thing quietly ends up on
AdministratorAccess, just to make it work. Which is the exact opposite of what you'd tell a client to do. cdk synthlights up with security warnings. Some are real, some are noise from the generated code inside custom resources, and if you don't do something about them, you learn to scroll past all of them.
Here's how far I got with each.
Least privilege
The goal is simple to state: the role that deploys and tests the construct should have exactly the permissions it needs, and nothing more.
Getting there is the hard part. The approach that makes sense is to work backward from what the role actually did, and there are a few places to get that signal:
- The service-last-accessed advisor data, so what the role actually called, then trim down to that set. Fine in the console, fiddly once you want it scripted and repeatable.
- CloudTrail, ideally CloudTrail Lake, to query the real API calls a deploy made and build the policy from those. This is the most trustworthy source, because it's not a guess, it's what actually happened.
- IAM Access Analyzer policy generation, which is the "official" answer and generates a policy from your CloudTrail activity.
And then the sharp edge that stuck with me. I had a role sitting on AdministratorAccess that still failed a deploy, missing s3:ListBucketVersions. How does a role with allow-everything miss a single S3 action?
Because the uploadRole still does not only need to put objects into the destination S3 bucket, but also needs permissions to get the bucket location and list the bucket as follow:
releaseBucket.grantPut(uploadRole, releaseObjectKeysPattern);
// see https://github.com/aws/aws-cdk/issues/6808#issuecomment-675198204
uploadRole.addToPolicy(new iam.PolicyStatement({
actions: [
's3:GetBucketLocation',
's3:ListBucket',
],
resources: [releaseBucket.bucketArn],
}));This detail makes the upload work.
cdk-nag
cdk-nag is the one I have no reservations about. It's an Aspect you bolt onto your app that walks the construct tree at synth time and checks it against a rule pack. There are several packs (AWS Solutions, HIPAA, NIST 800-53, PCI DSS, Serverless). I run the AWS Solutions pack, added once:
Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true }));The part I actually like is turning that into a test that fails the build. Not a wall of synth output you train yourself to ignore, but a red test when someone provides an unsuppressed nag:
import { App, Aspects, Stack } from 'aws-cdk-lib';
import { Annotations, Match } from 'aws-cdk-lib/assertions';
import { AwsSolutionsChecks } from 'cdk-nag';
import { VSCodeServer } from '../src';
import { suppressCommonNags } from '../src/suppress-nags';
describe('cdk-nag AwsSolutions Pack', () => {
let stack: Stack;
let app: App;
beforeAll(() => {
// GIVEN
app = new App();
stack = new Stack(app, 'testStack', {
env: { region: 'us-east-1', account: '1234' },
});
new VSCodeServer(stack, 'VSCodeServer', {});
suppressCommonNags(stack); // <- the honest, path-based suppressions
// WHEN
Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true }));
});
// THEN
test('No unsuppressed Warnings', () => {
const warnings = Annotations.fromStack(stack).findWarning(
'*',
Match.stringLikeRegexp('AwsSolutions-.*'),
);
expect(warnings).toHaveLength(0);
});
test('No unsuppressed Errors', () => {
const errors = Annotations.fromStack(stack).findError(
'*',
Match.stringLikeRegexp('AwsSolutions-.*'),
);
expect(errors).toHaveLength(0);
});
});Now, the problem. Some nags you can't fix, you can only explain. And there are two kinds of suppression depending on whether you have a handle on the resource.
Suppress on a construct you own
The first kind lives right on the construct you control. My favorite example is a construct I only had to build because of a gap in CDK itself.
CDK has no native way to look up an AWS-managed prefix list ID by name, for example com.amazonaws.global.cloudfront.origin-facing. Until it does (aws-cdk#15115), you can fall back to a custom resource that calls DescribeManagedPrefixLists. That call isn't resource-scoped, so it needs ec2:DescribeManagedPrefixLists on *, which trips AwsSolutions-IAM5 (the wildcard-permissions rule). It's a legitimate wildcard, so I suppress it with an actual reason and pass true so the suppression also applies to the Lambda and role the custom resource generates under the hood:
import { RemovalPolicy } from 'aws-cdk-lib';
import { IPrefixList, PrefixList } from 'aws-cdk-lib/aws-ec2';
import { Effect, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import {
AwsCustomResource,
AwsCustomResourcePolicy,
PhysicalResourceId,
} from 'aws-cdk-lib/custom-resources';
import { NagSuppressions } from 'cdk-nag';
import { Construct } from 'constructs';
export interface AwsManagedPrefixListProps {
/**
* Name of the AWS-managed prefix list, e.g. com.amazonaws.global.cloudfront.origin-facing
* List them via:
* aws ec2 describe-managed-prefix-lists --region <REGION> \
* | jq -r '.PrefixLists[] | select(.PrefixListName == "com.amazonaws.global.cloudfront.origin-facing") | .PrefixListId'
*/
readonly name: string;
}
// copied with grace from https://github.com/aws/aws-cdk/issues/15115#issuecomment-1665104687
// until CDK supports it natively
export class AwsManagedPrefixList extends Construct {
public readonly prefixList: IPrefixList;
constructor(scope: Construct, id: string, { name }: AwsManagedPrefixListProps) {
super(scope, id);
const cr = new AwsCustomResource(this, 'GetPrefixListId', {
logGroup: new LogGroup(this, 'GetPrefixListIdLogGroup', {
removalPolicy: RemovalPolicy.DESTROY,
retention: 1,
}),
onUpdate: {
service: '@aws-sdk/client-ec2',
action: 'DescribeManagedPrefixListsCommand',
parameters: {
Filters: [{ Name: 'prefix-list-name', Values: [name] }],
},
physicalResourceId: PhysicalResourceId.of(`${id}-${this.node.addr.slice(0, 16)}`),
},
policy: AwsCustomResourcePolicy.fromStatements([
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['ec2:DescribeManagedPrefixLists'],
resources: ['*'], // the API is not resource-scoped
}),
]),
role: new Role(this, 'GetPrefixListIdRole', {
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
}),
removalPolicy: RemovalPolicy.DESTROY,
});
// legit wildcard for this provider; the `true` applies it to the generated children too
NagSuppressions.addResourceSuppressions(
[cr],
[{ id: 'AwsSolutions-IAM5', reason: 'For this provider wildcards are fine' }],
true,
);
const prefixListId = cr.getResponseField('PrefixLists.0.PrefixListId');
this.prefixList = PrefixList.fromPrefixListId(this, 'PrefixList', prefixListId);
}
}Suppress by path for generated resources
The second kind is for resources you don't have a handle on, because CDK generated them for you. The AwsCustomResource provider is shared, singleton, generated code (that AWS679f53fac002430cb0da5b7982bd2287 resource you'll see in every stack that uses one). You can't reach it through a construct reference, only by its synthesized path. It trips AwsSolutions-L1, the rule that flags a Lambda not on the latest runtime, because AWS manages that provider's runtime, not you. So I keep those in one honest helper:
import { Stack } from 'aws-cdk-lib';
import { NagSuppressions } from 'cdk-nag';
// Nags we can only address by resource path, not through a construct
// reference. Mostly custom resources with a lot of generated code
// under the hood.
export function suppressCommonNags(stack: Stack) {
NagSuppressions.addResourceSuppressionsByPath(
stack,
`/${stack.stackName}/AWS679f53fac002430cb0da5b7982bd2287/Resource`,
[
{
id: 'AwsSolutions-L1',
reason:
'We manage runtime for AwsSdkCall Custom Resource and will update when necessary',
},
],
);
}One version note: the code above uses the NagSuppressions API, which is what I ran. Recent cdk-nag (v3) moved suppressions onto CDK's native Validations.of(construct).acknowledge({ id, reason }) instead. Same idea, different call, so if you're on the latest cdk-nag, translate the two suppression calls accordingly.
Conclusion
cdk-nag I'd wire in from the beginning now. It's one Aspect, one test that fails on an unsuppressed nag, and a tendency to write a real reason on every suppression instead of silently muting it. Those reasons are the point: a suppressed nag with "wildcards are fine for this provider" next to it is a decision, an empty suppression is just noise you've hidden. That alone raises the floor of the whole library.
Least privilege from the beginning and the details with the upload to public S3 buckets I learned now for the future.
If you've got a clean, repeatable flow for generating least-privilege deploy roles for CDK, that's the piece I still want. Until then, happy (and secure) shipping!



