Skip to content

Commit f396892

Browse files
authored
feat(core): include suppressed violations in validation-report.json (#38009)
## Summary - Suppressed violations are now included in `validation-report.json` under a `suppressedViolations` array per plugin report, providing an audit trail. - Each suppressed violation entry includes the full violation details plus `acknowledgedId`, `reason`, and `acknowledgedAt` (the construct path where `acknowledge()` was called). - Suppressed violations remain excluded from the active `violations` list, the pretty-printed output, and the success/failure determination. - `collectAcknowledgedRuleIds` now returns a `Map<string, AcknowledgedRule>` (with reason + construct path) instead of a bare `Set<string>`. Schema: aws/aws-cdk-cli#1556 ## Test plan - [x] New test: `suppressed violations appear in validation-report.json` — verifies the JSON report contains the suppressed violation with all metadata - [x] Existing suppression tests continue to pass (active violations removed, fatal violations retained) - [x] Full validation test suite passes (49/49)
1 parent cae7456 commit f396892

5 files changed

Lines changed: 210 additions & 88 deletions

File tree

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
import type { IConstruct } from 'constructs';
22
import { iterateDfsPreorder } from './construct-iteration';
33

4+
export interface AcknowledgedRule {
5+
readonly reason: string;
6+
readonly constructPath: string;
7+
readonly stackTrace?: string;
8+
}
9+
410
/**
511
* Collect all acknowledged rule IDs from construct metadata across the tree.
12+
* Returns a map from rule ID to acknowledgement details (reason, construct path, and stack trace).
613
*/
7-
export function collectAcknowledgedRuleIds(root: IConstruct): Set<string> {
8-
const ids = new Set<string>();
14+
export function collectAcknowledgedRuleIds(root: IConstruct): Map<string, AcknowledgedRule> {
15+
const rules = new Map<string, AcknowledgedRule>();
916
for (const construct of iterateDfsPreorder(root)) {
1017
for (const entry of construct.node.metadata) {
1118
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);
19+
for (const [id, reason] of Object.entries(entry.data as Record<string, string>)) {
20+
rules.set(id, {
21+
reason,
22+
constructPath: construct.node.path,
23+
stackTrace: entry.trace?.join('\n'),
24+
});
1425
}
1526
}
1627
}
1728
}
18-
return ids;
29+
return rules;
1930
}

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

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import type { StageSynthesisOptions } from '../stage';
2424
import { Stage } from '../stage';
2525
import type { IPolicyValidationPlugin } from '../validation';
2626
import { ConstructTree } from '../validation/private/construct-tree';
27-
import type { NamedValidationPluginReport } from '../validation/private/report';
27+
import type { NamedValidationPluginReport, SuppressedViolation } from '../validation/private/report';
2828
import { PolicyValidationReportFormatter } from '../validation/private/report';
2929

3030
const LEGACY_POLICY_VALIDATION_FILE_PATH = 'policy-validation-report.json';
@@ -174,20 +174,38 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
174174
// Rule matching: violations are matched as <pluginName>::<ruleName> with
175175
// spaces replaced by dashes. Users suppress with:
176176
// Validations.of(x).acknowledge({ id: '<plugin-name>::<rule-id>' })
177-
const acknowledgedRuleIds = collectAcknowledgedRuleIds(root);
178-
if (acknowledgedRuleIds.size > 0) {
177+
const acknowledgedRules = collectAcknowledgedRuleIds(root);
178+
const suppressedByReport: Map<number, SuppressedViolation[]> = new Map();
179+
if (acknowledgedRules.size > 0) {
179180
for (let i = 0; i < reports.length; i++) {
180181
const pluginName = reports[i].pluginName.replace(/ /g, '-');
181-
const filtered = reports[i].violations.filter(v => {
182-
if (v.severity === 'fatal') return true;
182+
const active: typeof reports[0]['violations'] = [];
183+
const suppressed: SuppressedViolation[] = [];
184+
for (const v of reports[i].violations) {
185+
if (v.severity === 'fatal') {
186+
active.push(v);
187+
continue;
188+
}
183189
const ruleId = `${pluginName}::${v.ruleName.replace(/ /g, '-')}`;
184-
return !acknowledgedRuleIds.has(ruleId);
185-
});
186-
if (filtered.length !== reports[i].violations.length) {
190+
const ack = acknowledgedRules.get(ruleId);
191+
if (ack) {
192+
suppressed.push({
193+
...v,
194+
acknowledgedId: ruleId,
195+
reason: ack.reason,
196+
acknowledgedAt: ack.constructPath,
197+
acknowledgedStackTrace: ack.stackTrace,
198+
});
199+
} else {
200+
active.push(v);
201+
}
202+
}
203+
if (suppressed.length > 0) {
204+
suppressedByReport.set(i, suppressed);
187205
reports[i] = {
188206
...reports[i],
189-
violations: filtered,
190-
success: filtered.every(v => v.severity !== 'error' && v.severity !== 'fatal'),
207+
violations: active,
208+
success: active.every(v => v.severity !== 'error' && v.severity !== 'fatal'),
191209
};
192210
}
193211
}
@@ -200,7 +218,7 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
200218
const writeLegacyReport = getBooleanContext(root, cxapi.VALIDATION_REPORT_JSON_CONTEXT, false);
201219

202220
const reportFile = path.join(assembly.directory, cxapi.VALIDATION_REPORT_FILE);
203-
const jsonOutput = formatter.formatJson(reports, assembly.version);
221+
const jsonOutput = formatter.formatJson(reports, assembly.version, suppressedByReport);
204222
fs.writeFileSync(reportFile, JSON.stringify(jsonOutput, undefined, 2));
205223

206224
if (writeLegacyReport) {

packages/aws-cdk-lib/core/lib/validation/private/report.ts

Lines changed: 104 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export interface PluginReportJson {
118118
readonly conclusion: PolicyValidationReportConclusion;
119119
readonly metadata?: { readonly [key: string]: string };
120120
readonly violations: PolicyViolationJson[];
121+
readonly suppressedViolations?: SuppressedViolationJson[];
121122
}
122123

123124
export type PolicyValidationReportConclusion = 'success' | 'failure';
@@ -148,6 +149,24 @@ export interface CloudFormationResourceJson {
148149
readonly propertyPaths?: string[];
149150
}
150151

152+
export interface SuppressedViolationJson extends PolicyViolationJson {
153+
readonly acknowledgedId: string;
154+
readonly reason?: string;
155+
readonly acknowledgedAt?: string;
156+
readonly acknowledgedStackTrace?: string;
157+
}
158+
159+
/**
160+
* A violation that was suppressed, carrying acknowledgement metadata.
161+
* Used internally to pass suppressed violations from synthesis to the formatter.
162+
*/
163+
export interface SuppressedViolation extends report.PolicyViolation {
164+
readonly acknowledgedId: string;
165+
readonly reason?: string;
166+
readonly acknowledgedAt?: string;
167+
readonly acknowledgedStackTrace?: string;
168+
}
169+
151170
/**
152171
* The report emitted by the plugin after evaluation.
153172
*/
@@ -280,65 +299,97 @@ export class PolicyValidationReportFormatter {
280299
};
281300
}
282301

283-
public formatJson(reps: NamedValidationPluginReport[], schemaVersion: string): PolicyValidationReportJson {
302+
public formatJson(
303+
reps: NamedValidationPluginReport[],
304+
schemaVersion: string,
305+
suppressedByReport?: Map<number, SuppressedViolation[]>,
306+
): PolicyValidationReportJson {
284307
return {
285308
version: schemaVersion,
286309
title: 'Validation Report',
287-
pluginReports: reps
288-
.filter(rep => !rep.success || rep.violations.length > 0)
289-
.map(rep => ({
290-
pluginName: rep.pluginName,
291-
pluginVersion: rep.pluginVersion,
292-
conclusion: (rep.success ? 'success' : 'failure') as PolicyValidationReportConclusion,
293-
metadata: rep.metadata,
294-
violations: rep.violations.map(violation => {
295-
const severity = normalizeSeverity(violation.severity);
296-
return {
297-
ruleName: violation.ruleName,
298-
description: violation.description,
299-
suggestedFix: violation.fix,
300-
severity: severity.severity,
301-
customSeverity: severity.customSeverity,
302-
ruleMetadata: violation.ruleMetadata,
303-
violatingConstructs: violation.violatingResources.map(resource => {
304-
const constructPath = resource.constructPath ?? (
305-
resource.templatePath && resource.resourceLogicalId
306-
? this.tree.getConstructByLogicalId(
307-
path.basename(resource.templatePath),
308-
resource.resourceLogicalId,
309-
)?.node.path
310-
: undefined
311-
);
312-
const construct = constructPath
313-
? this.tree.getConstructByPath(constructPath)
314-
: undefined;
315-
const constructInfo = construct
316-
? this.tree.constructTraceLevelFromTreeNode(construct)
317-
: undefined;
318-
319-
const result: ViolatingConstructJson = {
320-
constructPath: constructPath ?? 'N/A',
321-
constructFqn: constructInfo?.construct,
322-
libraryVersion: constructInfo?.libraryVersion,
323-
cloudFormationResource: resource.resourceLogicalId && resource.templatePath
324-
? {
325-
templatePath: resource.templatePath,
326-
logicalId: resource.resourceLogicalId,
327-
propertyPaths: resource.locations.length > 0 ? resource.locations : undefined,
328-
}
329-
: undefined,
330-
stackTraces: constructPath
331-
? this.formatStackTraces(constructPath)
332-
: undefined,
333-
};
334-
return result;
335-
}),
336-
};
337-
}),
338-
})),
310+
pluginReports: this.buildPluginReports(reps, suppressedByReport),
339311
};
340312
}
341313

314+
private formatViolationJson(violation: report.PolicyViolation): PolicyViolationJson {
315+
const severity = normalizeSeverity(violation.severity);
316+
return {
317+
ruleName: violation.ruleName,
318+
description: violation.description,
319+
suggestedFix: violation.fix,
320+
severity: severity.severity,
321+
customSeverity: severity.customSeverity,
322+
ruleMetadata: violation.ruleMetadata,
323+
violatingConstructs: violation.violatingResources.map(resource => {
324+
const constructPath = resource.constructPath ?? (
325+
resource.templatePath && resource.resourceLogicalId
326+
? this.tree.getConstructByLogicalId(
327+
path.basename(resource.templatePath),
328+
resource.resourceLogicalId,
329+
)?.node.path
330+
: undefined
331+
);
332+
const construct = constructPath
333+
? this.tree.getConstructByPath(constructPath)
334+
: undefined;
335+
const constructInfo = construct
336+
? this.tree.constructTraceLevelFromTreeNode(construct)
337+
: undefined;
338+
339+
const result: ViolatingConstructJson = {
340+
constructPath: constructPath ?? 'N/A',
341+
constructFqn: constructInfo?.construct,
342+
libraryVersion: constructInfo?.libraryVersion,
343+
cloudFormationResource: resource.resourceLogicalId && resource.templatePath
344+
? {
345+
templatePath: resource.templatePath,
346+
logicalId: resource.resourceLogicalId,
347+
propertyPaths: resource.locations.length > 0 ? resource.locations : undefined,
348+
}
349+
: undefined,
350+
stackTraces: constructPath
351+
? this.formatStackTraces(constructPath)
352+
: undefined,
353+
};
354+
return result;
355+
}),
356+
};
357+
}
358+
359+
private formatSuppressedViolationJson(sv: SuppressedViolation): SuppressedViolationJson {
360+
const base = this.formatViolationJson(sv);
361+
return {
362+
...base,
363+
acknowledgedId: sv.acknowledgedId,
364+
reason: sv.reason || undefined,
365+
acknowledgedAt: sv.acknowledgedAt || undefined,
366+
acknowledgedStackTrace: sv.acknowledgedStackTrace || undefined,
367+
};
368+
}
369+
370+
private buildPluginReports(
371+
reps: NamedValidationPluginReport[],
372+
suppressedByReport?: Map<number, SuppressedViolation[]>,
373+
): PluginReportJson[] {
374+
const results: PluginReportJson[] = [];
375+
for (let idx = 0; idx < reps.length; idx++) {
376+
const rep = reps[idx];
377+
const suppressed = suppressedByReport?.get(idx);
378+
if (rep.success && rep.violations.length === 0 && !suppressed) continue;
379+
results.push({
380+
pluginName: rep.pluginName,
381+
pluginVersion: rep.pluginVersion,
382+
conclusion: (rep.success ? 'success' : 'failure') as PolicyValidationReportConclusion,
383+
metadata: rep.metadata,
384+
violations: rep.violations.map(violation => this.formatViolationJson(violation)),
385+
suppressedViolations: suppressed
386+
? suppressed.map(sv => this.formatSuppressedViolationJson(sv))
387+
: undefined,
388+
});
389+
}
390+
return results;
391+
}
392+
342393
private formatStackTraces(constructPath: string): string[] | undefined {
343394
const trace = this.reportTrace.formatJson(constructPath);
344395
if (!trace) return undefined;

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,11 @@ export class Validations {
134134
}
135135

136136
private recordAcknowledgment(id: string, reason: string): void {
137-
const matches = this.scope.node.metadata.filter(
138-
(m: { type: string }) => m.type === Validations.ACKNOWLEDGED_RULES_METADATA_KEY,
137+
this.scope.node.addMetadata(
138+
Validations.ACKNOWLEDGED_RULES_METADATA_KEY,
139+
{ [id]: reason },
140+
{ stackTrace: true },
139141
);
140-
const existing = matches.length > 0 ? matches[matches.length - 1] : undefined;
141-
const acknowledged: Record<string, string> = existing?.data ?? {};
142-
acknowledged[id] = reason;
143-
this.scope.node.addMetadata(Validations.ACKNOWLEDGED_RULES_METADATA_KEY, acknowledged);
144142
}
145143

146144
private qualifyId(id: string): string {

0 commit comments

Comments
 (0)