Skip to content

Commit a47ad39

Browse files
authored
feat(core): plugin violations can be suppressed (#37808)
### Reason for this change Part of the [Validations RFC](aws/aws-cdk-rfcs#899). Enables suppression of validation plugin findings via `Validations.of().acknowledge()`. Reroll of #37781 ### Description of changes - `collectAcknowledgedRuleIds()` walks the construct tree collecting acknowledged rule IDs from metadata - After all plugins report, violations matching acknowledged IDs are filtered out - Rule matching: `<pluginName>::<ruleName>` (e.g. `CfnGuardValidator::S3_BUCKET_VERSIONING`) - Fatal violations (`severity === 'fatal'`) cannot be suppressed - Works for any plugin — no dependency on the default validation engine ### Usage ```ts Validations.of(myConstruct).acknowledge({ id: 'CfnGuardValidator::S3_BUCKET_VERSIONING', reason: 'Handled by org-level policy' }); ``` ### Description of how you validated changes - `tsc --noEmit` passes - 41 validation tests pass (2 new: suppression works, fatal cannot be suppressed) ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md)
1 parent b601edf commit a47ad39

3 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { IConstruct } from 'constructs';
2+
import { iterateDfsPreorder } from './construct-iteration';
3+
4+
/**
5+
* Collect all acknowledged rule IDs from construct metadata across the tree.
6+
*/
7+
export function collectAcknowledgedRuleIds(root: IConstruct): Set<string> {
8+
const ids = new Set<string>();
9+
for (const construct of iterateDfsPreorder(root)) {
10+
for (const entry of construct.node.metadata) {
11+
if (entry.type === 'aws:cdk:acknowledged-rules' && entry.data) {
12+
for (const id of Object.keys(entry.data as Record<string, string>)) {
13+
ids.add(id);
14+
}
15+
}
16+
}
17+
}
18+
return ids;
19+
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as fs from 'fs';
22
import * as path from 'path';
33
import * as private_cxapi from '@aws-cdk/cloud-assembly-api';
44
import type { IConstruct } from 'constructs';
5+
import { collectAcknowledgedRuleIds } from './collect-acknowledged-rule-ids';
56
import { iterateDfsPreorder } from './construct-iteration';
67
import { generateFeatureFlagReport } from './feature-flag-report';
78
import { lit } from './literal-string';
@@ -265,6 +266,32 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
265266
}
266267
}
267268

269+
// Filter out suppressed violations. Collect all acknowledged rule IDs
270+
// from construct metadata across the tree, then remove matching violations
271+
// from reports. Fatal violations cannot be suppressed.
272+
//
273+
// Rule matching: violations are matched as <pluginName>::<ruleName> with
274+
// spaces replaced by dashes. Users suppress with:
275+
// Validations.of(x).acknowledge({ id: '<plugin-name>::<rule-id>' })
276+
const acknowledgedRuleIds = collectAcknowledgedRuleIds(root);
277+
if (acknowledgedRuleIds.size > 0) {
278+
for (let i = 0; i < reports.length; i++) {
279+
const pluginName = reports[i].pluginName.replace(/ /g, '-');
280+
const filtered = reports[i].violations.filter(v => {
281+
if (v.severity === 'fatal') return true;
282+
const ruleId = `${pluginName}::${v.ruleName.replace(/ /g, '-')}`;
283+
return !acknowledgedRuleIds.has(ruleId);
284+
});
285+
if (filtered.length !== reports[i].violations.length) {
286+
reports[i] = {
287+
...reports[i],
288+
violations: filtered,
289+
success: filtered.every(v => v.severity !== 'error' && v.severity !== 'fatal'),
290+
};
291+
}
292+
}
293+
}
294+
268295
if (reports.length > 0) {
269296
const tree = new ConstructTree(root);
270297
const formatter = new PolicyValidationReportFormatter(tree);

packages/aws-cdk-lib/core/test/validation/validation.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,98 @@ Policy Validation Report Summary
10491049
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
10501050
expect(output).toContain('my-lib:TestId (');
10511051
});
1052+
1053+
test('plugin violations can be suppressed via Validations.acknowledge()', () => {
1054+
const app = new core.App({ context: annotationReportContext });
1055+
const stack = new core.Stack(app);
1056+
new core.CfnResource(stack, 'MyBucket', {
1057+
type: 'AWS::S3::Bucket',
1058+
properties: {},
1059+
});
1060+
1061+
core.Validations.of(app).addPlugins(
1062+
new FakePlugin('test-plugin', [{
1063+
description: 'S3 Bucket should have versioning enabled',
1064+
ruleName: 'S3_BUCKET_VERSIONING_ENABLED',
1065+
severity: 'error',
1066+
violatingResources: [{
1067+
locations: ['Properties/VersioningConfiguration'],
1068+
resourceLogicalId: 'MyBucket',
1069+
templatePath: '/path/to/Default.template.json',
1070+
}],
1071+
}]),
1072+
);
1073+
1074+
// Suppress the error-level violation using <pluginName>::<ruleId>
1075+
core.Validations.of(stack).acknowledge({ id: 'test-plugin::S3_BUCKET_VERSIONING_ENABLED', reason: 'Not needed for this bucket' });
1076+
1077+
app.synth();
1078+
1079+
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
1080+
expect(output).not.toContain('S3_BUCKET_VERSIONING_ENABLED');
1081+
});
1082+
1083+
test('fatal plugin violations cannot be suppressed', () => {
1084+
const app = new core.App({ context: annotationReportContext });
1085+
const stack = new core.Stack(app);
1086+
new core.CfnResource(stack, 'Fake', {
1087+
type: 'AWS::S3::Bucket',
1088+
properties: {},
1089+
});
1090+
1091+
core.Validations.of(app).addPlugins(
1092+
new FakePlugin('test-plugin', [{
1093+
description: 'Unknown resource type',
1094+
ruleName: 'E9001',
1095+
severity: 'fatal',
1096+
violatingResources: [{
1097+
locations: [],
1098+
resourceLogicalId: 'BadResource',
1099+
templatePath: '/path/to/Default.template.json',
1100+
}],
1101+
}]),
1102+
);
1103+
1104+
// Attempt to suppress the fatal violation
1105+
core.Validations.of(stack).acknowledge({ id: 'test-plugin::E9001', reason: 'Trying to suppress fatal' });
1106+
1107+
app.synth();
1108+
1109+
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
1110+
// Fatal violations remain despite acknowledgment
1111+
expect(output).toContain('E9001');
1112+
expect(output).toContain('Unknown resource type');
1113+
});
1114+
1115+
test('plugin names with spaces use dashes in suppression IDs', () => {
1116+
const app = new core.App({ context: annotationReportContext });
1117+
const stack = new core.Stack(app);
1118+
new core.CfnResource(stack, 'Fake', {
1119+
type: 'AWS::S3::Bucket',
1120+
properties: {},
1121+
});
1122+
1123+
core.Validations.of(app).addPlugins(
1124+
new FakePlugin('My Plugin', [{
1125+
description: 'Some violation',
1126+
ruleName: 'MY RULE',
1127+
severity: 'error',
1128+
violatingResources: [{
1129+
locations: [],
1130+
resourceLogicalId: 'Fake',
1131+
templatePath: '/path/to/Default.template.json',
1132+
}],
1133+
}]),
1134+
);
1135+
1136+
// Suppress using dashes instead of spaces
1137+
core.Validations.of(stack).acknowledge({ id: 'My-Plugin::MY-RULE', reason: 'OK' });
1138+
1139+
app.synth();
1140+
1141+
const output = consoleErrorMock.mock.calls.map((c: any[]) => c[0]).join('\n');
1142+
expect(output).not.toContain('MY RULE');
1143+
});
10521144
});
10531145

10541146
describe('Validations.of()', () => {

0 commit comments

Comments
 (0)