Skip to content

Commit 20aeb0f

Browse files
feat(core): template validation after synthesis (#23951)
Integrate policy as code tools into CDK synthesis via a plugin mechanism. Immediately after synthesis, the framework invokes all the registered plugins, collect the results and, if there are any violations, show a report to the user. Application developers register plugins to a `Stage`: ```ts const app = new App({ validationPlugins: [ new SomePolicyAgentPlugin(), new AnotherPolicyAgentPugin(), ] }); ``` Plugin authors must implement the `IPolicyValidationPlugin` interface. Hypothetical example of a CloudFormation Guard plugin: ```ts export class CfnGuardValidator implements IPolicyValidationPlugin { public readonly name = 'cfn-guard-validator'; constructor() {} validate(context: IPolicyValidationContext): PolicyValidationPluginReport { // execute the cfn-guard cli and get the JSON response from the tool const cliResultJson = executeCfnGuardCli(); // parse the results and return the violations format // that the framework expects const violations = parseGuardResults(cliResultJson); // construct the report and return it to the framework // this is a vastly over simplified example that is only // meant to show the structure of the report that is returned return { success: false, violations: [{ ruleName: violations.ruleName, recommendation: violations.recommendation, fix: violations.fix, violatingResources: [{ resourceName: violations.resourceName, locations: violations.locations, templatePath: violations.templatePath, }], }], }; } } ``` Co-authored-by: corymhall <[email protected]>
1 parent 9d4b66a commit 20aeb0f

25 files changed

+2674
-17
lines changed

package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,6 @@
8383
"@aws-cdk/assertions-alpha/fs-extra/**",
8484
"@aws-cdk/assertions/fs-extra",
8585
"@aws-cdk/assertions/fs-extra/**",
86-
"@aws-cdk/aws-iot-actions-alpha/case",
87-
"@aws-cdk/aws-iot-actions-alpha/case/**",
8886
"@aws-cdk/aws-codebuild/yaml",
8987
"@aws-cdk/aws-codebuild/yaml/**",
9088
"@aws-cdk/aws-codepipeline-actions/case",
@@ -99,6 +97,8 @@
9997
"@aws-cdk/aws-eks/yaml/**",
10098
"@aws-cdk/aws-events-targets/aws-sdk",
10199
"@aws-cdk/aws-events-targets/aws-sdk/**",
100+
"@aws-cdk/aws-iot-actions-alpha/case",
101+
"@aws-cdk/aws-iot-actions-alpha/case/**",
102102
"@aws-cdk/aws-iot-actions/case",
103103
"@aws-cdk/aws-iot-actions/case/**",
104104
"@aws-cdk/aws-s3-deployment/case",
@@ -117,6 +117,8 @@
117117
"@aws-cdk/core/ignore/**",
118118
"@aws-cdk/core/minimatch",
119119
"@aws-cdk/core/minimatch/**",
120+
"@aws-cdk/core/table",
121+
"@aws-cdk/core/table/**",
120122
"@aws-cdk/cx-api/semver",
121123
"@aws-cdk/cx-api/semver/**",
122124
"@aws-cdk/pipelines/aws-sdk",

packages/@aws-cdk/core/README.md

+114
Original file line numberDiff line numberDiff line change
@@ -1302,4 +1302,118 @@ permissions boundary attached.
13021302

13031303
For more details see the [Permissions Boundary](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_iam-readme.html#permissions-boundaries) section in the IAM guide.
13041304

1305+
## Policy Validation
1306+
1307+
If you or your organization use (or would like to use) any policy validation tool, such as
1308+
[CloudFormation
1309+
Guard](https://docs.aws.amazon.com/cfn-guard/latest/ug/what-is-guard.html) or
1310+
[OPA](https://www.openpolicyagent.org/), to define constraints on your
1311+
CloudFormation template, you can incorporate them into the CDK application.
1312+
By using the appropriate plugin, you can make the CDK application check the
1313+
generated CloudFormation templates against your policies immediately after
1314+
synthesis. If there are any violations, the synthesis will fail and a report
1315+
will be printed to the console or to a file (see below).
1316+
1317+
> **Note**
1318+
> This feature is considered experimental, and both the plugin API and the
1319+
> format of the validation report are subject to change in the future.
1320+
1321+
### For application developers
1322+
1323+
To use one or more validation plugins in your application, use the
1324+
`policyValidationBeta1` property of `Stage`:
1325+
1326+
```ts
1327+
// globally for the entire app (an app is a stage)
1328+
const app = new App({
1329+
policyValidationBeta1: [
1330+
// These hypothetical classes implement IValidationPlugin:
1331+
new ThirdPartyPluginX(),
1332+
new ThirdPartyPluginY(),
1333+
],
1334+
});
1335+
1336+
// only apply to a particular stage
1337+
const prodStage = new Stage(app, 'ProdStage', {
1338+
policyValidationBeta1: [...],
1339+
});
1340+
```
1341+
1342+
Immediately after synthesis, all plugins registered this way will be invoked to
1343+
validate all the templates generated in the scope you defined. In particular, if
1344+
you register the templates in the `App` object, all templates will be subject to
1345+
validation.
1346+
1347+
> **Warning**
1348+
> Other than modifying the cloud assembly, plugins can do anything that your CDK
1349+
> application can. They can read data from the filesystem, access the network
1350+
> etc. It's your responsibility as the consumer of a plugin to verify that it is
1351+
> secure to use.
1352+
1353+
By default, the report will be printed in a human readable format. If you want a
1354+
report in JSON format, enable it using the `@aws-cdk/core:validationReportJson`
1355+
context passing it directly to the application:
1356+
1357+
```ts
1358+
const app = new App({
1359+
context: { '@aws-cdk/core:validationReportJson': true },
1360+
});
1361+
```
1362+
1363+
Alternatively, you can set this context key-value pair using the `cdk.json` or
1364+
`cdk.context.json` files in your project directory (see
1365+
[Runtime context](https://docs.aws.amazon.com/cdk/v2/guide/context.html)).
1366+
1367+
If you choose the JSON format, the CDK will print the policy validation report
1368+
to a file called `policy-validation-report.json` in the cloud assembly
1369+
directory. For the default, human-readable format, the report will be printed to
1370+
the standard output.
1371+
1372+
### For plugin authors
1373+
1374+
The communication protocol between the CDK core module and your policy tool is
1375+
defined by the `IValidationPluginBeta1` interface. To create a new plugin you must
1376+
write a class that implements this interface. There are two things you need to
1377+
implement: the plugin name (by overriding the `name` property), and the
1378+
`validate()` method.
1379+
1380+
The framework will call `validate()`, passing an `IValidationContextBeta1` object.
1381+
The location of the templates to be validated is given by `templatePaths`. The
1382+
plugin should return an instance of `ValidationPluginReportBeta1`. This object
1383+
represents the report that the user wil receive at the end of the synthesis.
1384+
1385+
```ts
1386+
validate(context: ValidationContextBeta1): ValidationReportBeta1 {
1387+
// First read the templates using context.templatePaths...
1388+
1389+
// ...then perform the validation, and then compose and return the report.
1390+
// Using hard-coded values here for better clarity:
1391+
return {
1392+
success: false,
1393+
violations: [{
1394+
ruleName: 'CKV_AWS_117',
1395+
recommendation: 'Ensure that AWS Lambda function is configured inside a VPC',
1396+
fix: 'https://docs.bridgecrew.io/docs/ensure-that-aws-lambda-function-is-configured-inside-a-vpc-1',
1397+
violatingResources: [{
1398+
resourceName: 'MyFunction3BAA72D1',
1399+
templatePath: '/home/johndoe/myapp/cdk.out/MyService.template.json',
1400+
locations: 'Properties/VpcConfig',
1401+
}],
1402+
}],
1403+
};
1404+
}
1405+
```
1406+
1407+
Note that plugins are not allowed to modify anything in the cloud assembly. Any
1408+
attempt to do so will result in synthesis failure.
1409+
1410+
If your plugin depends on an external tool, keep in mind that some developers may
1411+
not have that tool installed in their workstations yet. To minimize friction, we
1412+
highly recommend that you provide some installation script along with your
1413+
plugin package, to automate the whole process. Better yet, run that script as
1414+
part of the installation of your package. With `npm`, for example, you can run
1415+
add it to the `postinstall`
1416+
[script](https://docs.npmjs.com/cli/v9/using-npm/scripts) in the `package.json`
1417+
file.
1418+
13051419
<!--END CORE DOCUMENTATION-->

packages/@aws-cdk/core/lib/app.ts

+9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER } from './private/private-con
55
import { addCustomSynthesis, ICustomSynthesis } from './private/synthesis';
66
import { IReusableStackSynthesizer } from './stack-synthesizers';
77
import { Stage } from './stage';
8+
import { IPolicyValidationPluginBeta1 } from './validation/validation';
89

910
const APP_SYMBOL = Symbol.for('@aws-cdk/core.App');
1011

@@ -118,6 +119,13 @@ export interface AppProps {
118119
* @default - A `DefaultStackSynthesizer` with default settings
119120
*/
120121
readonly defaultStackSynthesizer?: IReusableStackSynthesizer;
122+
123+
/**
124+
* Validation plugins to run after synthesis
125+
*
126+
* @default - no validation plugins
127+
*/
128+
readonly policyValidationBeta1?: IPolicyValidationPluginBeta1[];
121129
}
122130

123131
/**
@@ -159,6 +167,7 @@ export class App extends Stage {
159167
constructor(props: AppProps = {}) {
160168
super(undefined as any, '', {
161169
outdir: props.outdir ?? process.env[cxapi.OUTDIR_ENV],
170+
policyValidationBeta1: props.policyValidationBeta1,
162171
});
163172

164173
Object.defineProperty(this, APP_SYMBOL, { value: true });

packages/@aws-cdk/core/lib/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export * from './cloudformation.generated';
6464
export * from './feature-flags';
6565
export * from './permissions-boundary';
6666

67+
export * from './validation';
68+
6769
// WARNING: Should not be exported, but currently is because of a bug. See the
6870
// class description for more information.
6971
export * from './private/intrinsic';

packages/@aws-cdk/core/lib/private/runtime-info.ts

+31
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { IConstruct } from 'constructs';
2+
import { App } from '../app';
23
import { Stack } from '../stack';
34
import { Stage } from '../stage';
45

@@ -38,6 +39,34 @@ export function constructInfoFromConstruct(construct: IConstruct): ConstructInfo
3839
return undefined;
3940
}
4041

42+
/**
43+
* Add analytics data for any validation plugins that are used.
44+
* Since validation plugins are not constructs we have to handle them
45+
* as a special case
46+
*/
47+
function addValidationPluginInfo(stack: Stack, allConstructInfos: ConstructInfo[]): void {
48+
let stage = Stage.of(stack);
49+
let done = false;
50+
do {
51+
if (App.isApp(stage)) {
52+
done = true;
53+
}
54+
if (stage) {
55+
allConstructInfos.push(...stage.policyValidationBeta1.map(
56+
plugin => {
57+
return {
58+
// the fqn can be in the format of `package.module.construct`
59+
// those get pulled out into separate fields
60+
fqn: `policyValidation.${plugin.name}`,
61+
version: plugin.version ?? '0.0.0',
62+
};
63+
},
64+
));
65+
stage = Stage.of(stage);
66+
}
67+
} while (!done && stage);
68+
}
69+
4170
/**
4271
* For a given stack, walks the tree and finds the runtime info for all constructs within the tree.
4372
* Returns the unique list of construct info present in the stack,
@@ -57,6 +86,8 @@ export function constructInfoFromStack(stack: Stack): ConstructInfo[] {
5786
version: getJsiiAgentVersion(),
5887
});
5988

89+
addValidationPluginInfo(stack, allConstructInfos);
90+
6091
// Filter out duplicate values
6192
const uniqKeys = new Set();
6293
return allConstructInfos.filter(construct => {

packages/@aws-cdk/core/lib/private/synthesis.ts

+137-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { createHash } from 'crypto';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
14
import * as cxapi from '@aws-cdk/cx-api';
5+
import { CloudAssembly } from '@aws-cdk/cx-api';
26
import { IConstruct } from 'constructs';
37
import { MetadataResource } from './metadata-resource';
48
import { prepareApp } from './prepare-app';
@@ -9,6 +13,12 @@ import { Aspects, IAspect } from '../aspect';
913
import { Stack } from '../stack';
1014
import { ISynthesisSession } from '../stack-synthesizers/types';
1115
import { Stage, StageSynthesisOptions } from '../stage';
16+
import { IPolicyValidationPluginBeta1 } from '../validation';
17+
import { ConstructTree } from '../validation/private/construct-tree';
18+
import { PolicyValidationReportFormatter, NamedValidationPluginReport } from '../validation/private/report';
19+
20+
const POLICY_VALIDATION_FILE_PATH = 'policy-validation-report.json';
21+
const VALIDATION_REPORT_JSON_CONTEXT = '@aws-cdk/core:validationReportJson';
1222

1323
/**
1424
* Options for `synthesize()`
@@ -49,7 +59,115 @@ export function synthesize(root: IConstruct, options: SynthesisOptions = { }): c
4959
// stacks to add themselves to the synthesized cloud assembly.
5060
synthesizeTree(root, builder, options.validateOnSynthesis);
5161

52-
return builder.buildAssembly();
62+
const assembly = builder.buildAssembly();
63+
64+
invokeValidationPlugins(root, builder.outdir, assembly);
65+
66+
return assembly;
67+
}
68+
69+
/**
70+
* Find all the assemblies in the app, including all levels of nested assemblies
71+
* and return a map where the assemblyId is the key
72+
*/
73+
function getAssemblies(root: App, rootAssembly: CloudAssembly): Map<string, CloudAssembly> {
74+
const assemblies = new Map<string, CloudAssembly>();
75+
assemblies.set(root.artifactId, rootAssembly);
76+
visitAssemblies(root, 'pre', construct => {
77+
const stage = construct as Stage;
78+
if (stage.parentStage && assemblies.has(stage.parentStage.artifactId)) {
79+
assemblies.set(
80+
stage.artifactId,
81+
assemblies.get(stage.parentStage.artifactId)!.getNestedAssembly(stage.artifactId),
82+
);
83+
}
84+
});
85+
return assemblies;
86+
}
87+
88+
/**
89+
* Invoke validation plugins for all stages in an App.
90+
*/
91+
function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: CloudAssembly) {
92+
if (!App.isApp(root)) return;
93+
const hash = computeChecksumOfFolder(outdir);
94+
const assemblies = getAssemblies(root, assembly);
95+
const templatePathsByPlugin: Map<IPolicyValidationPluginBeta1, string[]> = new Map();
96+
visitAssemblies(root, 'post', construct => {
97+
if (Stage.isStage(construct)) {
98+
for (const plugin of construct.policyValidationBeta1) {
99+
if (!templatePathsByPlugin.has(plugin)) {
100+
templatePathsByPlugin.set(plugin, []);
101+
}
102+
let assemblyToUse = assemblies.get(construct.artifactId);
103+
if (!assemblyToUse) throw new Error(`Validation failed, cannot find cloud assembly for stage ${construct.stageName}`);
104+
templatePathsByPlugin.get(plugin)!.push(...assemblyToUse.stacksRecursively.map(stack => stack.templateFullPath));
105+
}
106+
}
107+
});
108+
109+
const reports: NamedValidationPluginReport[] = [];
110+
if (templatePathsByPlugin.size > 0) {
111+
// eslint-disable-next-line no-console
112+
console.log('Performing Policy Validations\n');
113+
}
114+
for (const [plugin, paths] of templatePathsByPlugin.entries()) {
115+
try {
116+
const report = plugin.validate({ templatePaths: paths });
117+
reports.push({ ...report, pluginName: plugin.name });
118+
} catch (e: any) {
119+
reports.push({
120+
success: false,
121+
pluginName: plugin.name,
122+
pluginVersion: plugin.version,
123+
violations: [],
124+
metadata: {
125+
error: `Validation plugin '${plugin.name}' failed: ${e.message}`,
126+
},
127+
});
128+
}
129+
if (computeChecksumOfFolder(outdir) !== hash) {
130+
throw new Error(`Illegal operation: validation plugin '${plugin.name}' modified the cloud assembly`);
131+
}
132+
}
133+
134+
if (reports.length > 0) {
135+
const tree = new ConstructTree(root);
136+
const formatter = new PolicyValidationReportFormatter(tree);
137+
const formatJson = root.node.tryGetContext(VALIDATION_REPORT_JSON_CONTEXT) ?? false;
138+
const output = formatJson
139+
? formatter.formatJson(reports)
140+
: formatter.formatPrettyPrinted(reports);
141+
142+
if (formatJson) {
143+
fs.writeFileSync(path.join(assembly.directory, POLICY_VALIDATION_FILE_PATH), JSON.stringify(output, undefined, 2));
144+
} else {
145+
// eslint-disable-next-line no-console
146+
console.error(output);
147+
}
148+
const failed = reports.some(r => !r.success);
149+
if (failed) {
150+
throw new Error('Validation failed. See the validation report above for details');
151+
} else {
152+
// eslint-disable-next-line no-console
153+
console.log('Policy Validation Successful!');
154+
}
155+
}
156+
}
157+
158+
function computeChecksumOfFolder(folder: string): string {
159+
const hash = createHash('sha256');
160+
const files = fs.readdirSync(folder, { withFileTypes: true });
161+
162+
for (const file of files) {
163+
const fullPath = path.join(folder, file.name);
164+
if (file.isDirectory()) {
165+
hash.update(computeChecksumOfFolder(fullPath));
166+
} else if (file.isFile()) {
167+
hash.update(fs.readFileSync(fullPath));
168+
}
169+
}
170+
return hash.digest().toString('hex');
53171
}
54172

55173
const CUSTOM_SYNTHESIS_SYM = Symbol.for('@aws-cdk/core:customSynthesis');
@@ -232,6 +350,24 @@ function validateTree(root: IConstruct) {
232350
}
233351
}
234352

353+
/**
354+
* Visit the given construct tree in either pre or post order, only looking at Assemblies
355+
*/
356+
function visitAssemblies(root: IConstruct, order: 'pre' | 'post', cb: (x: IConstruct) => void) {
357+
if (order === 'pre') {
358+
cb(root);
359+
}
360+
361+
for (const child of root.node.children) {
362+
if (!Stage.isStage(child)) { continue; }
363+
visitAssemblies(child, order, cb);
364+
}
365+
366+
if (order === 'post') {
367+
cb(root);
368+
}
369+
}
370+
235371
/**
236372
* Visit the given construct tree in either pre or post order, stopping at Assemblies
237373
*/

0 commit comments

Comments
 (0)