diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js new file mode 100644 index 000000000..32c4bb235 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/app.js @@ -0,0 +1,94 @@ +const fs = require('fs'); +const cdk = require('aws-cdk-lib/core'); +const s3 = require('aws-cdk-lib/aws-s3'); + +const stackPrefix = process.env.STACK_NAME_PREFIX; +if (!stackPrefix) { + throw new Error('the STACK_NAME_PREFIX environment variable is required'); +} + +class SecurityPlugin { + constructor() { + this.name = 'SecurityPlugin'; + this.version = '2.1.0'; + } + + validate(context) { + const violations = []; + for (const templatePath of context.templatePaths) { + const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); + for (const [logicalId, resource] of Object.entries(template.Resources || {})) { + if (resource.Type === 'AWS::S3::Bucket') { + violations.push( + { + ruleName: 'no-public-buckets', + description: 'S3 Buckets must not be publicly accessible', + fix: 'Set PublicAccessBlockConfiguration on the bucket', + severity: 'fatal', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/PublicAccessBlockConfiguration`], + }], + }, + { + ruleName: 'require-encryption', + description: 'S3 Buckets must have server-side encryption enabled', + fix: 'Add BucketEncryption property with SSE-S3 or SSE-KMS', + severity: 'error', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/BucketEncryption`], + }], + }, + { + ruleName: 'require-versioning', + description: 'S3 Buckets should have versioning enabled for data protection', + severity: 'warning', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/VersioningConfiguration`], + }], + }, + { + ruleName: 'consider-intelligent-tiering', + description: 'Consider using Intelligent-Tiering storage class for cost optimization', + severity: 'cost-optimization', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/IntelligentTieringConfigurations`], + }], + }, + ); + } + } + } + + return { + success: violations.length === 0, + violations, + }; + } +} + +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(new SecurityPlugin()); + +class ValidateStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + const bucket = new s3.Bucket(this, 'MyBucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // Construct Annotations plugin picks this up + cdk.Annotations.of(bucket).addWarningV2('bucket-no-lifecycle', 'This bucket has no lifecycle rules configured'); + } +} + +new ValidateStack(app, `${stackPrefix}-validate`); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json new file mode 100644 index 000000000..62ede1daa --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-app/cdk.json @@ -0,0 +1,8 @@ +{ + "app": "node app.js", + "versionReporting": false, + "context": { + "@aws-cdk/core:failSynthOnValidationErrors": false, + "@aws-cdk/core:annotationsInValidationReport": true + } +} diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js new file mode 100644 index 000000000..88da7f022 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/app.js @@ -0,0 +1,36 @@ +const cdk = require('aws-cdk-lib/core'); + +const stackPrefix = process.env.STACK_NAME_PREFIX; +if (!stackPrefix) { + throw new Error('the STACK_NAME_PREFIX environment variable is required'); +} + +class AlwaysPassesPlugin { + constructor() { + this.name = 'AlwaysPassesPlugin'; + this.version = '1.0.0'; + } + + validate(_context) { + return { + success: true, + violations: [], + }; + } +} + +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(new AlwaysPassesPlugin()); + +class PassingStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new cdk.CfnResource(this, 'WaitHandle', { + type: 'AWS::CloudFormation::WaitConditionHandle', + }); + } +} + +new PassingStack(app, `${stackPrefix}-validate-passing`); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json new file mode 100644 index 000000000..62ede1daa --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-passing-app/cdk.json @@ -0,0 +1,8 @@ +{ + "app": "node app.js", + "versionReporting": false, + "context": { + "@aws-cdk/core:failSynthOnValidationErrors": false, + "@aws-cdk/core:annotationsInValidationReport": true + } +} diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts new file mode 100644 index 000000000..294ed1918 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-passes-clean-app.integtest.ts @@ -0,0 +1,12 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate passes for clean app', + withSpecificFixture('validate-passing-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', fixture.fullStackName('validate-passing')], + ); + + expect(output).toContain('Policy validation passed. No violations found.'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts new file mode 100644 index 000000000..373fb9be0 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate/cdk-validate-reports-violations.integtest.ts @@ -0,0 +1,23 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate reports violations', + withSpecificFixture('validate-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', fixture.fullStackName('validate')], + { + allowErrExit: true, + }, + ); + + // SecurityPlugin violations + expect(output).toContain('S3 Buckets must not be publicly accessible'); + expect(output).toContain('S3 Buckets must have server-side encryption enabled'); + expect(output).toContain('S3 Buckets should have versioning enabled for data protection'); + expect(output).toContain('SecurityPlugin'); + + // Construct Annotations plugin picks up the addWarningV2 + expect(output).toContain('This bucket has no lifecycle rules configured'); + expect(output).toContain('Construct Annotations'); + }), +); diff --git a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md index 2fba211b7..87b360cca 100644 --- a/packages/@aws-cdk/toolkit-lib/docs/message-registry.md +++ b/packages/@aws-cdk/toolkit-lib/docs/message-registry.md @@ -146,9 +146,9 @@ Please let us know by [opening an issue](https://github.com/aws/aws-cdk-cli/issu | `CDK_TOOLKIT_I9500` | Stack diagnosis (no problems found) | `info` | {@link DiagnosedStack} | | `CDK_TOOLKIT_E9500` | Stack diagnosis (problems found) | `error` | {@link DiagnosedStack} | | `CDK_TOOLKIT_W9501` | Stack diagnosis (diagnosis could not be performed) | `warn` | {@link DiagnosedStack} | -| `CDK_TOOLKIT_I9600` | Validate passed with no problems | `info` | {@link ValidateResult} | -| `CDK_TOOLKIT_E9600` | Validate found problems | `error` | {@link ValidateResult} | -| `CDK_TOOLKIT_I9601` | No validation plugins configured | `info` | n/a | +| `CDK_TOOLKIT_I9600` | Policy validation passed | `info` | {@link ValidateResult} | +| `CDK_TOOLKIT_E9600` | Policy validation failed | `error` | {@link ValidateResult} | +| `CDK_TOOLKIT_I9601` | No policy validation report found | `info` | n/a | | `CDK_TOOLKIT_I0100` | Notices decoration (the header or footer of a list of notices) | `info` | n/a | | `CDK_TOOLKIT_W0101` | A notice that is marked as a warning | `warn` | n/a | | `CDK_TOOLKIT_E0101` | A notice that is marked as an error | `error` | n/a | diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts index 9b33e4347..a63a51378 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts @@ -508,19 +508,19 @@ export const IO = { // validate (96xx) CDK_TOOLKIT_I9600: make.info({ code: 'CDK_TOOLKIT_I9600', - description: 'Validate passed with no problems', + description: 'Policy validation passed', interface: 'ValidateResult', }), CDK_TOOLKIT_E9600: make.error({ code: 'CDK_TOOLKIT_E9600', - description: 'Validate found problems', + description: 'Policy validation failed', interface: 'ValidateResult', }), CDK_TOOLKIT_I9601: make.info({ code: 'CDK_TOOLKIT_I9601', - description: 'No validation plugins configured', + description: 'No policy validation report found', }), // Notices diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts new file mode 100644 index 000000000..f63308a88 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -0,0 +1,139 @@ +import * as path from 'node:path'; +import * as chalk from 'chalk'; +import type { PluginReportJson, ViolatingConstructJson } from '@aws-cdk/cloud-assembly-schema'; +import type { ValidateResult } from '../../actions/validate'; +import type { ActionLessMessage } from '../io/private'; +import { IO } from '../io/private'; + +interface FlattenedViolation { + readonly severity: string; + readonly description: string; + readonly ruleName: string; + readonly pluginName: string; + readonly construct: ViolatingConstructJson; +} + +const SEVERITY_ORDER: Record = { + fatal: 0, + error: 1, + warning: 2, + info: 3, +}; + +export function hostMessageFromValidation(result: ValidateResult): ActionLessMessage { + // Always emit at info level so the CLI IoHost doesn't wrap the entire output + // in a single color. The formatter handles per-severity coloring internally. + // Consumers detect failure via the structured `data.conclusion` field or exit code. + return IO.CDK_TOOLKIT_I9600.msg(formatValidateResult(result), result); +} + +export function formatValidateResult(result: ValidateResult): string { + const violations = flattenViolations(result.pluginReports); + + if (violations.length === 0) { + return '\nPolicy validation passed. No problems found.'; + } + + violations.sort((a, b) => { + const aOrder = SEVERITY_ORDER[a.severity.toLowerCase()] ?? 4; + const bOrder = SEVERITY_ORDER[b.severity.toLowerCase()] ?? 4; + return aOrder - bOrder; + }); + + const title = result.title ?? 'Validation Report'; + const blocks = violations.map((v) => formatViolationBlock(v)); + return `\n${title}\n${'-'.repeat(title.length)}\n\n${blocks.join('\n\n')}`; +} + +function flattenViolations(pluginReports: PluginReportJson[]): FlattenedViolation[] { + const result: FlattenedViolation[] = []; + + for (const report of pluginReports) { + const pluginName = report.pluginName; + + for (const violation of report.violations) { + const severity = normalizeSeverity(violation.severity); + + for (const construct of violation.violatingConstructs) { + result.push({ severity, description: violation.description, ruleName: violation.ruleName, pluginName, construct }); + } + } + } + + return result; +} + +function normalizeSeverity(severity: string | undefined): string { + if (!severity) return 'Warning'; + const lower = severity.toLowerCase(); + if (lower === 'fatal') return 'Fatal'; + if (lower === 'error') return 'Error'; + if (lower === 'warning') return 'Warning'; + if (lower === 'info') return 'Info'; + return severity.charAt(0).toUpperCase() + severity.slice(1); +} + +function formatViolationBlock(v: FlattenedViolation): string { + const lines: string[] = []; + + const location = getLeafLocation(v.construct.stackTraces); + if (location) { + lines.push(chalk.underline(location)); + } + + const severityColor = getSeverityColor(v.severity); + const description = stripAckTag(v.description); + const severityAndDesc = severityColor(chalk.bold(`${v.severity}: ${description}`)); + lines.push(`${severityAndDesc} ${v.pluginName}`); + + const constructInfo = formatConstructInfo(v.construct); + lines.push(` ${constructInfo}`); + + if (v.severity.toLowerCase() !== 'fatal') { + const ackId = `${v.pluginName}::${v.ruleName}`.replace(/ /g, '-'); + lines.push(` Acknowledge '${ackId}'`); + } + + return lines.join('\n'); +} + +function getSeverityColor(severity: string): (str: string) => string { + switch (severity.toLowerCase()) { + case 'fatal': return chalk.red; + case 'error': return chalk.ansi256(208); + case 'warning': return chalk.yellow; + default: return chalk.blue; + } +} + +function formatConstructInfo(construct: ViolatingConstructJson): string { + const parts: string[] = []; + const logicalId = construct.cloudFormationResource?.logicalId; + + if (construct.constructPath) { + parts.push(logicalId ? `${chalk.bold(construct.constructPath)} (${logicalId})` : chalk.bold(construct.constructPath)); + } else if (logicalId) { + parts.push(chalk.bold(logicalId)); + } + + if (construct.constructFqn) { + parts.push(construct.constructFqn); + } + + return parts.join(' '); +} + +function stripAckTag(description: string): string { + return description.replace(/\s*\[ack:\s*[^\]]+\]\s*/g, '').trim(); +} + +function getLeafLocation(stackTraces: string[] | undefined): string | undefined { + if (!stackTraces || stackTraces.length === 0) return undefined; + const lastTrace = stackTraces[stackTraces.length - 1]; + const frames = lastTrace.split('\n'); + if (frames.length === 0) return undefined; + const leafFrame = frames[0].trim(); + const match = leafFrame.match(/\((.+)\)$/) || leafFrame.match(/at\s+(.+)$/); + const location = match ? match[1] : leafFrame; + return path.isAbsolute(location.split(':')[0]) ? path.relative(process.cwd(), location) : location; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index 8e783a627..0ee0e6441 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1,7 +1,7 @@ import '../private/dispose-polyfill'; import * as path from 'node:path'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; -import type { FeatureFlagReportProperties } from '@aws-cdk/cloud-assembly-schema'; +import type { FeatureFlagReportProperties, PolicyValidationReportConclusion } from '@aws-cdk/cloud-assembly-schema'; import { ArtifactType, Manifest } from '@aws-cdk/cloud-assembly-schema'; import type { TemplateDiff } from '@aws-cdk/cloudformation-diff'; import * as chalk from 'chalk'; @@ -57,7 +57,7 @@ import type { PublishAssetsOptions, PublishAssetsResult } from '../actions/publi import type { RefactorOptions } from '../actions/refactor'; import { type RollbackOptions } from '../actions/rollback'; import { type SynthOptions } from '../actions/synth'; -import type { ValidateOptions, ValidateResult, PolicyValidationReportConclusion } from '../actions/validate'; +import type { ValidateOptions, ValidateResult } from '../actions/validate'; import type { IWatcher, WatchOptions } from '../actions/watch'; import { countAssemblyResults } from './private/count-assembly-results'; import { WATCH_EXCLUDE_DEFAULTS } from '../actions/watch/private'; @@ -89,6 +89,7 @@ import type { ElapsedTime, IoHelper } from '../api/io/private'; import { asIoHelper, IO, SPAN, withoutColor, withoutEmojis, withTrimmedWhitespace } from '../api/io/private'; import { CloudWatchLogEventMonitor, findCloudWatchLogGroups } from '../api/logs-monitor'; import { ResourceOrphaner } from '../api/orphan/orphaner'; +import { hostMessageFromValidation } from '../api/validate/validate-formatting'; import { parseAndValidateConstructPaths } from '../api/orphan/private/helpers'; import { Mode, PluginHost } from '../api/plugin'; import { @@ -114,7 +115,7 @@ import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; -const POLICY_VALIDATION_REPORT_FILE = 'validation-report.json'; +const VALIDATION_REPORT_FILE = 'validation-report.json'; export interface ToolkitOptions { /** @@ -181,7 +182,7 @@ export interface ToolkitOptions { * Names of toolkit features that are still under development, and may change in * the future. */ -export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets' | 'diagnose'; +export type UnstableFeature = 'refactor' | 'orphan' | 'flags' | 'publish-assets' | 'diagnose' | 'validate'; /** * The AWS CDK Programmatic Toolkit @@ -663,7 +664,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { const selectStacks = stacksOpt(options); await using assembly = await synthAndMeasure(ioHelper, cx, selectStacks); - const reportPath = path.join(assembly.directory, POLICY_VALIDATION_REPORT_FILE); + const reportPath = path.join(assembly.directory, VALIDATION_REPORT_FILE); if (!await fs.pathExists(reportPath)) { const result: ValidateResult = { @@ -686,11 +687,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { pluginReports: report.pluginReports, }; - if (conclusion === 'failure') { - await ioHelper.notify(IO.CDK_TOOLKIT_E9600.msg('❌ cdk validate found problems', result)); - } else { - await ioHelper.notify(IO.CDK_TOOLKIT_I9600.msg('✅ No problems found', result)); - } + await ioHelper.notify(hostMessageFromValidation(result)); return result; } diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json new file mode 100644 index 000000000..f816c0779 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/manifest.json @@ -0,0 +1,18 @@ +{ + "version": "40.0.0", + "artifacts": { + "test-validate.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "test-validate.assets.json" + } + }, + "test-validate": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "test-validate.template.json" + } + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json new file mode 100644 index 000000000..b4fd39619 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/test-validate.template.json @@ -0,0 +1,12 @@ +{ + "Resources": { + "MyBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "test-bucket" + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/validation-report.json new file mode 100644 index 000000000..9b722a055 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-multi-plugin-validation/cdk.out/validation-report.json @@ -0,0 +1,141 @@ +{ + "version": "1.0.0", + "title": "Validation Report", + "pluginReports": [ + { + "pluginName": "SecurityPlugin", + "pluginVersion": "2.1.0", + "conclusion": "failure", + "violations": [ + { + "ruleName": "no-public-buckets", + "description": "S3 Buckets must not be publicly accessible", + "suggestedFix": "Set PublicAccessBlockConfiguration on the bucket", + "severity": "fatal", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/PublicAccessBlockConfiguration"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "require-encryption", + "description": "S3 Buckets must have server-side encryption enabled", + "suggestedFix": "Add BucketEncryption property with SSE-S3 or SSE-KMS", + "severity": "error", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/BucketEncryption"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "require-versioning", + "description": "S3 Buckets should have versioning enabled for data protection", + "severity": "warning", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/VersioningConfiguration"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "consider-intelligent-tiering", + "description": "Consider using Intelligent-Tiering storage class for cost optimization", + "severity": "info", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/IntelligentTieringConfigurations"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + }, + { + "ruleName": "org-tagging-policy", + "description": "Resource does not comply with organization tagging policy TG-0042", + "severity": "custom", + "customSeverity": "compliance", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket/Resource", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket", + "propertyPaths": ["/Resources/MyBucket/Properties/Tags"] + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + } + ] + }, + { + "pluginName": "Construct Annotations", + "conclusion": "failure", + "violations": [ + { + "ruleName": "bucket-no-lifecycle", + "description": "This bucket has no lifecycle rules configured", + "severity": "warning", + "violatingConstructs": [ + { + "constructPath": "test-validate/MyBucket", + "constructFqn": "aws-cdk-lib/aws-s3.Bucket", + "libraryVersion": "2.253.0", + "cloudFormationResource": { + "templatePath": "test-validate.template.json", + "logicalId": "MyBucket" + }, + "stackTraces": [ + "new Bucket (app.js:80:20)\nnew ValidateStack (app.js:78:5)" + ] + } + ] + } + ] + } + ] +} diff --git a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts index 38c9ad239..86a8791cd 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -44,13 +44,6 @@ describe('validate', () => { ioHost.expectMessage({ containing: 'No validation plugins configured', level: 'info' }); }); - test('emits error IO message on failure', async () => { - const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - await toolkit.validate(cx); - - ioHost.expectMessage({ containing: 'cdk validate found problems', level: 'error' }); - }); - test('emits info IO message on success', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); await toolkit.validate(cx); @@ -95,6 +88,12 @@ describe('validate', () => { expect(result.pluginReports[1].pluginVersion).toBeUndefined(); }); + test('throws on malformed report', async () => { + const cx = await cdkOutFixture(toolkit, 'stack-with-malformed-validation-report'); + + await expect(toolkit.validate(cx)).rejects.toThrow(); + }); + test('parses stack traces correctly', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); const result = await toolkit.validate(cx); @@ -109,11 +108,11 @@ describe('validate', () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); await toolkit.validate(cx); - const errorMsg = ioHost.messages.find( - (m) => m.code === 'CDK_TOOLKIT_E9600', + const msg = ioHost.messages.find( + (m) => m.code === 'CDK_TOOLKIT_I9600', ); - expect(errorMsg).toBeDefined(); - expect(errorMsg!.data).toMatchObject({ + expect(msg).toBeDefined(); + expect(msg!.data).toMatchObject({ conclusion: 'failure', title: 'Validation Report', pluginReports: expect.arrayContaining([ diff --git a/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts new file mode 100644 index 000000000..cf403fb7a --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts @@ -0,0 +1,169 @@ +import { formatValidateResult } from '../../../lib/api/validate/validate-formatting'; +import type { ValidateResult } from '../../../lib/actions/validate'; + +// Disable chalk for predictable assertions +process.env.FORCE_COLOR = '0'; + +function makeResult(pluginReports: ValidateResult['pluginReports']): ValidateResult { + const conclusion = pluginReports.some((r) => r.conclusion === 'failure') ? 'failure' : 'success'; + return { conclusion, pluginReports } as ValidateResult; +} + +describe('formatValidateResult', () => { + test('returns pass message when no violations', () => { + const result = makeResult([ + { pluginName: 'TestPlugin', conclusion: 'success', violations: [] }, + ]); + expect(formatValidateResult(result)).toContain('No problems found.'); + }); + + test('sorts violations by severity (fatal > error > warning > info > custom)', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [ + { ruleName: 'r1', description: 'info issue', severity: 'info', violatingConstructs: [{ constructPath: 'Stack/A' }] }, + { ruleName: 'r2', description: 'fatal issue', severity: 'fatal', violatingConstructs: [{ constructPath: 'Stack/B' }] }, + { ruleName: 'r3', description: 'warning issue', severity: 'warning', violatingConstructs: [{ constructPath: 'Stack/C' }] }, + { ruleName: 'r4', description: 'error issue', severity: 'error', violatingConstructs: [{ constructPath: 'Stack/D' }] }, + { ruleName: 'r5', description: 'custom issue', severity: 'custom', violatingConstructs: [{ constructPath: 'Stack/E' }] }, + ], + }]); + + const output = formatValidateResult(result); + const lines = output.split('\n\n').filter(l => l.trim()); + expect(lines[1]).toContain('fatal issue'); + expect(lines[2]).toContain('error issue'); + expect(lines[3]).toContain('warning issue'); + expect(lines[4]).toContain('info issue'); + expect(lines[5]).toContain('custom issue'); + }); + + test('formats construct path with logical id', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad thing', + severity: 'error', + violatingConstructs: [{ + constructPath: 'Stack/MyBucket/Resource', + cloudFormationResource: { templatePath: 'Stack.template.json', logicalId: 'MyBucketF68F3FF0' }, + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('Stack/MyBucket/Resource'); + expect(output).toContain('MyBucketF68F3FF0'); + }); + + test('formats construct with only logical id when no construct path', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad thing', + severity: 'error', + violatingConstructs: [{ + constructPath: '', + cloudFormationResource: { templatePath: 'Stack.template.json', logicalId: 'MyResource' }, + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('MyResource'); + }); + + test('extracts leaf location from stack trace with parens', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad', + severity: 'error', + violatingConstructs: [{ + constructPath: 'Stack/Bucket', + stackTraces: ['new Bucket (lib/my-stack.ts:12:5)\nnew MyStack (lib/my-stack.ts:30:5)'], + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('lib/my-stack.ts:12:5'); + }); + + test('extracts leaf location from bare stack trace without parens', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad', + severity: 'error', + violatingConstructs: [{ + constructPath: 'Stack/Bucket', + stackTraces: ['at file.js:10:5'], + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('file.js:10:5'); + }); + + test('omits acknowledge line for fatal severity', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'critical', + severity: 'fatal', + violatingConstructs: [{ constructPath: 'Stack/Resource' }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).not.toContain('Acknowledge'); + }); + + test('includes acknowledge line for non-fatal severities', () => { + const result = makeResult([{ + pluginName: 'SecurityPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'no-public-buckets', + description: 'bad bucket', + severity: 'error', + violatingConstructs: [{ constructPath: 'Stack/Bucket' }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain("Acknowledge 'SecurityPlugin::no-public-buckets'"); + }); + + test('includes constructFqn when present', () => { + const result = makeResult([{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'rule1', + description: 'bad', + severity: 'warning', + violatingConstructs: [{ + constructPath: 'Stack/Bucket', + constructFqn: 'aws-cdk-lib/aws-s3.Bucket', + }], + }], + }]); + + const output = formatValidateResult(result); + expect(output).toContain('aws-cdk-lib/aws-s3.Bucket'); + }); +}); diff --git a/packages/aws-cdk/lib/cli/cdk-toolkit.ts b/packages/aws-cdk/lib/cli/cdk-toolkit.ts index 8b7b1f4d6..365115d73 100644 --- a/packages/aws-cdk/lib/cli/cdk-toolkit.ts +++ b/packages/aws-cdk/lib/cli/cdk-toolkit.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import { format } from 'node:util'; import * as cxapi from '@aws-cdk/cloud-assembly-api'; import { RequireApproval } from '@aws-cdk/cloud-assembly-schema'; -import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature } from '@aws-cdk/toolkit-lib'; +import type { ConfirmationRequest, DeploymentMethod, DiagnoseOptions, PublishAssetsOptions, ToolkitAction, ToolkitOptions, UnstableFeature, ValidateOptions } from '@aws-cdk/toolkit-lib'; import { PermissionChangeType, Toolkit, ToolkitError } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; import * as chokidar from 'chokidar'; @@ -211,6 +211,7 @@ export class CdkToolkit { 'flags': true, 'orphan': true, 'refactor': true, + 'validate': true, }; this.toolkit = new InternalToolkit(props.sdkProvider, { @@ -844,6 +845,14 @@ export class CdkToolkit { return totalDrifts > 0 && options.fail ? 1 : 0; } + /** + * Validate synthesized templates against policy rules + */ + public async validate(options: ValidateOptions): Promise { + const result = await this.toolkit.validate(this.props.cloudExecutable, options); + return result.conclusion === 'failure' ? 1 : 0; + } + /** * Diagnose errors */ diff --git a/packages/aws-cdk/lib/cli/cli-config.ts b/packages/aws-cdk/lib/cli/cli-config.ts index ffb8f0119..8e04bba8d 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -222,6 +222,14 @@ export async function makeConfig(): Promise { variadic: true, }, }, + 'validate': { + description: 'Validate synthesized CloudFormation templates against policy rules', + options: {}, + arg: { + name: 'STACKS', + variadic: true, + }, + }, 'diagnose': { description: 'Find the root cause(s) of stack deployment failures', options: { diff --git a/packages/aws-cdk/lib/cli/cli-type-registry.json b/packages/aws-cdk/lib/cli/cli-type-registry.json index 97aeea0b5..33b467b4d 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -590,6 +590,14 @@ "variadic": true } }, + "validate": { + "description": "Validate synthesized CloudFormation templates against policy rules", + "options": {}, + "arg": { + "name": "STACKS", + "variadic": true + } + }, "diagnose": { "description": "Find the root cause(s) of stack deployment failures", "options": { diff --git a/packages/aws-cdk/lib/cli/cli.ts b/packages/aws-cdk/lib/cli/cli.ts index f406498e0..446e19782 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -444,6 +444,14 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise): any { desc: 'Create a drift-aware change set that brings actual resource states in line with template definitions', }), ) + .command('validate [STACKS..]', 'Validate synthesized CloudFormation templates against policy rules', (yargs: Argv) => yargs) .command('diagnose [STACKS..]', 'Find the root cause(s) of stack deployment failures', (yargs: Argv) => yargs .option('toolkit-stack-name', { diff --git a/packages/aws-cdk/lib/cli/user-configuration.ts b/packages/aws-cdk/lib/cli/user-configuration.ts index 055d8bc0a..61438a348 100644 --- a/packages/aws-cdk/lib/cli/user-configuration.ts +++ b/packages/aws-cdk/lib/cli/user-configuration.ts @@ -43,6 +43,7 @@ export enum Command { DRIFT = 'drift', CLI_TELEMETRY = 'cli-telemetry', DIAGNOSE = 'diagnose', + VALIDATE = 'validate', } const BUNDLING_COMMANDS = [ diff --git a/packages/aws-cdk/lib/cli/user-input.ts b/packages/aws-cdk/lib/cli/user-input.ts index 08d103b42..3d1d7b981 100644 --- a/packages/aws-cdk/lib/cli/user-input.ts +++ b/packages/aws-cdk/lib/cli/user-input.ts @@ -55,6 +55,11 @@ export interface UserInput { */ readonly deploy?: DeployOptions; + /** + * Validate synthesized CloudFormation templates against policy rules + */ + readonly validate?: ValidateOptions; + /** * Find the root cause(s) of stack deployment failures */ @@ -981,6 +986,18 @@ export interface DeployOptions { readonly STACKS?: Array; } +/** + * Validate synthesized CloudFormation templates against policy rules + * + * @struct + */ +export interface ValidateOptions { + /** + * Positional argument for validate + */ + readonly STACKS?: Array; +} + /** * Find the root cause(s) of stack deployment failures * diff --git a/yarn.lock b/yarn.lock index b2fe8fca1..606e75dd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -235,14 +235,14 @@ __metadata: linkType: hard "@aws-cdk/cloud-assembly-api@npm:^2.2.3": - version: 2.2.3 - resolution: "@aws-cdk/cloud-assembly-api@npm:2.2.3" + version: 2.2.4 + resolution: "@aws-cdk/cloud-assembly-api@npm:2.2.4" dependencies: - jsonschema: "npm:~1.4.1" - semver: "npm:^7.7.4" + jsonschema: "npm:^1.5.0" + semver: "npm:^7.8.0" peerDependencies: - "@aws-cdk/cloud-assembly-schema": ">=53.21.0" - checksum: 10c0/3f9309a28ef6e7ba62ed83925d8471b3141e991ec895f7b0340a5baa6a57cab1408e6876203cea2f8e6db75cf9785248e4015b4dca8c941b0c0ea2fc7900b8ea + "@aws-cdk/cloud-assembly-schema": ">=53.25.0" + checksum: 10c0/49ff690c827a1f4795267a9f449d10b2098522f9c55a09d5dc42db93faf9091e7ddef81c37763e2300e4c17d0c0564586fcfc3dad02232fdcdc912d050db7c67 languageName: node linkType: hard @@ -299,12 +299,12 @@ __metadata: linkType: soft "@aws-cdk/cloud-assembly-schema@npm:^53.21.0": - version: 53.23.0 - resolution: "@aws-cdk/cloud-assembly-schema@npm:53.23.0" + version: 53.27.0 + resolution: "@aws-cdk/cloud-assembly-schema@npm:53.27.0" dependencies: - jsonschema: "npm:~1.4.1" - semver: "npm:^7.7.4" - checksum: 10c0/1fae7ecb9c1a4ee8030d1a7314aae95d010e295b9dc9e6c74d65794c4ca43b8b85899d5509af02fbf34abeaaae46d02542ba00612f6f319e83abea44fcb2eb90 + jsonschema: "npm:^1.5.0" + semver: "npm:^7.8.0" + checksum: 10c0/862ac533f5a261e24386fd6eff9cd18dca2be8b2799f2b8d3c3d6df57b3eba01bf78bdb265166466d5fafc6cd2d7d065f1cb0fe175c5b84ef41064fd00ae304e languageName: node linkType: hard