diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/index.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/index.ts index 9905f44e9..534117eff 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/index.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/index.ts @@ -3,3 +3,4 @@ export * from './metadata-schema'; export * from './artifact-schema'; export * from './context-queries'; export * from './interfaces'; +export * from './validation-report-schema'; diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/validation-report-schema.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/validation-report-schema.ts new file mode 100644 index 000000000..8366fa94e --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/cloud-assembly/validation-report-schema.ts @@ -0,0 +1,187 @@ +/** + * JSON schema for policy-validation-report.json + * + * This file is written to the cloud assembly directory by aws-cdk-lib + * during synthesis and consumed by the CDK CLI's validate command. + */ + +/** + * The top-level structure of the policy validation report file. + */ +export interface PolicyValidationReportJson { + /** + * Protocol version + */ + readonly version: string; + + /** + * Report title, if present. + */ + readonly title?: string; + + /** + * Reports from all validation plugins that ran during synthesis. + */ + readonly pluginReports: PluginReportJson[]; +} + +/** + * A report from a single validation plugin. + */ +export interface PluginReportJson { + /** + * The name of the plugin that produced this report. + */ + readonly pluginName: string; + + /** + * Version of the plugin that produced this report. + * + * @default - no version + */ + readonly pluginVersion?: string; + + /** + * Whether the plugin's validation passed or failed. + */ + readonly conclusion: PolicyValidationReportConclusion; + + /** + * Additional plugin-specific metadata. + * + * @default - no metadata + */ + readonly metadata?: { readonly [key: string]: string }; + + /** + * Violations found by this plugin. + */ + readonly violations: PolicyViolationJson[]; +} + +/** + * The final conclusion of a validation report. + */ +export type PolicyValidationReportConclusion = 'success' | 'failure'; + +/** + * A single policy violation found by a validation plugin. + */ +export interface PolicyViolationJson { + /** + * The name of the rule that was violated. + */ + readonly ruleName: string; + + /** + * A description of the violation. + */ + readonly description: string; + + /** + * How to fix the violation. + * + * @default - no fix provided + */ + readonly suggestedFix?: string; + + /** + * The severity of the violation. + */ + readonly severity: PolicyViolationSeverity; + + /** + * If the plugin wants to report using a non-standard severity, put it here + */ + readonly customSeverity?: string; + + /** + * Additional rule-specific metadata. + * + * @default - no metadata + */ + readonly ruleMetadata?: { readonly [key: string]: string }; + + /** + * Constructs that violated the rule. + */ + readonly violatingConstructs: ViolatingConstructJson[]; +} + +/** + * The severity of a policy violation. + * + * If you need to use a severity level that doesn't exist as a static member, + * use `custom`. + */ +export type PolicyViolationSeverity = 'fatal' | 'error' | 'warning' | 'info' | 'custom'; + +/** + * A construct that violated a policy rule. + */ +export interface ViolatingConstructJson { + /** + * The construct path as defined in the application. + * + * @default - no construct path + */ + readonly constructPath: string; + + /** + * The fully qualified name of the construct class (includes the library name) + * + * @default - no construct info + */ + readonly constructFqn?: string; + + /** + * The version of the library that contains this construct. + * + * The library name is the first component of the construct FQN. + * + * @default - no version info + */ + readonly libraryVersion?: string; + + /** + * If this construct violation regards a CloudFormation resource, a reference to the resource details + */ + readonly cloudFormationResource?: CloudFormationResourceJson; + + /** + * Stack traces associated with this violation + * + * This can be all the stack traces where a violating property got its value, + * or just the construct creation stack trace. + * + * Every element of the array is a stack trace, where each stack trace is a `\n`-delimited string. + * + * @default - No stack traces + */ + readonly stackTraces?: string[]; +} + +/** + * A node in the construct creation stack trace. + */ +export interface CloudFormationResourceJson { + /** + * The path to the CloudFormation template containing this resource. + */ + readonly templatePath: string; + + /** + * The logical ID of the resource in the CloudFormation template. + */ + readonly logicalId: string; + + /** + * Properties within the construct where the violation was detected. + * + * Either a single component, in which case it regards a top-level property + * name, or a JSON path (starting with `$.`) to indicate a deeper property. + * + * @default - no locations + */ + readonly propertyPaths?: string[]; +} diff --git a/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts b/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts index d61920f52..048c7020a 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts +++ b/packages/@aws-cdk/cloud-assembly-schema/lib/manifest.ts @@ -3,6 +3,7 @@ import * as jsonschema from 'jsonschema'; import * as semver from 'semver'; import type * as assets from './assets'; import * as assembly from './cloud-assembly'; +import type * as validation from './cloud-assembly/validation-report-schema'; import type * as integ from './integ-tests'; /* eslint-disable @typescript-eslint/no-var-requires */ @@ -28,6 +29,8 @@ import ASSEMBLY_SCHEMA = require('../schema/cloud-assembly.schema.json'); import INTEG_SCHEMA = require('../schema/integ.schema.json'); +import VALIDATION_REPORT_SCHEMA = require('../schema/validation-report.schema.json'); + /** * Version is shared for both manifests */ @@ -153,6 +156,15 @@ export abstract class Manifest { }; } + /** + * Load and validate the policy validation report from file. + * + * @param filePath - path to the validation report file. + */ + public static loadValidationReport(filePath: string): validation.PolicyValidationReportJson { + return Manifest.loadManifest(filePath, VALIDATION_REPORT_SCHEMA); + } + /** * Fetch the current schema version number. */ @@ -188,7 +200,7 @@ export abstract class Manifest { manifest: any, schema: jsonschema.Schema, options?: LoadManifestOptions, - ): asserts manifest is assembly.AssemblyManifest { + ): void { function parseVersion(version: string) { const ver = semver.valid(version); if (!ver) { diff --git a/packages/@aws-cdk/cloud-assembly-schema/schema/validation-report.schema.json b/packages/@aws-cdk/cloud-assembly-schema/schema/validation-report.schema.json new file mode 100644 index 000000000..26b0d91b2 --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-schema/schema/validation-report.schema.json @@ -0,0 +1,163 @@ +{ + "$ref": "#/definitions/PolicyValidationReportJson", + "definitions": { + "PolicyValidationReportJson": { + "description": "The top-level structure of the policy validation report file.", + "type": "object", + "properties": { + "version": { + "description": "Protocol version", + "type": "string" + }, + "title": { + "description": "Report title, if present.", + "type": "string" + }, + "pluginReports": { + "description": "Reports from all validation plugins that ran during synthesis.", + "type": "array", + "items": { + "$ref": "#/definitions/PluginReportJson" + } + } + }, + "required": ["version", "pluginReports"] + }, + "PluginReportJson": { + "description": "A report from a single validation plugin.", + "type": "object", + "properties": { + "pluginName": { + "description": "The name of the plugin that produced this report.", + "type": "string" + }, + "pluginVersion": { + "description": "Version of the plugin that produced this report.", + "type": "string" + }, + "conclusion": { + "description": "Whether the plugin's validation passed or failed.", + "$ref": "#/definitions/PolicyValidationReportConclusion" + }, + "metadata": { + "description": "Additional plugin-specific metadata.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "violations": { + "description": "Violations found by this plugin.", + "type": "array", + "items": { + "$ref": "#/definitions/PolicyViolationJson" + } + } + }, + "required": ["pluginName", "conclusion", "violations"] + }, + "PolicyValidationReportConclusion": { + "description": "The final conclusion of a validation report.", + "type": "string", + "enum": ["success", "failure"] + }, + "PolicyViolationJson": { + "description": "A single policy violation found by a validation plugin.", + "type": "object", + "properties": { + "ruleName": { + "description": "The name of the rule that was violated.", + "type": "string" + }, + "description": { + "description": "A description of the violation.", + "type": "string" + }, + "suggestedFix": { + "description": "How to fix the violation.", + "type": "string" + }, + "severity": { + "description": "The severity of the violation.", + "$ref": "#/definitions/PolicyViolationSeverity" + }, + "customSeverity": { + "description": "If the plugin wants to report using a non-standard severity, put it here.", + "type": "string" + }, + "ruleMetadata": { + "description": "Additional rule-specific metadata.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "violatingConstructs": { + "description": "Constructs that violated the rule.", + "type": "array", + "items": { + "$ref": "#/definitions/ViolatingConstructJson" + } + } + }, + "required": ["ruleName", "description", "severity", "violatingConstructs"] + }, + "PolicyViolationSeverity": { + "description": "The severity of a policy violation.", + "type": "string", + "enum": ["fatal", "error", "warning", "info", "custom"] + }, + "ViolatingConstructJson": { + "description": "A construct that violated a policy rule.", + "type": "object", + "properties": { + "constructPath": { + "description": "The construct path as defined in the application.", + "type": "string" + }, + "constructFqn": { + "description": "The fully qualified name of the construct class (includes the library name).", + "type": "string" + }, + "libraryVersion": { + "description": "The version of the library that contains this construct.", + "type": "string" + }, + "cloudFormationResource": { + "description": "If this construct violation regards a CloudFormation resource, a reference to the resource details.", + "$ref": "#/definitions/CloudFormationResourceJson" + }, + "stackTraces": { + "description": "Stack traces associated with this violation.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["constructPath"] + }, + "CloudFormationResourceJson": { + "description": "CloudFormation resource details for a violating construct.", + "type": "object", + "properties": { + "templatePath": { + "description": "The path to the CloudFormation template containing this resource.", + "type": "string" + }, + "logicalId": { + "description": "The logical ID of the resource in the CloudFormation template.", + "type": "string" + }, + "propertyPaths": { + "description": "Properties within the construct where the violation was detected.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["templatePath", "logicalId"] + } + } +} diff --git a/packages/@aws-cdk/cloud-assembly-schema/test/__snapshots__/manifest.test.ts.snap b/packages/@aws-cdk/cloud-assembly-schema/test/__snapshots__/manifest.test.ts.snap index 14a8ff803..eba1ebdd6 100644 --- a/packages/@aws-cdk/cloud-assembly-schema/test/__snapshots__/manifest.test.ts.snap +++ b/packages/@aws-cdk/cloud-assembly-schema/test/__snapshots__/manifest.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`manifest load 1`] = ` { diff --git a/packages/@aws-cdk/cloud-assembly-schema/test/validation-report.test.ts b/packages/@aws-cdk/cloud-assembly-schema/test/validation-report.test.ts new file mode 100644 index 000000000..3c1f27ada --- /dev/null +++ b/packages/@aws-cdk/cloud-assembly-schema/test/validation-report.test.ts @@ -0,0 +1,110 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Manifest } from '../lib/manifest'; + +describe('Manifest.loadValidationReport', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validation-report-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('loads a valid report', () => { + const reportPath = path.join(tmpDir, 'policy-validation-report.json'); + fs.writeFileSync(reportPath, JSON.stringify({ + version: '1.0.0', + pluginReports: [{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'no-public-buckets', + description: 'S3 Buckets must not be publicly accessible', + severity: 'error', + violatingConstructs: [{ + constructPath: 'MyStack/MyBucket/Resource', + cloudFormationResource: { + templatePath: 'MyStack.template.json', + logicalId: 'MyBucketF68F3FF0', + }, + }], + }], + }], + })); + + const report = Manifest.loadValidationReport(reportPath); + + expect(report.pluginReports).toHaveLength(1); + expect(report.pluginReports[0].pluginName).toBe('TestPlugin'); + expect(report.pluginReports[0].conclusion).toBe('failure'); + expect(report.pluginReports[0].violations[0].ruleName).toBe('no-public-buckets'); + expect(report.pluginReports[0].violations[0].violatingConstructs[0].constructPath).toBe('MyStack/MyBucket/Resource'); + }); + + test('loads a report with optional fields', () => { + const reportPath = path.join(tmpDir, 'policy-validation-report.json'); + fs.writeFileSync(reportPath, JSON.stringify({ + version: '1.0.0', + title: 'Validation Report', + pluginReports: [{ + pluginName: 'TestPlugin', + pluginVersion: '1.0.0', + conclusion: 'success', + metadata: { environment: 'production' }, + violations: [], + }], + })); + + const report = Manifest.loadValidationReport(reportPath); + + expect(report.title).toBe('Validation Report'); + expect(report.pluginReports[0].pluginVersion).toBe('1.0.0'); + expect(report.pluginReports[0].metadata).toEqual({ environment: 'production' }); + }); + + test('throws on missing required fields', () => { + const reportPath = path.join(tmpDir, 'policy-validation-report.json'); + fs.writeFileSync(reportPath, JSON.stringify({ + title: 'Validation Report', + })); + + expect(() => Manifest.loadValidationReport(reportPath)).toThrow(); + }); + + test('throws on invalid conclusion value', () => { + const reportPath = path.join(tmpDir, 'policy-validation-report.json'); + fs.writeFileSync(reportPath, JSON.stringify({ + version: '1.0.0', + pluginReports: [{ + pluginName: 'TestPlugin', + conclusion: 'maybe', + violations: [], + }], + })); + + expect(() => Manifest.loadValidationReport(reportPath)).toThrow(); + }); + + test('throws on invalid severity value', () => { + const reportPath = path.join(tmpDir, 'policy-validation-report.json'); + fs.writeFileSync(reportPath, JSON.stringify({ + version: '1.0.0', + pluginReports: [{ + pluginName: 'TestPlugin', + conclusion: 'failure', + violations: [{ + ruleName: 'test-rule', + description: 'test', + severity: 'not-a-severity', + violatingConstructs: [], + }], + }], + })); + + expect(() => Manifest.loadValidationReport(reportPath)).toThrow(); + }); +});