Skip to content

Commit 707b049

Browse files
committed
add some util functions
1 parent 83a10bf commit 707b049

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

packages/cdkConstructs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
export * from "./constructs/TypescriptLambdaFunction.js"
33
export * from "./apps/createApp.js"
44
export * from "./config/index.js"
5+
export * from "./utils/helpers.js"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {Stack, CfnResource} from "aws-cdk-lib"
2+
import {NagPackSuppression, NagSuppressions} from "cdk-nag"
3+
import {IConstruct} from "constructs"
4+
5+
/**
6+
* Locate CloudFormation resources by their synthesized `aws:cdk:path` metadata.
7+
*
8+
* Use this helper when logical IDs vary between synths but the fully-qualified
9+
* construct path is stable (for example when targeting resources for nag
10+
* suppressions).
11+
*
12+
* @param construct - Root construct that will be walked recursively.
13+
* @param paths - One or more fully qualified `aws:cdk:path` strings to match.
14+
* @returns Every resource whose metadata path equals one of the supplied paths.
15+
*/
16+
export function findCloudFormationResourcesByPath(construct: IConstruct, paths: Array<string>): Array<CfnResource> {
17+
const matches: Array<CfnResource> = []
18+
const targetPaths = new Set(paths)
19+
const seen = new Set<string>()
20+
const search = (node: IConstruct): void => {
21+
if (node instanceof CfnResource) {
22+
const resourcePath = node.cfnOptions.metadata?.["aws:cdk:path"]
23+
if (typeof resourcePath === "string" && targetPaths.has(resourcePath) && !seen.has(node.logicalId)) {
24+
matches.push(node)
25+
seen.add(node.logicalId)
26+
}
27+
}
28+
for (const child of node.node.children) {
29+
search(child)
30+
}
31+
}
32+
search(construct)
33+
return matches
34+
}
35+
36+
/**
37+
* Locate CloudFormation resources by CloudFormation type.
38+
*
39+
* Recursively traverses the construct tree and returns all `CfnResource`
40+
* instances that match the provided `AWS::<Service>::<Resource>` type string.
41+
*
42+
* @param construct - Root construct to traverse.
43+
* @param type - CloudFormation type identifier (for example `AWS::Lambda::Function`).
44+
* @returns All resources whose `cfnResourceType` matches `type`.
45+
*/
46+
export function findCloudFormationResourcesByType(construct: IConstruct, type: string): Array<CfnResource> {
47+
const matches: Array<CfnResource> = []
48+
const search = (node: IConstruct): void => {
49+
if (node instanceof CfnResource && node.cfnResourceType === type) {
50+
matches.push(node)
51+
}
52+
for (const child of node.node.children) {
53+
search(child)
54+
}
55+
}
56+
search(construct)
57+
return matches
58+
}
59+
/**
60+
* Merge cfn-guard rule suppressions onto the provided resources.
61+
*
62+
* Ensures the metadata structure exists, deduplicates rule IDs, and leaves any
63+
* pre-existing suppressions intact.
64+
*
65+
* @param resources - CloudFormation resources that require suppressions.
66+
* @param rules - One or more cfn-guard rule identifiers to suppress.
67+
*/
68+
export function addSuppressions(resources: Array<CfnResource>, rules: Array<string>): void {
69+
resources.forEach(resource => {
70+
if (!resource.cfnOptions.metadata) {
71+
resource.cfnOptions.metadata = {}
72+
}
73+
const existing = resource.cfnOptions.metadata.guard?.SuppressedRules || []
74+
const combined = Array.from(new Set([...existing, ...rules]))
75+
resource.cfnOptions.metadata.guard = {SuppressedRules: combined}
76+
})
77+
}
78+
79+
/**
80+
* Apply the default lambda-focused cfn-guard suppressions.
81+
*
82+
* Finds every `AWS::Lambda::Function` in the stack and suppresses the common
83+
* Lambda guard rules related to DLQ usage, VPC placement, and reserved concurrency.
84+
*
85+
* @param stack - Stack containing the Lambda resources to update.
86+
*/
87+
export function addLambdaCfnGuardSuppressions(stack: Stack): void {
88+
const allLambdas = findCloudFormationResourcesByType(stack, "AWS::Lambda::Function")
89+
addSuppressions(allLambdas, ["LAMBDA_DLQ_CHECK", "LAMBDA_INSIDE_VPC", "LAMBDA_CONCURRENCY_CHECK"])
90+
}
91+
92+
/**
93+
* Attach identical nag suppressions to several construct paths.
94+
*
95+
* Invokes `safeAddNagSuppression` for each path, allowing missing paths to be
96+
* skipped without failing the entire operation.
97+
*
98+
* @param stack - CDK stack that contains the constructs.
99+
* @param paths - Paths to apply the suppression group to.
100+
* @param suppressions - Suppression definitions shared by all targets.
101+
* The suppressions must include id and reason and can optionally include appliesTo.
102+
*/
103+
export function safeAddNagSuppressionGroup(
104+
stack: Stack,
105+
paths: Array<string>,
106+
suppressions: Array<NagPackSuppression>
107+
) {
108+
paths.forEach(path => safeAddNagSuppression(stack, path, suppressions))
109+
}
110+
111+
/**
112+
* Attach nag suppressions to a single construct path.
113+
*
114+
* Wraps `NagSuppressions.addResourceSuppressionsByPath` and logs an info-level
115+
* message when the path cannot be resolved, preventing build failures in
116+
* partially synthesized stacks.
117+
*
118+
* @param stack - CDK stack that contains the construct.
119+
* @param path - Fully qualified CDK path to the resource.
120+
* @param suppressions - Suppression entries to apply.
121+
*/
122+
export function safeAddNagSuppression(stack: Stack, path: string, suppressions: Array<NagPackSuppression>) {
123+
try {
124+
NagSuppressions.addResourceSuppressionsByPath(stack, path, suppressions)
125+
} catch (err) {
126+
console.log(`Could not find path ${path}: ${err}`)
127+
}
128+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
afterEach,
3+
describe,
4+
expect,
5+
test,
6+
vi
7+
} from "vitest"
8+
import {Stack, CfnResource} from "aws-cdk-lib"
9+
import {Code, Function as LambdaFunction, Runtime} from "aws-cdk-lib/aws-lambda"
10+
import {NagPackSuppression, NagSuppressions} from "cdk-nag"
11+
12+
import * as helpers from "../../src/utils/helpers"
13+
14+
const defaultSuppressionRules = ["LAMBDA_DLQ_CHECK", "LAMBDA_INSIDE_VPC", "LAMBDA_CONCURRENCY_CHECK"]
15+
16+
const createResource = (stack: Stack, id: string, type = "Custom::Test", path?: string): CfnResource => {
17+
const resource = new CfnResource(stack, id, {type, properties: {}})
18+
resource.cfnOptions.metadata = {
19+
...(resource.cfnOptions.metadata ?? {}),
20+
"aws:cdk:path": path ?? `${stack.stackName}/${id}`
21+
}
22+
return resource
23+
}
24+
25+
afterEach(() => {
26+
vi.restoreAllMocks()
27+
})
28+
29+
describe("findCloudFormationResourcesByPath", () => {
30+
test("returns unique matches for the provided metadata paths", () => {
31+
const stack = new Stack(undefined, "HelpersTestStack")
32+
const first = createResource(stack, "First", "Custom::Foo", "match/one")
33+
const second = createResource(stack, "Second", "Custom::Foo", "match/two")
34+
createResource(stack, "Third", "Custom::Foo", "nope")
35+
36+
const matches = helpers.findCloudFormationResourcesByPath(stack, ["match/one", "match/one", "match/two"])
37+
38+
expect(matches).toEqual([first, second])
39+
})
40+
})
41+
42+
describe("findCloudFormationResourcesByType", () => {
43+
test("returns every resource whose CloudFormation type matches", () => {
44+
const stack = new Stack(undefined, "HelpersTestStack")
45+
const fooOne = createResource(stack, "FooOne", "Custom::Foo")
46+
const fooTwo = createResource(stack, "FooTwo", "Custom::Foo")
47+
createResource(stack, "Bar", "Custom::Bar")
48+
49+
const matches = helpers.findCloudFormationResourcesByType(stack, "Custom::Foo")
50+
51+
expect(matches).toEqual([fooOne, fooTwo])
52+
})
53+
})
54+
55+
describe("addSuppressions", () => {
56+
test("merges new rules, deduplicates them, and creates metadata when missing", () => {
57+
const stack = new Stack(undefined, "HelpersTestStack")
58+
const existing = createResource(stack, "Existing")
59+
existing.cfnOptions.metadata = {
60+
...existing.cfnOptions.metadata,
61+
guard: {SuppressedRules: ["EXISTING", "SHARED"]}
62+
}
63+
const empty = createResource(stack, "Empty")
64+
65+
helpers.addSuppressions([existing, empty], ["SHARED", "NEW"])
66+
67+
expect(existing.cfnOptions.metadata?.guard?.SuppressedRules).toEqual(["EXISTING", "SHARED", "NEW"])
68+
expect(empty.cfnOptions.metadata?.guard?.SuppressedRules).toEqual(["SHARED", "NEW"])
69+
})
70+
})
71+
72+
describe("addLambdaCfnGuardSuppressions", () => {
73+
test("applies the default lambda suppressions to every lambda in the stack", () => {
74+
const stack = new Stack(undefined, "HelpersTestStack")
75+
const lambdaOne = new LambdaFunction(stack, "LambdaOne", {
76+
runtime: Runtime.NODEJS_18_X,
77+
handler: "index.handler",
78+
code: Code.fromInline("exports.handler = async () => {};")
79+
})
80+
const lambdaTwo = new LambdaFunction(stack, "LambdaTwo", {
81+
runtime: Runtime.NODEJS_18_X,
82+
handler: "index.handler",
83+
code: Code.fromInline("exports.handler = async () => {};")
84+
})
85+
86+
helpers.addLambdaCfnGuardSuppressions(stack)
87+
88+
const firstCfn = lambdaOne.node.defaultChild as CfnResource
89+
const secondCfn = lambdaTwo.node.defaultChild as CfnResource
90+
expect(firstCfn.cfnOptions.metadata?.guard?.SuppressedRules).toEqual(defaultSuppressionRules)
91+
expect(secondCfn.cfnOptions.metadata?.guard?.SuppressedRules).toEqual(defaultSuppressionRules)
92+
})
93+
})
94+
95+
describe("safeAddNagSuppressionGroup", () => {
96+
test("invokes cdk-nag for every provided path", () => {
97+
const stack = new Stack(undefined, "HelpersTestStack")
98+
const suppressions: Array<NagPackSuppression> = [{id: "RULE", reason: "already covered"}]
99+
const spy = vi.spyOn(NagSuppressions, "addResourceSuppressionsByPath").mockImplementation(() => {})
100+
101+
helpers.safeAddNagSuppressionGroup(stack, ["one", "two"], suppressions)
102+
103+
expect(spy).toHaveBeenCalledTimes(2)
104+
expect(spy).toHaveBeenNthCalledWith(1, stack, "one", suppressions)
105+
expect(spy).toHaveBeenNthCalledWith(2, stack, "two", suppressions)
106+
})
107+
})
108+
109+
describe("safeAddNagSuppression", () => {
110+
const sampleSuppressions: Array<NagPackSuppression> = [{id: "RULE", reason: "covered elsewhere"}]
111+
112+
test("routes suppressions to cdk-nag", () => {
113+
const stack = new Stack(undefined, "HelpersTestStack")
114+
const spy = vi.spyOn(NagSuppressions, "addResourceSuppressionsByPath").mockImplementation(() => {})
115+
116+
helpers.safeAddNagSuppression(stack, "path/to/resource", sampleSuppressions)
117+
118+
expect(spy).toHaveBeenCalledWith(stack, "path/to/resource", sampleSuppressions)
119+
})
120+
121+
test("logs and swallows errors when the target path cannot be resolved", () => {
122+
const stack = new Stack(undefined, "HelpersTestStack")
123+
vi.spyOn(NagSuppressions, "addResourceSuppressionsByPath").mockImplementation(() => {
124+
throw new Error("missing")
125+
})
126+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
127+
128+
expect(() => helpers.safeAddNagSuppression(stack, "missing/path", sampleSuppressions)).not.toThrow()
129+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("missing/path"))
130+
})
131+
})

0 commit comments

Comments
 (0)