diff --git a/package.json b/package.json index 4b6c96b79d550..eecc83ce460de 100644 --- a/package.json +++ b/package.json @@ -83,8 +83,6 @@ "@aws-cdk/assertions-alpha/fs-extra/**", "@aws-cdk/assertions/fs-extra", "@aws-cdk/assertions/fs-extra/**", - "@aws-cdk/aws-iot-actions-alpha/case", - "@aws-cdk/aws-iot-actions-alpha/case/**", "@aws-cdk/aws-codebuild/yaml", "@aws-cdk/aws-codebuild/yaml/**", "@aws-cdk/aws-codepipeline-actions/case", @@ -99,6 +97,8 @@ "@aws-cdk/aws-eks/yaml/**", "@aws-cdk/aws-events-targets/aws-sdk", "@aws-cdk/aws-events-targets/aws-sdk/**", + "@aws-cdk/aws-iot-actions-alpha/case", + "@aws-cdk/aws-iot-actions-alpha/case/**", "@aws-cdk/aws-iot-actions/case", "@aws-cdk/aws-iot-actions/case/**", "@aws-cdk/aws-s3-deployment/case", @@ -117,6 +117,8 @@ "@aws-cdk/core/ignore/**", "@aws-cdk/core/minimatch", "@aws-cdk/core/minimatch/**", + "@aws-cdk/core/table", + "@aws-cdk/core/table/**", "@aws-cdk/cx-api/semver", "@aws-cdk/cx-api/semver/**", "@aws-cdk/pipelines/aws-sdk", diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index da9707ff8f93d..860961b8300b9 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -1302,4 +1302,118 @@ permissions boundary attached. 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. +## Policy Validation + +If you or your organization use (or would like to use) any policy validation tool, such as +[CloudFormation +Guard](https://docs.aws.amazon.com/cfn-guard/latest/ug/what-is-guard.html) or +[OPA](https://www.openpolicyagent.org/), to define constraints on your +CloudFormation template, you can incorporate them into the CDK application. +By using the appropriate plugin, you can make the CDK application check the +generated CloudFormation templates against your policies immediately after +synthesis. If there are any violations, the synthesis will fail and a report +will be printed to the console or to a file (see below). + +> **Note** +> This feature is considered experimental, and both the plugin API and the +> format of the validation report are subject to change in the future. + +### For application developers + +To use one or more validation plugins in your application, use the +`policyValidationBeta1` property of `Stage`: + +```ts +// globally for the entire app (an app is a stage) +const app = new App({ + policyValidationBeta1: [ + // These hypothetical classes implement IValidationPlugin: + new ThirdPartyPluginX(), + new ThirdPartyPluginY(), + ], +}); + +// only apply to a particular stage +const prodStage = new Stage(app, 'ProdStage', { + policyValidationBeta1: [...], +}); +``` + +Immediately after synthesis, all plugins registered this way will be invoked to +validate all the templates generated in the scope you defined. In particular, if +you register the templates in the `App` object, all templates will be subject to +validation. + +> **Warning** +> Other than modifying the cloud assembly, plugins can do anything that your CDK +> application can. They can read data from the filesystem, access the network +> etc. It's your responsibility as the consumer of a plugin to verify that it is +> secure to use. + +By default, the report will be printed in a human readable format. If you want a +report in JSON format, enable it using the `@aws-cdk/core:validationReportJson` +context passing it directly to the application: + +```ts +const app = new App({ + context: { '@aws-cdk/core:validationReportJson': true }, +}); +``` + +Alternatively, you can set this context key-value pair using the `cdk.json` or +`cdk.context.json` files in your project directory (see +[Runtime context](https://docs.aws.amazon.com/cdk/v2/guide/context.html)). + +If you choose the JSON format, the CDK will print the policy validation report +to a file called `policy-validation-report.json` in the cloud assembly +directory. For the default, human-readable format, the report will be printed to +the standard output. + +### For plugin authors + +The communication protocol between the CDK core module and your policy tool is +defined by the `IValidationPluginBeta1` interface. To create a new plugin you must +write a class that implements this interface. There are two things you need to +implement: the plugin name (by overriding the `name` property), and the +`validate()` method. + +The framework will call `validate()`, passing an `IValidationContextBeta1` object. +The location of the templates to be validated is given by `templatePaths`. The +plugin should return an instance of `ValidationPluginReportBeta1`. This object +represents the report that the user wil receive at the end of the synthesis. + +```ts +validate(context: ValidationContextBeta1): ValidationReportBeta1 { + // First read the templates using context.templatePaths... + + // ...then perform the validation, and then compose and return the report. + // Using hard-coded values here for better clarity: + return { + success: false, + violations: [{ + ruleName: 'CKV_AWS_117', + recommendation: 'Ensure that AWS Lambda function is configured inside a VPC', + fix: 'https://docs.bridgecrew.io/docs/ensure-that-aws-lambda-function-is-configured-inside-a-vpc-1', + violatingResources: [{ + resourceName: 'MyFunction3BAA72D1', + templatePath: '/home/johndoe/myapp/cdk.out/MyService.template.json', + locations: 'Properties/VpcConfig', + }], + }], + }; +} +``` + +Note that plugins are not allowed to modify anything in the cloud assembly. Any +attempt to do so will result in synthesis failure. + +If your plugin depends on an external tool, keep in mind that some developers may +not have that tool installed in their workstations yet. To minimize friction, we +highly recommend that you provide some installation script along with your +plugin package, to automate the whole process. Better yet, run that script as +part of the installation of your package. With `npm`, for example, you can run +add it to the `postinstall` +[script](https://docs.npmjs.com/cli/v9/using-npm/scripts) in the `package.json` +file. + diff --git a/packages/@aws-cdk/core/lib/app.ts b/packages/@aws-cdk/core/lib/app.ts index 2a3e4a4291696..fe278ecf1a6a3 100644 --- a/packages/@aws-cdk/core/lib/app.ts +++ b/packages/@aws-cdk/core/lib/app.ts @@ -5,6 +5,7 @@ import { PRIVATE_CONTEXT_DEFAULT_STACK_SYNTHESIZER } from './private/private-con import { addCustomSynthesis, ICustomSynthesis } from './private/synthesis'; import { IReusableStackSynthesizer } from './stack-synthesizers'; import { Stage } from './stage'; +import { IPolicyValidationPluginBeta1 } from './validation/validation'; const APP_SYMBOL = Symbol.for('@aws-cdk/core.App'); @@ -118,6 +119,13 @@ export interface AppProps { * @default - A `DefaultStackSynthesizer` with default settings */ readonly defaultStackSynthesizer?: IReusableStackSynthesizer; + + /** + * Validation plugins to run after synthesis + * + * @default - no validation plugins + */ + readonly policyValidationBeta1?: IPolicyValidationPluginBeta1[]; } /** @@ -159,6 +167,7 @@ export class App extends Stage { constructor(props: AppProps = {}) { super(undefined as any, '', { outdir: props.outdir ?? process.env[cxapi.OUTDIR_ENV], + policyValidationBeta1: props.policyValidationBeta1, }); Object.defineProperty(this, APP_SYMBOL, { value: true }); diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 58298eac4e86f..b35e89c0e59e4 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -64,6 +64,8 @@ export * from './cloudformation.generated'; export * from './feature-flags'; export * from './permissions-boundary'; +export * from './validation'; + // WARNING: Should not be exported, but currently is because of a bug. See the // class description for more information. export * from './private/intrinsic'; diff --git a/packages/@aws-cdk/core/lib/private/runtime-info.ts b/packages/@aws-cdk/core/lib/private/runtime-info.ts index d0defa65f54e9..2bb77af582d6f 100644 --- a/packages/@aws-cdk/core/lib/private/runtime-info.ts +++ b/packages/@aws-cdk/core/lib/private/runtime-info.ts @@ -1,4 +1,5 @@ import { IConstruct } from 'constructs'; +import { App } from '../app'; import { Stack } from '../stack'; import { Stage } from '../stage'; @@ -38,6 +39,34 @@ export function constructInfoFromConstruct(construct: IConstruct): ConstructInfo return undefined; } +/** + * Add analytics data for any validation plugins that are used. + * Since validation plugins are not constructs we have to handle them + * as a special case + */ +function addValidationPluginInfo(stack: Stack, allConstructInfos: ConstructInfo[]): void { + let stage = Stage.of(stack); + let done = false; + do { + if (App.isApp(stage)) { + done = true; + } + if (stage) { + allConstructInfos.push(...stage.policyValidationBeta1.map( + plugin => { + return { + // the fqn can be in the format of `package.module.construct` + // those get pulled out into separate fields + fqn: `policyValidation.${plugin.name}`, + version: plugin.version ?? '0.0.0', + }; + }, + )); + stage = Stage.of(stage); + } + } while (!done && stage); +} + /** * For a given stack, walks the tree and finds the runtime info for all constructs within the tree. * Returns the unique list of construct info present in the stack, @@ -57,6 +86,8 @@ export function constructInfoFromStack(stack: Stack): ConstructInfo[] { version: getJsiiAgentVersion(), }); + addValidationPluginInfo(stack, allConstructInfos); + // Filter out duplicate values const uniqKeys = new Set(); return allConstructInfos.filter(construct => { diff --git a/packages/@aws-cdk/core/lib/private/synthesis.ts b/packages/@aws-cdk/core/lib/private/synthesis.ts index 8889281f52606..340ce6d2e38b5 100644 --- a/packages/@aws-cdk/core/lib/private/synthesis.ts +++ b/packages/@aws-cdk/core/lib/private/synthesis.ts @@ -1,4 +1,8 @@ +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; import * as cxapi from '@aws-cdk/cx-api'; +import { CloudAssembly } from '@aws-cdk/cx-api'; import { IConstruct } from 'constructs'; import { MetadataResource } from './metadata-resource'; import { prepareApp } from './prepare-app'; @@ -9,6 +13,12 @@ import { Aspects, IAspect } from '../aspect'; import { Stack } from '../stack'; import { ISynthesisSession } from '../stack-synthesizers/types'; import { Stage, StageSynthesisOptions } from '../stage'; +import { IPolicyValidationPluginBeta1 } from '../validation'; +import { ConstructTree } from '../validation/private/construct-tree'; +import { PolicyValidationReportFormatter, NamedValidationPluginReport } from '../validation/private/report'; + +const POLICY_VALIDATION_FILE_PATH = 'policy-validation-report.json'; +const VALIDATION_REPORT_JSON_CONTEXT = '@aws-cdk/core:validationReportJson'; /** * Options for `synthesize()` @@ -49,7 +59,115 @@ export function synthesize(root: IConstruct, options: SynthesisOptions = { }): c // stacks to add themselves to the synthesized cloud assembly. synthesizeTree(root, builder, options.validateOnSynthesis); - return builder.buildAssembly(); + const assembly = builder.buildAssembly(); + + invokeValidationPlugins(root, builder.outdir, assembly); + + return assembly; +} + +/** + * Find all the assemblies in the app, including all levels of nested assemblies + * and return a map where the assemblyId is the key + */ +function getAssemblies(root: App, rootAssembly: CloudAssembly): Map { + const assemblies = new Map(); + assemblies.set(root.artifactId, rootAssembly); + visitAssemblies(root, 'pre', construct => { + const stage = construct as Stage; + if (stage.parentStage && assemblies.has(stage.parentStage.artifactId)) { + assemblies.set( + stage.artifactId, + assemblies.get(stage.parentStage.artifactId)!.getNestedAssembly(stage.artifactId), + ); + } + }); + return assemblies; +} + +/** + * Invoke validation plugins for all stages in an App. + */ +function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: CloudAssembly) { + if (!App.isApp(root)) return; + const hash = computeChecksumOfFolder(outdir); + const assemblies = getAssemblies(root, assembly); + const templatePathsByPlugin: Map = new Map(); + visitAssemblies(root, 'post', construct => { + if (Stage.isStage(construct)) { + for (const plugin of construct.policyValidationBeta1) { + if (!templatePathsByPlugin.has(plugin)) { + templatePathsByPlugin.set(plugin, []); + } + let assemblyToUse = assemblies.get(construct.artifactId); + if (!assemblyToUse) throw new Error(`Validation failed, cannot find cloud assembly for stage ${construct.stageName}`); + templatePathsByPlugin.get(plugin)!.push(...assemblyToUse.stacksRecursively.map(stack => stack.templateFullPath)); + } + } + }); + + const reports: NamedValidationPluginReport[] = []; + if (templatePathsByPlugin.size > 0) { + // eslint-disable-next-line no-console + console.log('Performing Policy Validations\n'); + } + for (const [plugin, paths] of templatePathsByPlugin.entries()) { + try { + const report = plugin.validate({ templatePaths: paths }); + reports.push({ ...report, pluginName: plugin.name }); + } catch (e: any) { + reports.push({ + success: false, + pluginName: plugin.name, + pluginVersion: plugin.version, + violations: [], + metadata: { + error: `Validation plugin '${plugin.name}' failed: ${e.message}`, + }, + }); + } + if (computeChecksumOfFolder(outdir) !== hash) { + throw new Error(`Illegal operation: validation plugin '${plugin.name}' modified the cloud assembly`); + } + } + + if (reports.length > 0) { + const tree = new ConstructTree(root); + const formatter = new PolicyValidationReportFormatter(tree); + const formatJson = root.node.tryGetContext(VALIDATION_REPORT_JSON_CONTEXT) ?? false; + const output = formatJson + ? formatter.formatJson(reports) + : formatter.formatPrettyPrinted(reports); + + if (formatJson) { + fs.writeFileSync(path.join(assembly.directory, POLICY_VALIDATION_FILE_PATH), JSON.stringify(output, undefined, 2)); + } else { + // eslint-disable-next-line no-console + console.error(output); + } + const failed = reports.some(r => !r.success); + if (failed) { + throw new Error('Validation failed. See the validation report above for details'); + } else { + // eslint-disable-next-line no-console + console.log('Policy Validation Successful!'); + } + } +} + +function computeChecksumOfFolder(folder: string): string { + const hash = createHash('sha256'); + const files = fs.readdirSync(folder, { withFileTypes: true }); + + for (const file of files) { + const fullPath = path.join(folder, file.name); + if (file.isDirectory()) { + hash.update(computeChecksumOfFolder(fullPath)); + } else if (file.isFile()) { + hash.update(fs.readFileSync(fullPath)); + } + } + return hash.digest().toString('hex'); } const CUSTOM_SYNTHESIS_SYM = Symbol.for('@aws-cdk/core:customSynthesis'); @@ -232,6 +350,24 @@ function validateTree(root: IConstruct) { } } +/** + * Visit the given construct tree in either pre or post order, only looking at Assemblies + */ +function visitAssemblies(root: IConstruct, order: 'pre' | 'post', cb: (x: IConstruct) => void) { + if (order === 'pre') { + cb(root); + } + + for (const child of root.node.children) { + if (!Stage.isStage(child)) { continue; } + visitAssemblies(child, order, cb); + } + + if (order === 'post') { + cb(root); + } +} + /** * Visit the given construct tree in either pre or post order, stopping at Assemblies */ diff --git a/packages/@aws-cdk/core/lib/private/tree-metadata.ts b/packages/@aws-cdk/core/lib/private/tree-metadata.ts index bc54fba2f7eb6..defd019f93ff6 100644 --- a/packages/@aws-cdk/core/lib/private/tree-metadata.ts +++ b/packages/@aws-cdk/core/lib/private/tree-metadata.ts @@ -18,6 +18,7 @@ const FILE_PATH = 'tree.json'; * */ export class TreeMetadata extends Construct { + private _tree?: { [path: string]: Node }; constructor(scope: Construct) { super(scope, 'Tree'); } @@ -42,9 +43,15 @@ export class TreeMetadata extends Construct { .filter((child) => child !== undefined) .reduce((map, child) => Object.assign(map, { [child!.id]: child }), {}); + const parent = construct.node.scope; const node: Node = { id: construct.node.id || 'App', path: construct.node.path, + parent: parent && parent.node.path ? { + id: parent.node.id, + path: parent.node.path, + constructInfo: constructInfoFromConstruct(parent), + } : undefined, children: Object.keys(childrenMap).length === 0 ? undefined : childrenMap, attributes: this.synthAttributes(construct), constructInfo: constructInfoFromConstruct(construct), @@ -59,9 +66,16 @@ export class TreeMetadata extends Construct { version: 'tree-0.1', tree: visit(this.node.root), }; + this._tree = lookup; const builder = session.assembly; - fs.writeFileSync(path.join(builder.outdir, FILE_PATH), JSON.stringify(tree, undefined, 2), { encoding: 'utf-8' }); + fs.writeFileSync(path.join(builder.outdir, FILE_PATH), JSON.stringify(tree, (key: string, value: any) => { + // we are adding in the `parent` attribute for internal use + // and it doesn't make much sense to include it in the + // tree.json + if (key === 'parent') return undefined; + return value; + }, 2), { encoding: 'utf-8' }); builder.addArtifact('Tree', { type: ArtifactType.CDK_TREE, @@ -71,6 +85,38 @@ export class TreeMetadata extends Construct { }); } + /** + * This gets a specific "branch" of the tree for a given construct path. + * It will return the root Node of the tree with non-relevant branches filtered + * out (i.e. node children that don't traverse to the given construct path) + * + * @internal + */ + public _getNodeBranch(constructPath: string): Node | undefined { + if (!this._tree) { + throw new Error(`attempting to get node branch for ${constructPath}, but the tree has not been created yet!`); + } + const tree = this._tree[constructPath]; + const newTree: Node = { + id: tree.id, + path: tree.path, + attributes: tree.attributes, + constructInfo: tree.constructInfo, + // need to re-add the parent because the current node + // won't have the parent's parent + parent: tree.parent ? this._tree[tree.parent.path] : undefined, + }; + // need the properties to be mutable + let branch = newTree as any; + do { + branch.parent.children = { + [branch.id]: branch, + }; + branch = branch.parent; + } while (branch.parent); + return branch; + } + private synthAttributes(construct: IConstruct): { [key: string]: any } | undefined { // check if a construct implements IInspectable function canInspect(inspectable: any): inspectable is IInspectable { @@ -88,9 +134,10 @@ export class TreeMetadata extends Construct { } } -interface Node { +export interface Node { readonly id: string; readonly path: string; + readonly parent?: Node; readonly children?: { [key: string]: Node }; readonly attributes?: { [key: string]: any }; diff --git a/packages/@aws-cdk/core/lib/stage.ts b/packages/@aws-cdk/core/lib/stage.ts index 6762a2e2898f0..94a8c29f05009 100644 --- a/packages/@aws-cdk/core/lib/stage.ts +++ b/packages/@aws-cdk/core/lib/stage.ts @@ -3,6 +3,7 @@ import { IConstruct, Construct, Node } from 'constructs'; import { Environment } from './environment'; import { PermissionsBoundary } from './permissions-boundary'; import { synthesize } from './private/synthesis'; +import { IPolicyValidationPluginBeta1 } from './validation'; const STAGE_SYMBOL = Symbol.for('@aws-cdk/core.Stage'); @@ -69,6 +70,14 @@ export interface StageProps { * @default - no permissions boundary is applied */ readonly permissionsBoundary?: PermissionsBoundary; + + /** + * Validation plugins to run during synthesis. If any plugin reports any violation, + * synthesis will be interrupted and the report displayed to the user. + * + * @default - no validation plugins are used + */ + readonly policyValidationBeta1?: IPolicyValidationPluginBeta1[] } /** @@ -137,6 +146,15 @@ export class Stage extends Construct { */ private assembly?: cxapi.CloudAssembly; + /** + * Validation plugins to run during synthesis. If any plugin reports any violation, + * synthesis will be interrupted and the report displayed to the user. + * + * @default - no validation plugins are used + */ + public readonly policyValidationBeta1: IPolicyValidationPluginBeta1[] = []; + + constructor(scope: Construct, id: string, props: StageProps = {}) { super(scope, id); @@ -156,6 +174,10 @@ export class Stage extends Construct { this._assemblyBuilder = this.createBuilder(props.outdir); this.stageName = [this.parentStage?.stageName, props.stageName ?? id].filter(x => x).join('-'); + + if (props.policyValidationBeta1) { + this.policyValidationBeta1 = props.policyValidationBeta1; + } } /** diff --git a/packages/@aws-cdk/core/lib/validation/index.ts b/packages/@aws-cdk/core/lib/validation/index.ts new file mode 100644 index 0000000000000..01960ddd703be --- /dev/null +++ b/packages/@aws-cdk/core/lib/validation/index.ts @@ -0,0 +1,2 @@ +export * from './validation'; +export * from './report'; diff --git a/packages/@aws-cdk/core/lib/validation/private/construct-tree.ts b/packages/@aws-cdk/core/lib/validation/private/construct-tree.ts new file mode 100644 index 0000000000000..e4426ed464f0d --- /dev/null +++ b/packages/@aws-cdk/core/lib/validation/private/construct-tree.ts @@ -0,0 +1,241 @@ +import { Construct, IConstruct } from 'constructs'; +import { App } from '../../app'; +import { CfnResource } from '../../cfn-resource'; +import { TreeMetadata, Node } from '../../private/tree-metadata'; +import { Stack } from '../../stack'; + +/** + * A construct centric view of a stack trace + */ +export interface ConstructTrace { + /** + * The construct node id + */ + readonly id: string; + + /** + * The construct path + */ + readonly path: string; + /** + * The construct trace for the next construct + * in the trace tree + * + * @default - undefined if this is the last construct in the tree + */ + readonly child?: ConstructTrace; + + /** + * The name of the construct + * + * This will be equal to the fqn so will also include + * library information + * + * @default - undefined if this is a locally defined construct + */ + readonly construct?: string; + + /** + * The version of the library the construct comes from + * + * @default - undefined if this is a locally defined construct + */ + readonly libraryVersion?: string; + + /** + * If `CDK_DEBUG` is set to true, then this will show + * the line from the stack trace that contains the location + * in the source file where the construct is defined. + * + * If `CDK_DEBUG` is not set then this will instruct the user + * to run with `--debug` if they would like the location + * + * @default - undefined if the construct comes from a library + * and the location would point to node_modules + */ + readonly location?: string; +} + +/** + * Utility class to help accessing information on constructs in the + * construct tree. This can be created once and shared between + * all the validation plugin executions. + */ +export class ConstructTree { + /** + * A cache of the ConstructTrace by node.path. Each construct + */ + private readonly _traceCache = new Map(); + private readonly _constructByPath = new Map(); + private readonly _constructByTemplatePathAndLogicalId = new Map>(); + private readonly treeMetadata: TreeMetadata; + + constructor( + private readonly root: IConstruct, + ) { + if (App.isApp(this.root)) { + this.treeMetadata = this.root.node.tryFindChild('Tree') as TreeMetadata; + } else { + this.treeMetadata = App.of(this.root)?.node.tryFindChild('Tree') as TreeMetadata; + } + this._constructByPath.set(this.root.node.path, root); + // do this once at the start so we don't have to traverse + // the entire tree everytime we want to find a nested node + this.root.node.findAll().forEach(child => { + this._constructByPath.set(child.node.path, child); + const defaultChild = child.node.defaultChild; + if (defaultChild && CfnResource.isCfnResource(defaultChild)) { + const stack = Stack.of(defaultChild); + const logicalId = stack.resolve(defaultChild.logicalId); + this.setLogicalId(stack, logicalId, child); + } + }); + + // Another pass to include all the L1s that haven't been added yet + this.root.node.findAll().forEach(child => { + if (CfnResource.isCfnResource(child)) { + const stack = Stack.of(child); + const logicalId = Stack.of(child).resolve(child.logicalId); + this.setLogicalId(stack, logicalId, child); + } + }); + } + + private setLogicalId(stack: Stack, logicalId: string, child: Construct) { + if (!this._constructByTemplatePathAndLogicalId.has(stack.templateFile)) { + this._constructByTemplatePathAndLogicalId.set(stack.templateFile, new Map([[logicalId, child]])); + } else { + this._constructByTemplatePathAndLogicalId.get(stack.templateFile)?.set(logicalId, child); + } + } + + /** + * Get the stack trace from the construct node metadata. + * The stack trace only gets recorded if the node is a `CfnResource`, + * but the stack trace will have entries for all types of parent construct + * scopes + */ + private getTraceMetadata(size: number, node?: Node): string[] { + if (node) { + const construct = this.getConstructByPath(node.path); + if (construct) { + let trace; + if (CfnResource.isCfnResource(construct)) { + trace = construct.node.metadata.find(meta => !!meta.trace)?.trace ?? []; + } else { + trace = construct.node.defaultChild?.node.metadata.find(meta => !!meta.trace)?.trace ?? []; + } + // the top item is never pointing to anything relevant + trace.shift(); + // take just the items we need and reverse it since we are + // displaying to trace bottom up + return Object.create(trace.slice(0, size)); + } + } + return []; + } + + /** + * Get a ConstructTrace from the cache for a given construct + * + * Construct the stack trace of constructs. This will start with the + * root of the tree and go down to the construct that has the violation + */ + public getTrace(node: Node, locations?: string[]): ConstructTrace | undefined { + const trace = this._traceCache.get(node.path); + if (trace) { + return trace; + } + + const size = this.nodeSize(node); + + // the first time through the node will + // be the root of the tree. We need to go + // down the tree until we get to the bottom which + // will be the resource with the violation and it + // will contain the trace info + let child = node; + if (!locations) { + do { + if (child.children) { + child = this.getChild(child.children); + } + } while (child.children); + } + const metadata = (locations ?? this.getTraceMetadata(size, child)); + const thisLocation = metadata.pop(); + + let constructTrace: ConstructTrace = { + id: node.id, + path: node.path, + // the "child" trace will be the "parent" node + // since we are going bottom up + child: node.children + ? this.getTrace(this.getChild(node.children), metadata) + : undefined, + construct: node.constructInfo?.fqn, + libraryVersion: node.constructInfo?.version, + location: thisLocation ?? "Run with '--debug' to include location info", + }; + this._traceCache.set(constructTrace.path, constructTrace); + return constructTrace; + } + + /** + * Each node will only have a single child so just + * return that + */ + private getChild(children: { [key: string]: Node }): Node { + return Object.values(children)[0]; + } + + /** + * Get the size of a Node + */ + private nodeSize(node: Node): number { + let size = 1; + if (!node.children) { + return size; + } + let children: Node | undefined = this.getChild(node.children); + do { + size++; + children = children.children + ? this.getChild(children.children) + : undefined; + } while (children); + + return size; + } + + /** + * Get a specific node in the tree by construct path + * + * @param path the construct path of the node to return + * @returns the TreeMetadata Node + */ + public getTreeNode(path: string): Node | undefined { + return this.treeMetadata._getNodeBranch(path); + } + + /** + * Get a specific Construct by the node.addr + * + * @param path the node.addr of the construct + * @returns the Construct + */ + public getConstructByPath(path: string): Construct | undefined { + return this._constructByPath.get(path); + } + + /** + * Get a specific Construct by the CfnResource logical ID. This will + * be the construct.node.defaultChild with the given ID + * + * @param logicalId the ID of the CfnResource + * @returns the Construct + */ + public getConstructByLogicalId(templateFile: string, logicalId: string): Construct | undefined { + return this._constructByTemplatePathAndLogicalId.get(templateFile)?.get(logicalId); + } +} diff --git a/packages/@aws-cdk/core/lib/validation/private/report.ts b/packages/@aws-cdk/core/lib/validation/private/report.ts new file mode 100644 index 0000000000000..7a26dede3eb6b --- /dev/null +++ b/packages/@aws-cdk/core/lib/validation/private/report.ts @@ -0,0 +1,241 @@ +import * as os from 'os'; +import * as path from 'path'; +import { table } from 'table'; +import { ConstructTree, ConstructTrace } from './construct-tree'; +import { ReportTrace } from './trace'; +import * as report from '../report'; + +/** + * Validation produced by the validation plugin, in construct terms. + */ +export interface PolicyViolationConstructAware extends report.PolicyViolationBeta1 { + /** + * The constructs violating this rule. + */ + readonly violatingConstructs: ValidationViolatingConstruct[]; +} + +/** + * Construct violating a specific rule. + */ +export interface ValidationViolatingConstruct extends report.PolicyViolatingResourceBeta1 { + /** + * The construct path as defined in the application. + * + * @default - construct path will be empty if the cli is not run with `--debug` + */ + readonly constructPath?: string; + + /** + * A stack of constructs that lead to the violation. + * + * @default - stack will be empty if the cli is not run with `--debug` + */ + readonly constructStack?: ConstructTrace; +} + +/** + * JSON representation of the report. + */ +export interface PolicyValidationReportJson { + /** + * Report title. + */ + readonly title: string; + + /** + * Reports for all of the validation plugins registered + * in the app + */ + readonly pluginReports: PluginReportJson[]; +} + +/** + * A report from a single plugin + */ +export interface PluginReportJson { + /** + * List of violations in the report. + */ + readonly violations: PolicyViolationConstructAware[]; + + /** + * Report summary. + */ + readonly summary: PolicyValidationReportSummary; + + /** + * Plugin version. + */ + readonly version?: string; +} + +/** + * Summary of the report. + */ +export interface PolicyValidationReportSummary { + /** + * The final status of the validation (pass/fail) + */ + readonly status: report.PolicyValidationReportStatusBeta1; + + /** + * The name of the plugin that created the report + */ + readonly pluginName: string; + + /** + * Additional metadata about the report. This property is intended + * to be used by plugins to add additional information. + * + * @default - no metadata + */ + readonly metadata?: { readonly [key: string]: string }; +} + +/** + * The report containing the name of the plugin that created it. + */ +export interface NamedValidationPluginReport extends report.PolicyValidationPluginReportBeta1 { + /** + * The name of the plugin that created the report + */ + readonly pluginName: string; +} + + +/** + * The report emitted by the plugin after evaluation. + */ +export class PolicyValidationReportFormatter { + private readonly reportTrace: ReportTrace; + constructor(private readonly tree: ConstructTree) { + this.reportTrace = new ReportTrace(tree); + } + + + public formatPrettyPrinted(reps: NamedValidationPluginReport[]): string { + const json = this.formatJson(reps); + const output = [json.title]; + output.push('-'.repeat(json.title.length)); + + json.pluginReports.forEach(plugin => { + output.push(''); + output.push(table([ + [`Plugin: ${plugin.summary.pluginName}`], + [`Version: ${plugin.version ?? 'N/A'}`], + [`Status: ${plugin.summary.status}`], + ], { + header: { content: 'Plugin Report' }, + singleLine: true, + columns: [{ + paddingLeft: 3, + paddingRight: 3, + }], + })); + if (plugin.summary.metadata) { + output.push(''); + output.push(`Metadata: \n\t${Object.entries(plugin.summary.metadata).flatMap(([key, value]) => `${key}: ${value}`).join('\n\t')}`); + } + + if (plugin.violations.length > 0) { + output.push(''); + output.push('(Violations)'); + } + + plugin.violations.forEach((violation) => { + const constructs = violation.violatingConstructs; + const occurrences = constructs.length; + const title = reset(red(bright(`${violation.ruleName} (${occurrences} occurrences)`))); + output.push(''); + output.push(title); + if (violation.severity) { + output.push(`Severity: ${violation.severity}`); + } + output.push(''); + output.push(' Occurrences:'); + for (const construct of constructs) { + output.push(''); + output.push(` - Construct Path: ${construct.constructPath ?? 'N/A'}`); + output.push(` - Template Path: ${construct.templatePath}`); + output.push(` - Creation Stack:\n\t${this.reportTrace.formatPrettyPrinted(construct.constructPath)}`); + output.push(` - Resource ID: ${construct.resourceLogicalId}`); + if (construct.locations) { + output.push(' - Template Locations:'); + for (const location of construct.locations) { + output.push(` > ${location}`); + } + } + } + output.push(''); + output.push(` Description: ${violation.description }`); + if (violation.fix) { + output.push(` How to fix: ${violation.fix}`); + } + if (violation.ruleMetadata) { + output.push(` Rule Metadata: \n\t${Object.entries(violation.ruleMetadata).flatMap(([key, value]) => `${key}: ${value}`).join('\n\t')}`); + } + }); + }); + + output.push(''); + output.push('Policy Validation Report Summary'); + output.push(''); + output.push(table([ + ['Plugin', 'Status'], + ...reps.map(rep => [rep.pluginName, rep.success ? 'success' : 'failure']), + ], { })); + + return output.join(os.EOL); + } + + public formatJson(reps: NamedValidationPluginReport[]): PolicyValidationReportJson { + return { + title: 'Validation Report', + pluginReports: reps + .filter(rep => !rep.success) + .map(rep => ({ + version: rep.pluginVersion, + summary: { + pluginName: rep.pluginName, + status: rep.success ? report.PolicyValidationReportStatusBeta1.SUCCESS : report.PolicyValidationReportStatusBeta1.FAILURE, + metadata: rep.metadata, + }, + violations: rep.violations.map(violation => ({ + ruleName: violation.ruleName, + description: violation.description, + fix: violation.fix, + ruleMetadata: violation.ruleMetadata, + severity: violation.severity, + violatingResources: violation.violatingResources, + violatingConstructs: violation.violatingResources.map(resource => { + const constructPath = this.tree.getConstructByLogicalId( + path.basename(resource.templatePath), + resource.resourceLogicalId, + )?.node.path; + return { + constructStack: this.reportTrace.formatJson(constructPath), + constructPath: constructPath, + locations: resource.locations, + resourceLogicalId: resource.resourceLogicalId, + templatePath: resource.templatePath, + }; + }), + })), + })), + }; + } +} + + +function reset(s: string) { + return `${s}\x1b[0m`; +} + +function red(s: string) { + return `\x1b[31m${s}`; +} + +function bright(s: string) { + return `\x1b[1m${s}`; +} diff --git a/packages/@aws-cdk/core/lib/validation/private/trace.ts b/packages/@aws-cdk/core/lib/validation/private/trace.ts new file mode 100644 index 0000000000000..d7e6a57946114 --- /dev/null +++ b/packages/@aws-cdk/core/lib/validation/private/trace.ts @@ -0,0 +1,67 @@ +import { ConstructTree, ConstructTrace } from './construct-tree'; + +const STARTER_LINE = '└── '; +const VERTICAL_LINE = '│'; + +/** + * Utility class to generate the construct stack trace + * for a report + */ +export class ReportTrace { + constructor(private readonly tree: ConstructTree) {} + + /** + * Return a JSON representation of the construct trace + */ + public formatJson(constructPath?: string): ConstructTrace | undefined { + return this.trace(constructPath); + } + + /** + * This will render something like this: + * + * Creation Stack: + * └── MyStack (MyStack) + * │ Library: aws-cdk-lib.Stack + * │ Library Version: 2.50.0 + * │ Location: Object. (/home/hallcor/tmp/cdk-tmp-app/src/main.ts:25:20) + * └── MyCustomL3Construct (MyStack/MyCustomL3Construct) + * │ Library: N/A - (Local Construct) + * │ Library Version: N/A + * │ Location: new MyStack (/home/hallcor/tmp/cdk-tmp-app/src/main.ts:15:20) + * └── Bucket (MyStack/MyCustomL3Construct/Bucket) + * │ Library: aws-cdk-lib/aws-s3.Bucket + * │ Library Version: 2.50.0 + * │ Location: new MyCustomL3Construct (/home/hallcor/tmp/cdk-tmp-app/src/main.ts:9:20)/ + */ + public formatPrettyPrinted(constructPath?: string): string { + const trace = this.formatJson(constructPath); + return this.renderPrettyPrintedTraceInfo(trace); + } + + private renderPrettyPrintedTraceInfo(info?: ConstructTrace, indent?: string, start: string = STARTER_LINE): string { + const notAvailableMessage = '\tConstruct trace not available. Rerun with `--debug` to see trace information'; + if (info) { + const indentation = indent ?? ' '.repeat(STARTER_LINE.length+1); + const result: string[] = [ + `${start} ${info?.id} (${info?.path})`, + `${indentation}${VERTICAL_LINE} Construct: ${info?.construct}`, + `${indentation}${VERTICAL_LINE} Library Version: ${info?.libraryVersion}`, + `${indentation}${VERTICAL_LINE} Location: ${info?.location}`, + ...info?.child ? [this.renderPrettyPrintedTraceInfo(info?.child, ' '.repeat(indentation.length+STARTER_LINE.length+1), indentation+STARTER_LINE)] : [], + ]; + return result.join('\n\t'); + } + return notAvailableMessage; + } + + private trace(constructPath?: string): ConstructTrace | undefined { + if (constructPath) { + const treeNode = this.tree.getTreeNode(constructPath); + if (treeNode) { + return this.tree.getTrace(treeNode); + } + } + return; + } +} diff --git a/packages/@aws-cdk/core/lib/validation/report.ts b/packages/@aws-cdk/core/lib/validation/report.ts new file mode 100644 index 0000000000000..926d823b3540c --- /dev/null +++ b/packages/@aws-cdk/core/lib/validation/report.ts @@ -0,0 +1,107 @@ +/** + * Violation produced by the validation plugin. + */ +export interface PolicyViolationBeta1 { + /** + * The name of the rule. + */ + readonly ruleName: string; + + /** + * The description of the violation. + */ + readonly description: string; + + /** + * The resources violating this rule. + */ + readonly violatingResources: PolicyViolatingResourceBeta1[]; + + /** + * How to fix the violation. + * + * @default - no fix is provided + */ + readonly fix?: string; + + /** + * The severity of the violation, only used for reporting purposes. + * This is useful for helping the user discriminate between warnings, + * errors, information, etc. + * + * @default - no severity + */ + readonly severity?: string; + + /** + * Additional metadata to include with the rule results. + * This can be used to provide additional information that is + * plugin specific. The data provided here will be rendered as is. + * + * @default - no rule metadata + */ + readonly ruleMetadata?: { readonly [key: string]: string } +} + +/** + * Resource violating a specific rule. + */ +export interface PolicyViolatingResourceBeta1 { + /** + * The logical ID of the resource in the CloudFormation template. + */ + readonly resourceLogicalId: string; + + /** + * The locations in the CloudFormation template that pose the violations. + */ + readonly locations: string[]; + + /** + * The path to the CloudFormation template that contains this resource + */ + readonly templatePath: string; +} + +/** + * The final status of the validation report + */ +export enum PolicyValidationReportStatusBeta1 { + /** + * No violations were found + */ + SUCCESS = 'success', + + /** + * At least one violation was found + */ + FAILURE = 'failure', +} + +/** + * The report emitted by the plugin after evaluation. + */ +export interface PolicyValidationPluginReportBeta1 { + /** + * List of violations in the report. + */ + readonly violations: PolicyViolationBeta1[]; + + /** + * Whether or not the report was successful. + */ + readonly success: boolean; + + /** + * The version of the plugin that created the report. + * @default - no version + */ + readonly pluginVersion?: string; + + /** + * Arbitrary information about the report. + * + * @default - no metadata + */ + readonly metadata?: { readonly [key: string]: string } +} diff --git a/packages/@aws-cdk/core/lib/validation/validation.ts b/packages/@aws-cdk/core/lib/validation/validation.ts new file mode 100644 index 0000000000000..df5f6a95d5a07 --- /dev/null +++ b/packages/@aws-cdk/core/lib/validation/validation.ts @@ -0,0 +1,66 @@ +import { PolicyValidationPluginReportBeta1 } from './report'; + +/** + * Represents a validation plugin that will be executed during synthesis + * + * @example + * class MyCustomValidatorPlugin implements IValidationPlugin { + * public readonly name = 'my-custom-plugin'; + * + * public isReady(): boolean { + * // check if the plugin tool is installed + * return true; + * } + * + * public validate(context: IValidationContext): ValidationPluginReport { + * const templatePaths = context.templatePaths; + * // perform validation on the template + * // if there are any failures report them + * return { + * pluginName: this.name, + * success: false, + * violations: [{ + * ruleName: 'rule-name', + * description: 'description of the rule', + * violatingResources: [{ + * resourceName: 'FailingResource', + * templatePath: '/path/to/stack.template.json', + * }], + * }); + * } + * } + */ +export interface IPolicyValidationPluginBeta1 { + /** + * The name of the plugin that will be displayed in the validation + * report + */ + readonly name: string; + + /** + * The version of the plugin, following the Semantic Versioning specification (see + * https://semver.org/). This version is used for analytics purposes, to + * measure the usage of different plugins and different versions. The value of + * this property should be kept in sync with the actual version of the + * software package. If the version is not provided or is not a valid semantic + * version, it will be reported as `0.0.0`. + */ + readonly version?: string; + + /** + * The method that will be called by the CDK framework to perform + * validations. This is where the plugin will evaluate the CloudFormation + * templates for compliance and report and violations + */ + validate(context: IPolicyValidationContextBeta1): PolicyValidationPluginReportBeta1; +} + +/** + * Context available to the validation plugin + */ +export interface IPolicyValidationContextBeta1 { + /** + * The absolute path of all templates to be processed + */ + readonly templatePaths: string[]; +} diff --git a/packages/@aws-cdk/core/package.json b/packages/@aws-cdk/core/package.json index 704eed7baaec2..8504d383b11a1 100644 --- a/packages/@aws-cdk/core/package.json +++ b/packages/@aws-cdk/core/package.json @@ -214,13 +214,15 @@ "constructs": "^10.0.0", "fs-extra": "^9.1.0", "ignore": "^5.2.4", - "minimatch": "^3.1.2" + "minimatch": "^3.1.2", + "table": "^6.8.1" }, "bundledDependencies": [ "fs-extra", "minimatch", "@balena/dockerignore", - "ignore" + "ignore", + "table" ], "homepage": "https://github.com/aws/aws-cdk", "peerDependencies": { diff --git a/packages/@aws-cdk/core/rosetta/README-custom-resource-provider.ts-fixture b/packages/@aws-cdk/core/rosetta/README-custom-resource-provider.ts-fixture index ae4b1befd4b20..832388f862131 100644 --- a/packages/@aws-cdk/core/rosetta/README-custom-resource-provider.ts-fixture +++ b/packages/@aws-cdk/core/rosetta/README-custom-resource-provider.ts-fixture @@ -8,6 +8,8 @@ declare class Sum extends Construct { public readonly result: number; constructor(scope: Construct, id: string, props: SumProps); } +declare class ThirdPartyPluginX extends IValidationPlugin {} +declare class ThirdPartyPluginY extends IValidationPlugin {} class fixture$construct extends Construct { public constructor(scope: Construct, id: string) { diff --git a/packages/@aws-cdk/core/test/metadata-resource.test.ts b/packages/@aws-cdk/core/test/metadata-resource.test.ts index 1f5f676b5cdbf..8fb4f1c6507d8 100644 --- a/packages/@aws-cdk/core/test/metadata-resource.test.ts +++ b/packages/@aws-cdk/core/test/metadata-resource.test.ts @@ -1,6 +1,6 @@ import * as zlib from 'zlib'; import { Construct } from 'constructs'; -import { App, Stack } from '../lib'; +import { App, Stack, IPolicyValidationPluginBeta1, IPolicyValidationContextBeta1, Stage, PolicyValidationPluginReportBeta1 } from '../lib'; import { formatAnalytics } from '../lib/private/metadata-resource'; import { ConstructInfo } from '../lib/private/runtime-info'; @@ -9,11 +9,16 @@ describe('MetadataResource', () => { let stack: Stack; beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { return true; }); + jest.spyOn(console, 'error').mockImplementation(() => { return true; }); app = new App({ analyticsReporting: true, }); stack = new Stack(app, 'Stack'); }); + afterEach(() => { + jest.resetAllMocks(); + }); test('is not included if the region is known and metadata is not available', () => { new Stack(app, 'StackUnavailable', { @@ -70,8 +75,42 @@ describe('MetadataResource', () => { expect(stackAnalytics()).not.toContain('TestConstruct'); }); - function stackAnalytics(stackName: string = 'Stack') { - const encodedAnalytics = app.synth().getStackByName(stackName).template.Resources?.CDKMetadata?.Properties?.Analytics as string; + test('validation plugins included', () => { + const newApp = new App({ + analyticsReporting: true, + policyValidationBeta1: [ + new ValidationPlugin('plugin1'), + ], + }); + + const stage1 = new Stage(newApp, 'Stage1', { + policyValidationBeta1: [ + new ValidationPlugin('plugin11'), + ], + }); + + const stack1 = new Stack(stage1, 'Stack1', { stackName: 'stack1' }); + + const stage2 = new Stage(newApp, 'Stage2', { + policyValidationBeta1: [ + new ValidationPlugin('plugin12'), + ], + }); + const stack2 = new Stack(stage2, 'Stack2', { stackName: 'stack1' }); + + expect(stackAnalytics(stage1, stack1.stackName)).toMatch(/policyValidation.{plugin11,plugin1}/); + expect(stackAnalytics(stage2, stack2.stackName)).toMatch(/policyValidation.{plugin12,plugin1}/); + }); + + function stackAnalytics(stage: Stage = app, stackName: string = 'Stack') { + let stackArtifact; + if (App.isApp(stage)) { + stackArtifact = stage.synth().getStackByName(stackName); + } else { + const a = App.of(stage)!; + stackArtifact = a.synth().getNestedAssembly(stage.artifactId).getStackByName(stackName); + } + let encodedAnalytics = stackArtifact.template.Resources?.CDKMetadata?.Properties?.Analytics as string;; return plaintextConstructsFromAnalytics(encodedAnalytics); } }); @@ -153,3 +192,14 @@ class TestThirdPartyConstruct extends Construct { // @ts-ignore private static readonly [JSII_RUNTIME_SYMBOL] = { fqn: 'mycoolthing.TestConstruct', version: '1.2.3' } } + +class ValidationPlugin implements IPolicyValidationPluginBeta1 { + constructor(public readonly name: string) {} + + validate(_context: IPolicyValidationContextBeta1): PolicyValidationPluginReportBeta1 { + return { + success: true, + violations: [], + }; + } +} diff --git a/packages/@aws-cdk/core/test/validation/trace.test.ts b/packages/@aws-cdk/core/test/validation/trace.test.ts new file mode 100644 index 0000000000000..f676ee1eda117 --- /dev/null +++ b/packages/@aws-cdk/core/test/validation/trace.test.ts @@ -0,0 +1,100 @@ +import { Construct } from 'constructs'; +import * as core from '../../lib'; +import { ConstructTree } from '../../lib/validation/private/construct-tree'; +import { ReportTrace } from '../../lib/validation/private/trace'; + +beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => { return true; }); +}); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('ReportTrace', () => { + test('trace includes location when CDK_DEBUG=true', () => { + // GIVEN + try { + process.env.CDK_DEBUG = 'true'; + const app = new core.App({ + treeMetadata: true, + }); + const stack = new MyStack(app, 'MyStack'); + app.synth(); + const tree = new ConstructTree(app); + + // WHEN + const trace = new ReportTrace(tree); + const formatted = trace.formatJson(stack.constructPath); + + // THEN + expect(formatted).toEqual({ + id: 'MyStack', + construct: expect.stringMatching(/.*Stack/), + libraryVersion: '0.0.0', + location: expect.stringMatching(/Object. \(.*\/trace.test.ts:[0-9]+:[0-9]+\)/), + path: 'MyStack', + child: { + id: 'MyConstruct', + construct: 'constructs.Construct', + libraryVersion: expect.any(String), + location: expect.stringMatching(/new MyStack \(.*\/trace.test.ts:[0-9]+:[0-9]+\)/), + path: 'MyStack/MyConstruct', + }, + }); + } finally { + process.env.CDK_DEBUG = ''; + } + }); + + test('trace does not include location when CDK_DEBUG=false', () => { + // GIVEN + const app = new core.App({ + treeMetadata: true, + }); + const stack = new MyStack(app, 'MyStack'); + app.synth(); + const tree = new ConstructTree(app); + + // WHEN + const trace = new ReportTrace(tree); + const formatted = trace.formatJson(stack.constructPath); + + // THEN + expect(formatted).toEqual({ + id: 'MyStack', + construct: expect.stringMatching(/.*Stack/), + libraryVersion: '0.0.0', + location: "Run with '--debug' to include location info", + path: 'MyStack', + child: { + id: 'MyConstruct', + construct: 'constructs.Construct', + libraryVersion: expect.any(String), + location: "Run with '--debug' to include location info", + path: 'MyStack/MyConstruct', + }, + }); + }); +}); + +class MyConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + new core.CfnResource(this, 'Resource', { + type: 'AWS::CDK::TestResource', + properties: { + testProp1: 'testValue', + }, + }); + } +} + +class MyStack extends core.Stack { + public readonly constructPath: string; + constructor(scope: Construct, id: string, props?: core.StackProps) { + super(scope, id, props); + const myConstruct = new MyConstruct(this, 'MyConstruct'); + this.constructPath = myConstruct.node.path; + } +} diff --git a/packages/@aws-cdk/core/test/validation/validation.test.ts b/packages/@aws-cdk/core/test/validation/validation.test.ts new file mode 100644 index 0000000000000..8a8ed04ec9822 --- /dev/null +++ b/packages/@aws-cdk/core/test/validation/validation.test.ts @@ -0,0 +1,936 @@ +/* eslint-disable quote-props */ +import * as fs from 'fs'; +import * as path from 'path'; +import { Construct } from 'constructs'; +import { table } from 'table'; +import * as core from '../../lib'; +import { PolicyValidationPluginReportBeta1, PolicyViolationBeta1 } from '../../lib'; + + +let consoleErrorMock: jest.SpyInstance; +let consoleLogMock: jest.SpyInstance; +beforeEach(() => { + consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => { return true; }); + consoleLogMock = jest.spyOn(console, 'log').mockImplementation(() => { return true; }); +}); + +afterEach(() => { + consoleErrorMock.mockRestore(); + consoleLogMock.mockRestore(); +}); + +describe('validations', () => { + test('validation failure', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('test-plugin', [{ + description: 'test recommendation', + ruleName: 'test-rule', + severity: 'medium', + ruleMetadata: { + id: 'abcdefg', + }, + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }], + }]), + ], + }); + const stack = new core.Stack(app); + new core.CfnResource(stack, 'Fake', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + expect(consoleErrorMock.mock.calls[0][0]).toEqual(validationReport({ + templatePath: '/path/to/Default.template.json', + constructPath: 'Default/Fake', + title: 'test-rule', + ruleMetadata: { + id: 'abcdefg', + }, + severity: 'medium', + creationStack: `\t└── Default (Default) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── Fake (Default/Fake) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info`, + resourceLogicalId: 'Fake', + })); + }); + + test('validation success', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('test-plugin', []), + new FakePlugin('test-plugin2', []), + new FakePlugin('test-plugin3', []), + ], + }); + const stack = new core.Stack(app); + new core.CfnResource(stack, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'success', + }, + }); + expect(() => { + app.synth(); + }).not.toThrow(/Validation failed/); + expect(consoleLogMock.mock.calls).toEqual([ + [ + expect.stringMatching(/Performing Policy Validations/), + ], + [ + expect.stringMatching(/Policy Validation Successful!/), + ], + ]); + expect(consoleErrorMock.mock.calls[0][0]).toEqual(`Validation Report +----------------- + +Policy Validation Report Summary + +╔══════════════╤═════════╗ +║ Plugin │ Status ║ +╟──────────────┼─────────╢ +║ test-plugin │ success ║ +╟──────────────┼─────────╢ +║ test-plugin2 │ success ║ +╟──────────────┼─────────╢ +║ test-plugin3 │ success ║ +╚══════════════╧═════════╝ +`); + }); + + test('multiple stacks', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('test-plugin', [{ + description: 'test recommendation', + ruleName: 'test-rule', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'DefaultResource', + templatePath: '/path/to/stack1.template.json', + }], + }]), + ], + }); + const stack1 = new core.Stack(app, 'stack1'); + new core.CfnResource(stack1, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack2 = new core.Stack(app, 'stack2'); + new core.CfnResource(stack2, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = consoleErrorMock.mock.calls[0][0]; + // Assuming the rest of the report's content is checked by another test + expect(report).toContain('- Template Path: /path/to/stack1.template.json'); + expect(report).not.toContain('- Template Path: /path/to/stack2.template.json'); + }); + + test('multiple stages', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('test-plugin1', [{ + description: 'do something', + ruleName: 'test-rule1', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'DefaultResource', + templatePath: '/path/to/Stage1stack1DDED8B6C.template.json', + }], + }], '1.2.3'), + ], + }); + const stage1 = new core.Stage(app, 'Stage1', { + policyValidationBeta1: [ + new FakePlugin('test-plugin2', [{ + description: 'do something', + ruleName: 'test-rule2', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'DefaultResource', + templatePath: '/path/to/Stage1stack1DDED8B6C.template.json', + }], + }]), + ], + }); + const stage2 = new core.Stage(app, 'Stage2', { + policyValidationBeta1: [ + new FakePlugin('test-plugin3', [{ + description: 'do something', + ruleName: 'test-rule3', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'DefaultResource', + templatePath: '/path/to/Stage2stack259BA718E.template.json', + }], + }]), + ], + }); + const stage3 = new core.Stage(stage2, 'Stage3', { + policyValidationBeta1: [ + new FakePlugin('test-plugin4', [{ + description: 'do something', + ruleName: 'test-rule4', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'DefaultResource', + templatePath: '/path/to/Stage2Stage3stack10CD36915.template.json', + }], + }]), + ], + }); + const stack3 = new core.Stack(stage3, 'stack1'); + new core.CfnResource(stack3, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack1 = new core.Stack(stage1, 'stack1'); + new core.CfnResource(stack1, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack2 = new core.Stack(stage2, 'stack2'); + new core.CfnResource(stack2, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = consoleErrorMock.mock.calls[0][0]; + // Assuming the rest of the report's content is checked by another test + expect(report).toEqual(`Validation Report +----------------- + +${generateTable('test-plugin2', 'failure', 'N/A')} + +(Violations) + +${reset(red(bright('test-rule2 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Stage1/stack1/DefaultResource + - Template Path: /path/to/Stage1stack1DDED8B6C.template.json + - Creation Stack: +\t└── Stage1 (Stage1) +\t │ Construct: @aws-cdk/core.Stage +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── stack1 (Stage1/stack1) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── DefaultResource (Stage1/stack1/DefaultResource) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: DefaultResource + - Template Locations: + > test-location + + Description: do something + +${generateTable('test-plugin4', 'failure', 'N/A')} + +(Violations) + +${reset(red(bright('test-rule4 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Stage2/Stage3/stack1/DefaultResource + - Template Path: /path/to/Stage2Stage3stack10CD36915.template.json + - Creation Stack: +\t└── Stage3 (Stage2/Stage3) +\t │ Construct: @aws-cdk/core.Stage +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── stack1 (Stage2/Stage3/stack1) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── DefaultResource (Stage2/Stage3/stack1/DefaultResource) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: DefaultResource + - Template Locations: + > test-location + + Description: do something + +${generateTable('test-plugin3', 'failure', 'N/A')} + +(Violations) + +${reset(red(bright('test-rule3 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Stage2/stack2/DefaultResource + - Template Path: /path/to/Stage2stack259BA718E.template.json + - Creation Stack: +\t└── Stage2 (Stage2) +\t │ Construct: @aws-cdk/core.Stage +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── stack2 (Stage2/stack2) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── DefaultResource (Stage2/stack2/DefaultResource) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: DefaultResource + - Template Locations: + > test-location + + Description: do something + +${generateTable('test-plugin1', 'failure', '1.2.3')} + +(Violations) + +${reset(red(bright('test-rule1 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Stage1/stack1/DefaultResource + - Template Path: /path/to/Stage1stack1DDED8B6C.template.json + - Creation Stack: +\t└── Stage1 (Stage1) +\t │ Construct: @aws-cdk/core.Stage +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── stack1 (Stage1/stack1) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── DefaultResource (Stage1/stack1/DefaultResource) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: DefaultResource + - Template Locations: + > test-location + + Description: do something + +Policy Validation Report Summary + +╔══════════════╤═════════╗ +║ Plugin │ Status ║ +╟──────────────┼─────────╢ +║ test-plugin2 │ failure ║ +╟──────────────┼─────────╢ +║ test-plugin4 │ failure ║ +╟──────────────┼─────────╢ +║ test-plugin3 │ failure ║ +╟──────────────┼─────────╢ +║ test-plugin1 │ failure ║ +╚══════════════╧═════════╝ +`); + }); + + test('multiple stages, multiple plugins', () => { + const mockValidate = jest.fn().mockImplementation(() => { + return { + success: true, + violations: [], + }; + }); + const app = new core.App({ + policyValidationBeta1: [ + { + name: 'test-plugin', + validate: mockValidate, + }, + ], + }); + const stage1 = new core.Stage(app, 'Stage1', { }); + const stage2 = new core.Stage(app, 'Stage2', { + policyValidationBeta1: [ + { + name: 'test-plugin2', + validate: mockValidate, + }, + ], + }); + const stage3 = new core.Stage(stage2, 'Stage3', { }); + const stack3 = new core.Stack(stage3, 'stack1'); + new core.CfnResource(stack3, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack1 = new core.Stack(stage1, 'stack1'); + new core.CfnResource(stack1, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack2 = new core.Stack(stage2, 'stack2'); + new core.CfnResource(stack2, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + app.synth(); + + expect(mockValidate).toHaveBeenCalledTimes(2); + expect(mockValidate).toHaveBeenNthCalledWith(2, { + templatePaths: [ + expect.stringMatching(/assembly-Stage1\/Stage1stack1DDED8B6C.template.json/), + expect.stringMatching(/assembly-Stage2\/Stage2stack259BA718E.template.json/), + expect.stringMatching(/assembly-Stage2\/assembly-Stage2-Stage3\/Stage2Stage3stack10CD36915.template.json/), + ], + }); + expect(mockValidate).toHaveBeenNthCalledWith(1, { + templatePaths: [ + expect.stringMatching(/assembly-Stage2\/Stage2stack259BA718E.template.json/), + expect.stringMatching(/assembly-Stage2\/assembly-Stage2-Stage3\/Stage2Stage3stack10CD36915.template.json/), + ], + }); + }); + + test('multiple stages, single plugin', () => { + const mockValidate = jest.fn().mockImplementation(() => { + return { + success: true, + violations: [], + }; + }); + const app = new core.App({ + policyValidationBeta1: [ + { + name: 'test-plugin', + validate: mockValidate, + }, + ], + }); + const stage1 = new core.Stage(app, 'Stage1', { }); + const stage2 = new core.Stage(app, 'Stage2', { }); + const stage3 = new core.Stage(stage2, 'Stage3', { }); + const stack3 = new core.Stack(stage3, 'stack1'); + new core.CfnResource(stack3, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack1 = new core.Stack(stage1, 'stack1'); + new core.CfnResource(stack1, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + const stack2 = new core.Stack(stage2, 'stack2'); + new core.CfnResource(stack2, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + app.synth(); + + expect(mockValidate).toHaveBeenCalledTimes(1); + expect(mockValidate).toHaveBeenCalledWith({ + templatePaths: [ + expect.stringMatching(/assembly-Stage1\/Stage1stack1DDED8B6C.template.json/), + expect.stringMatching(/assembly-Stage2\/Stage2stack259BA718E.template.json/), + expect.stringMatching(/assembly-Stage2\/assembly-Stage2-Stage3\/Stage2Stage3stack10CD36915.template.json/), + ], + }); + }); + + test('multiple constructs', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('test-plugin', [{ + description: 'test recommendation', + ruleName: 'test-rule', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'SomeResource317FDD71', + templatePath: '/path/to/Default.template.json', + }], + }]), + ], + }); + const stack = new core.Stack(app); + new LevelTwoConstruct(stack, 'SomeResource'); + new LevelTwoConstruct(stack, 'AnotherResource'); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = consoleErrorMock.mock.calls[0][0]; + // Assuming the rest of the report's content is checked by another test + expect(report).toContain('- Construct Path: Default/SomeResource'); + expect(report).not.toContain('- Construct Path: Default/AnotherResource'); + }); + + test('multiple plugins', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('plugin1', [{ + description: 'do something', + ruleName: 'rule-1', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }], + }]), + new FakePlugin('plugin2', [{ + description: 'do another thing', + ruleName: 'rule-2', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }], + }]), + ], + }); + const stack = new core.Stack(app); + new core.CfnResource(stack, 'Fake', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = consoleErrorMock.mock.calls[0][0]; + expect(report).toEqual(`Validation Report +----------------- + +${generateTable('plugin1', 'failure', 'N/A')} + +(Violations) + +${reset(red(bright('rule-1 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Default/Fake + - Template Path: /path/to/Default.template.json + - Creation Stack: +\t└── Default (Default) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── Fake (Default/Fake) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: Fake + - Template Locations: + > test-location + + Description: do something + +${generateTable('plugin2', 'failure', 'N/A')} + +(Violations) + +${reset(red(bright('rule-2 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Default/Fake + - Template Path: /path/to/Default.template.json + - Creation Stack: +\t└── Default (Default) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── Fake (Default/Fake) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: Fake + - Template Locations: + > test-location + + Description: do another thing + +Policy Validation Report Summary + +${table([ + ['Plugin', 'Status'], + ['plugin1', 'failure'], + ['plugin2', 'failure'], + ], { })}`); + }); + + test('multiple plugins with mixed results', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('plugin1', []), + new FakePlugin('plugin2', [{ + description: 'do another thing', + ruleName: 'rule-2', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }], + }]), + ], + }); + const stack = new core.Stack(app); + new core.CfnResource(stack, 'Fake', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = consoleErrorMock.mock.calls[0][0]; + expect(report).toEqual(`Validation Report +----------------- + +${generateTable('plugin2', 'failure', 'N/A')} + +(Violations) + +${reset(red(bright('rule-2 (1 occurrences)')))} + + Occurrences: + + - Construct Path: Default/Fake + - Template Path: /path/to/Default.template.json + - Creation Stack: +\t└── Default (Default) +\t │ Construct: @aws-cdk/core.Stack +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info +\t └── Fake (Default/Fake) +\t │ Construct: @aws-cdk/core.CfnResource +\t │ Library Version: 0.0.0 +\t │ Location: Run with '--debug' to include location info + - Resource ID: Fake + - Template Locations: + > test-location + + Description: do another thing + +Policy Validation Report Summary + +${table([ + ['Plugin', 'Status'], + ['plugin1', 'success'], + ['plugin2', 'failure'], + ])}`); + }); + + test('plugin throws an error', () => { + const app = new core.App({ + policyValidationBeta1: [ + // This plugin will throw an error + new BrokenPlugin(), + + // But this one should still run + new FakePlugin('test-plugin', [{ + description: 'test recommendation', + ruleName: 'test-rule', + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }], + }]), + ], + }); + + const stack = new core.Stack(app); + new core.CfnResource(stack, 'Fake', { + type: 'Test::Resource::Fake', + properties: { + result: 'success', + }, + }); + + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = consoleErrorMock.mock.calls[0][0]; + expect(report).toContain('error: Validation plugin \'broken-plugin\' failed: Something went wrong'); + expect(report).toContain(generateTable('test-plugin', 'failure', 'N/A')); + }); + + test('plugin tries to modify a template', () => { + const app = new core.App({ + policyValidationBeta1: [ + new RoguePlugin(), + ], + }); + const stack = new core.Stack(app); + new core.CfnResource(stack, 'DefaultResource', { + type: 'Test::Resource::Fake', + properties: { + result: 'success', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Illegal operation: validation plugin 'rogue-plugin' modified the cloud assembly/); + }); + + test('JSON format', () => { + const app = new core.App({ + policyValidationBeta1: [ + new FakePlugin('test-plugin', [{ + description: 'test recommendation', + ruleName: 'test-rule', + ruleMetadata: { + id: 'abcdefg', + }, + violatingResources: [{ + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }], + }]), + ], + context: { '@aws-cdk/core:validationReportJson': true }, + }); + const stack = new core.Stack(app); + new core.CfnResource(stack, 'Fake', { + type: 'Test::Resource::Fake', + properties: { + result: 'failure', + }, + }); + expect(() => { + app.synth(); + }).toThrow(/Validation failed/); + + const report = fs.readFileSync(path.join(app.outdir, 'policy-validation-report.json')).toString('utf-8'); + expect(JSON.parse(report)).toEqual(expect.objectContaining({ + title: 'Validation Report', + pluginReports: [ + { + summary: { + pluginName: 'test-plugin', + status: 'failure', + }, + violations: [ + { + ruleName: 'test-rule', + description: 'test recommendation', + ruleMetadata: { id: 'abcdefg' }, + violatingResources: [{ + 'locations': [ + 'test-location', + ], + 'resourceLogicalId': 'Fake', + 'templatePath': '/path/to/Default.template.json', + }], + violatingConstructs: [ + { + constructStack: { + 'id': 'Default', + 'construct': '@aws-cdk/core.Stack', + 'libraryVersion': '0.0.0', + 'location': "Run with '--debug' to include location info", + 'path': 'Default', + 'child': { + 'id': 'Fake', + 'construct': '@aws-cdk/core.CfnResource', + 'libraryVersion': '0.0.0', + 'location': "Run with '--debug' to include location info", + 'path': 'Default/Fake', + }, + }, + constructPath: 'Default/Fake', + locations: ['test-location'], + resourceLogicalId: 'Fake', + templatePath: '/path/to/Default.template.json', + }, + ], + }, + ], + }, + ], + })); + }); +}); + +class FakePlugin implements core.IPolicyValidationPluginBeta1 { + private _version?: string; + + constructor( + public readonly name: string, + private readonly violations: PolicyViolationBeta1[], + readonly version?: string) { + this._version = version; + } + + validate(_context: core.IPolicyValidationContextBeta1): PolicyValidationPluginReportBeta1 { + return { + success: this.violations.length === 0, + violations: this.violations, + pluginVersion: this._version, + }; + } +} + +class RoguePlugin implements core.IPolicyValidationPluginBeta1 { + public readonly name = 'rogue-plugin'; + + validate(context: core.IPolicyValidationContextBeta1): PolicyValidationPluginReportBeta1 { + const templatePath = context.templatePaths[0]; + fs.writeFileSync(templatePath, 'malicious data'); + return { + success: true, + violations: [], + }; + } +} + +class BrokenPlugin implements core.IPolicyValidationPluginBeta1 { + public readonly name = 'broken-plugin'; + + validate(_context: core.IPolicyValidationContextBeta1): PolicyValidationPluginReportBeta1 { + throw new Error('Something went wrong'); + } +} + +interface ValidationReportData { + templatePath: string, + title: string, + constructPath: string, + creationStack?: string, + resourceLogicalId: string, + severity?: string, + ruleMetadata?: { [key: string]: string }; +} + +function generateTable(pluginName: string, status: string, pluginVersion: string): string { + return table([ + [`Plugin: ${pluginName}`], + [`Version: ${pluginVersion}`], + [`Status: ${status}`], + ], { + header: { content: 'Plugin Report' }, + singleLine: true, + columns: [{ + paddingLeft: 3, + paddingRight: 3, + }], + }); +} + +const validationReport = (data: ValidationReportData) => { + const title = reset(red(bright(`${data.title} (1 occurrences)`))); + return [ + 'Validation Report', + '-----------------', + '', + '╔═════════════════════════╗', + '║ Plugin Report ║', + '║ Plugin: test-plugin ║', + '║ Version: N/A ║', + '║ Status: failure ║', + '╚═════════════════════════╝', + '', + '', + '(Violations)', + '', + title, + ...data.severity ? [`Severity: ${data.severity}`] : [], + '', + ' Occurrences:', + '', + ` - Construct Path: ${data.constructPath}`, + ` - Template Path: ${data.templatePath}`, + ' - Creation Stack:', + `${data.creationStack ?? 'Construct trace not available. Rerun with `--debug` to see trace information'}`, + ` - Resource ID: ${data.resourceLogicalId}`, + ' - Template Locations:', + ' > test-location', + '', + ' Description: test recommendation', + ...data.ruleMetadata ? [` Rule Metadata: \n\t${Object.entries(data.ruleMetadata).flatMap(([key, value]) => `${key}: ${value}`).join('\n\t')}`] : [], + '', + 'Policy Validation Report Summary', + '', + '╔═════════════╤═════════╗', + '║ Plugin │ Status ║', + '╟─────────────┼─────────╢', + '║ test-plugin │ failure ║', + '╚═════════════╧═════════╝', + '', + + ].join('\n'); +}; + +function reset(s: string) { + return `${s}\x1b[0m`; +} + +function red(s: string) { + return `\x1b[31m${s}`; +} + +function bright(s: string) { + return `\x1b[1m${s}`; +} + +class LevelTwoConstruct extends Construct { + constructor(scope: Construct, id: string) { + super(scope, id); + new core.CfnResource(this, 'Resource', { + type: 'Test::Resource::Fake', + properties: { + result: 'success', + }, + }); + } +} diff --git a/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md b/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md index 1f18d1bfb848a..13f8da316399f 100644 --- a/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md +++ b/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md @@ -901,6 +901,7 @@ of the `id`s, the `name`s of the columns can be changed without data loss. | (not in v1) | | | | 2.68.0 | `false` | `true` | + ### @aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2 *Enable AmazonEMRServicePolicy_v2 managed policies* (fix) @@ -912,7 +913,7 @@ If this flag is set, the default behavior is to use the new `AmazonEMRServicePol managed policies. This is a feature flag as the old behavior will be deprecated, but some resources may require manual -intervention since they don't automatically have the appropriate tags propagated. +intervention since they might not have the appropriate tags propagated automatically. | Since | Default | Recommended | diff --git a/packages/@aws-cdk/cx-api/lib/features.ts b/packages/@aws-cdk/cx-api/lib/features.ts index 5edd140b6bff2..d1cd0388ca2c8 100644 --- a/packages/@aws-cdk/cx-api/lib/features.ts +++ b/packages/@aws-cdk/cx-api/lib/features.ts @@ -733,7 +733,6 @@ export const FLAGS: Record = { recommendedValue: true, }, - ////////////////////////////////////////////////////////////////////// [ENABLE_EMR_SERVICE_POLICY_V2]: { type: FlagType.BugFix, summary: 'Enable AmazonEMRServicePolicy_v2 managed policies', diff --git a/packages/aws-cdk-lib/.gitignore b/packages/aws-cdk-lib/.gitignore index a667f73ca1665..6b75540036a24 100644 --- a/packages/aws-cdk-lib/.gitignore +++ b/packages/aws-cdk-lib/.gitignore @@ -15,4 +15,4 @@ scripts/*.d.ts junit.xml !.eslintrc.js dist -.LAST_PACKAGE \ No newline at end of file +.LAST_PACKAGE diff --git a/packages/aws-cdk-lib/NOTICE b/packages/aws-cdk-lib/NOTICE index 6e3b597cc9c8f..d07ebccb5ffb1 100644 --- a/packages/aws-cdk-lib/NOTICE +++ b/packages/aws-cdk-lib/NOTICE @@ -404,3 +404,367 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ---------------- + +** ajv - https://www.npmjs.com/package/ajv/v/8.12.0 | MIT +The MIT License (MIT) + +Copyright (c) 2015-2021 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +---------------- + +** ansi-regex - https://www.npmjs.com/package/ansi-regex/v/5.0.1 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** ansi-styles - https://www.npmjs.com/package/ansi-styles/v/4.3.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** astral-regex - https://www.npmjs.com/package/astral-regex/v/2.0.0 | MIT +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** color-convert - https://www.npmjs.com/package/color-convert/v/2.0.1 | MIT +Copyright (c) 2011-2016 Heather Arthur + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +---------------- + +** color-name - https://www.npmjs.com/package/color-name/v/1.1.4 | MIT +The MIT License (MIT) +Copyright (c) 2015 Dmitry Ivanov + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +---------------- + +** emoji-regex - https://www.npmjs.com/package/emoji-regex/v/8.0.0 | MIT +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** fast-deep-equal - https://www.npmjs.com/package/fast-deep-equal/v/3.1.3 | MIT +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +---------------- + +** is-fullwidth-code-point - https://www.npmjs.com/package/is-fullwidth-code-point/v/3.0.0 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** json-schema-traverse - https://www.npmjs.com/package/json-schema-traverse/v/1.0.0 | MIT +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +---------------- + +** lodash.truncate - https://www.npmjs.com/package/lodash.truncate/v/4.4.2 | MIT +Copyright jQuery Foundation and other contributors + +Based on Underscore.js, copyright Jeremy Ashkenas, +DocumentCloud and Investigative Reporters & Editors + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at https://github.com/lodash/lodash + +The following license applies to all parts of this software except as +documented below: + +==== + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==== + +Copyright and related rights for sample code are waived via CC0. Sample +code is defined as all source code displayed within the prose of the +documentation. + +CC0: http://creativecommons.org/publicdomain/zero/1.0/ + +==== + +Files located in the node_modules and vendor directories are externally +maintained libraries used by this software which have their own +licenses; we recommend you read them, as their terms may differ from the +terms above. + + +---------------- + +** require-from-string - https://www.npmjs.com/package/require-from-string/v/2.0.2 | MIT +The MIT License (MIT) + +Copyright (c) Vsevolod Strukchinsky (github.com/floatdrop) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +---------------- + +** slice-ansi - https://www.npmjs.com/package/slice-ansi/v/4.0.0 | MIT +MIT License + +Copyright (c) DC +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** string-width - https://www.npmjs.com/package/string-width/v/4.2.3 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** strip-ansi - https://www.npmjs.com/package/strip-ansi/v/6.0.1 | MIT +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +---------------- + +** table - https://www.npmjs.com/package/table/v/6.8.1 | BSD-3-Clause +Copyright (c) 2018, Gajus Kuizinas (http://gajus.com/) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +---------------- + +** uri-js - https://www.npmjs.com/package/uri-js/v/4.4.1 | BSD-2-Clause +Copyright 2011 Gary Court. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY GARY COURT "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GARY COURT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Gary Court. + + +---------------- \ No newline at end of file diff --git a/packages/aws-cdk-lib/README.md b/packages/aws-cdk-lib/README.md index da9707ff8f93d..860961b8300b9 100644 --- a/packages/aws-cdk-lib/README.md +++ b/packages/aws-cdk-lib/README.md @@ -1302,4 +1302,118 @@ permissions boundary attached. 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. +## Policy Validation + +If you or your organization use (or would like to use) any policy validation tool, such as +[CloudFormation +Guard](https://docs.aws.amazon.com/cfn-guard/latest/ug/what-is-guard.html) or +[OPA](https://www.openpolicyagent.org/), to define constraints on your +CloudFormation template, you can incorporate them into the CDK application. +By using the appropriate plugin, you can make the CDK application check the +generated CloudFormation templates against your policies immediately after +synthesis. If there are any violations, the synthesis will fail and a report +will be printed to the console or to a file (see below). + +> **Note** +> This feature is considered experimental, and both the plugin API and the +> format of the validation report are subject to change in the future. + +### For application developers + +To use one or more validation plugins in your application, use the +`policyValidationBeta1` property of `Stage`: + +```ts +// globally for the entire app (an app is a stage) +const app = new App({ + policyValidationBeta1: [ + // These hypothetical classes implement IValidationPlugin: + new ThirdPartyPluginX(), + new ThirdPartyPluginY(), + ], +}); + +// only apply to a particular stage +const prodStage = new Stage(app, 'ProdStage', { + policyValidationBeta1: [...], +}); +``` + +Immediately after synthesis, all plugins registered this way will be invoked to +validate all the templates generated in the scope you defined. In particular, if +you register the templates in the `App` object, all templates will be subject to +validation. + +> **Warning** +> Other than modifying the cloud assembly, plugins can do anything that your CDK +> application can. They can read data from the filesystem, access the network +> etc. It's your responsibility as the consumer of a plugin to verify that it is +> secure to use. + +By default, the report will be printed in a human readable format. If you want a +report in JSON format, enable it using the `@aws-cdk/core:validationReportJson` +context passing it directly to the application: + +```ts +const app = new App({ + context: { '@aws-cdk/core:validationReportJson': true }, +}); +``` + +Alternatively, you can set this context key-value pair using the `cdk.json` or +`cdk.context.json` files in your project directory (see +[Runtime context](https://docs.aws.amazon.com/cdk/v2/guide/context.html)). + +If you choose the JSON format, the CDK will print the policy validation report +to a file called `policy-validation-report.json` in the cloud assembly +directory. For the default, human-readable format, the report will be printed to +the standard output. + +### For plugin authors + +The communication protocol between the CDK core module and your policy tool is +defined by the `IValidationPluginBeta1` interface. To create a new plugin you must +write a class that implements this interface. There are two things you need to +implement: the plugin name (by overriding the `name` property), and the +`validate()` method. + +The framework will call `validate()`, passing an `IValidationContextBeta1` object. +The location of the templates to be validated is given by `templatePaths`. The +plugin should return an instance of `ValidationPluginReportBeta1`. This object +represents the report that the user wil receive at the end of the synthesis. + +```ts +validate(context: ValidationContextBeta1): ValidationReportBeta1 { + // First read the templates using context.templatePaths... + + // ...then perform the validation, and then compose and return the report. + // Using hard-coded values here for better clarity: + return { + success: false, + violations: [{ + ruleName: 'CKV_AWS_117', + recommendation: 'Ensure that AWS Lambda function is configured inside a VPC', + fix: 'https://docs.bridgecrew.io/docs/ensure-that-aws-lambda-function-is-configured-inside-a-vpc-1', + violatingResources: [{ + resourceName: 'MyFunction3BAA72D1', + templatePath: '/home/johndoe/myapp/cdk.out/MyService.template.json', + locations: 'Properties/VpcConfig', + }], + }], + }; +} +``` + +Note that plugins are not allowed to modify anything in the cloud assembly. Any +attempt to do so will result in synthesis failure. + +If your plugin depends on an external tool, keep in mind that some developers may +not have that tool installed in their workstations yet. To minimize friction, we +highly recommend that you provide some installation script along with your +plugin package, to automate the whole process. Better yet, run that script as +part of the installation of your package. With `npm`, for example, you can run +add it to the `postinstall` +[script](https://docs.npmjs.com/cli/v9/using-npm/scripts) in the `package.json` +file. + diff --git a/packages/aws-cdk-lib/package.json b/packages/aws-cdk-lib/package.json index 96ca304cef827..ef362560146c2 100644 --- a/packages/aws-cdk-lib/package.json +++ b/packages/aws-cdk-lib/package.json @@ -109,9 +109,13 @@ "minimatch", "punycode", "semver", + "table", "yaml" ], "dependencies": { + "@aws-cdk/asset-awscli-v1": "^2.2.97", + "@aws-cdk/asset-node-proxy-agent-v5": "^2.0.77", + "@aws-cdk/asset-kubectl-v20": "^2.1.1", "@balena/dockerignore": "^1.0.2", "case": "1.6.3", "fs-extra": "^9.1.0", @@ -120,10 +124,8 @@ "minimatch": "^3.1.2", "punycode": "^2.3.0", "semver": "^7.3.8", - "yaml": "1.10.2", - "@aws-cdk/asset-awscli-v1": "^2.2.97", - "@aws-cdk/asset-node-proxy-agent-v5": "^2.0.77", - "@aws-cdk/asset-kubectl-v20": "^2.1.1" + "table": "^6.8.1", + "yaml": "1.10.2" }, "devDependencies": { "@aws-cdk/alexa-ask": "0.0.0",