Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"app": "node app.js",
"versionReporting": false,
"context": {
"@aws-cdk/core:failSynthOnValidationErrors": false,
"@aws-cdk/core:annotationsInValidationReport": true
}
}
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"app": "node app.js",
"versionReporting": false,
"context": {
"@aws-cdk/core:failSynthOnValidationErrors": false,
"@aws-cdk/core:annotationsInValidationReport": true
}
}
Original file line number Diff line number Diff line change
@@ -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.');
}),
);
Original file line number Diff line number Diff line change
@@ -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');
}),
);
6 changes: 3 additions & 3 deletions packages/@aws-cdk/toolkit-lib/docs/message-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 3 additions & 3 deletions packages/@aws-cdk/toolkit-lib/lib/api/io/private/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,19 +508,19 @@ export const IO = {
// validate (96xx)
CDK_TOOLKIT_I9600: make.info<ValidateResult>({
code: 'CDK_TOOLKIT_I9600',
description: 'Validate passed with no problems',
description: 'Policy validation passed',
interface: 'ValidateResult',
}),

CDK_TOOLKIT_E9600: make.error<ValidateResult>({
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
Expand Down
139 changes: 139 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
fatal: 0,
error: 1,
warning: 2,
info: 3,
};

export function hostMessageFromValidation(result: ValidateResult): ActionLessMessage<any> {
// 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;
}
Loading
Loading