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-online-app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js new file mode 100644 index 000000000..e29b80c96 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/app.js @@ -0,0 +1,83 @@ +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 = '1.0.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: 'error', + violatingResources: [{ + resourceLogicalId: logicalId, + templatePath, + locations: [`/Resources/${logicalId}/Properties/PublicAccessBlockConfiguration`], + }], + }); + } + } + } + return { success: violations.length === 0, violations }; + } +} + +const app = new cdk.App(); +cdk.Validations.of(app).addPlugins(new SecurityPlugin()); + +// Valid stack — no offline or online errors +class ValidStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new cdk.CfnResource(this, 'WaitHandle', { + type: 'AWS::CloudFormation::WaitConditionHandle', + }); + } +} + +// Invalid stack (online only) — CFN rejects the resource type +class OnlineInvalidStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new cdk.CfnResource(this, 'BadResource', { + type: 'AWS::Fake::DoesNotExist', + properties: { SomeProperty: 'value' }, + }); + } +} + +// Combined stack — has BOTH offline (S3 bucket triggers SecurityPlugin) +// AND online errors (invalid resource type rejected by CFN) +class CombinedStack extends cdk.Stack { + constructor(scope, id, props) { + super(scope, id, props); + new s3.Bucket(this, 'MyBucket', { + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + new cdk.CfnResource(this, 'BadResource', { + type: 'AWS::Fake::DoesNotExist', + properties: { SomeProperty: 'value' }, + }); + } +} + +new ValidStack(app, `${stackPrefix}-validate-online-valid`); +new OnlineInvalidStack(app, `${stackPrefix}-validate-online-invalid`); +new CombinedStack(app, `${stackPrefix}-validate-online-combined`); + +app.synth(); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json new file mode 100644 index 000000000..791696b03 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/validate-online-app/cdk.json @@ -0,0 +1,7 @@ +{ + "app": "node app.js", + "versionReporting": false, + "context": { + "@aws-cdk/core:failSynthOnValidationErrors": false + } +} 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-online/cdk-validate-no-online-skips-cfn.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-no-online-skips-cfn.integtest.ts new file mode 100644 index 000000000..eb684c774 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-no-online-skips-cfn.integtest.ts @@ -0,0 +1,14 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --no-online skips CloudFormation validation', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--no-online', fixture.fullStackName('validate-online-invalid')], + ); + + // With --no-online, the invalid resource type should NOT be caught + expect(output).not.toContain('AWS::Fake::DoesNotExist'); + expect(output).not.toContain('CloudFormation'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts new file mode 100644 index 000000000..3ddb77a7e --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-catches-invalid-resource.integtest.ts @@ -0,0 +1,16 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --online catches invalid resource type', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-invalid')], + { + allowErrExit: true, + }, + ); + + expect(output).toContain('CloudFormation'); + expect(output).toContain('AWS::Fake::DoesNotExist'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts new file mode 100644 index 000000000..a62e7b5e5 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-combined-offline-and-online.integtest.ts @@ -0,0 +1,21 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --online reports both offline and online errors', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-combined')], + { + allowErrExit: true, + }, + ); + + // Offline: SecurityPlugin catches the S3 bucket + expect(output).toContain('S3 Buckets must not be publicly accessible'); + expect(output).toContain('SecurityPlugin'); + + // Online: CloudFormation rejects the fake resource type + expect(output).toContain('AWS::Fake::DoesNotExist'); + expect(output).toContain('CloudFormation'); + }), +); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts new file mode 100644 index 000000000..243151cb4 --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/validate-online/cdk-validate-online-passes-valid-template.integtest.ts @@ -0,0 +1,12 @@ +import { integTest, withSpecificFixture } from '../../../lib'; + +integTest( + 'cdk validate --online passes for valid template', + withSpecificFixture('validate-online-app', async (fixture) => { + const output = await fixture.cdk( + ['--unstable=validate', 'validate', '--online', fixture.fullStackName('validate-online-valid')], + ); + + expect(output).toContain('No violations found'); + }), +); 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/actions/validate/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts index 93f4b2272..ee4f3a09d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/validate/index.ts @@ -6,6 +6,17 @@ export interface ValidateOptions { * Select the stacks to validate */ readonly stacks?: StackSelector; + + /** + * Submit templates to CloudFormation for early validation. + * + * Creates a non-executing change set per stack and reports any + * early validation errors (invalid resource types, property validation, name conflicts). + * Requires AWS credentials. + * + * @default true + */ + readonly online?: boolean; } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts index 9a541db93..4e4655c0f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/cfn-api.ts @@ -18,9 +18,9 @@ import { changeSetNameFromArn, stackNameFromArn } from '../../util/cloudformatio import type { ICloudFormationClient, SdkProvider } from '../aws-auth/private'; import type { Template, TemplateBodyParameter, TemplateParameter } from '../cloudformation'; import { CloudFormationStack, makeBodyParameter } from '../cloudformation'; +import type { StackDiagnosis } from '../../actions/diagnose'; import { throwDeploymentErrorFromDiagnosis } from '../diagnosing/diagnosis-formatting'; import { CloudFormationStackDiagnoser } from '../diagnosing/stack-diagnoser'; -import type { TargetEnvironment } from '../environment'; import type { IoHelper } from '../io/private'; import type { ResourcesToImport } from '../resource-import'; import { StackArtifactSourceTracer } from '../source-tracing/private/stack-source-tracing'; @@ -89,26 +89,21 @@ async function waitFor( } } +export interface ChangeSetReport { + readonly description: DescribeChangeSetCommandOutput; + readonly diagnosis: StackDiagnosis; +} + /** - * Waits for a ChangeSet to be available for triggering a StackUpdate. - * - * Will return a changeset that is either ready to be executed or has no changes. - * Will throw in other cases. - * - * @param cfn - a CloudFormation client - * @param stackNameOrArn - the name or ARN of the Stack that the ChangeSet belongs to, prefer passing an ARN if available - * @param changeSetNameOrArn - the name or ARN of the ChangeSet, prefer passing an ARN if available - * @param fetchAll - if true, fetches all pages of the ChangeSet before returning. - * - * @returns the CloudFormation description of the ChangeSet + * Waits for a ChangeSet to reach a terminal state and returns the diagnosis without throwing. */ -export async function waitForChangeSet( +export async function waitForChangeSetReport( cfn: ICloudFormationClient, ioHelper: IoHelper, stackNameOrArn: string, changeSetNameOrArn: string, { fetchAll, diagnoser }: { fetchAll: boolean; diagnoser: CloudFormationStackDiagnoser }, -): Promise { +): Promise { const stackDisplayName = stackNameFromArn(stackNameOrArn); const changeSetDisplayName = changeSetNameFromArn(changeSetNameOrArn); await ioHelper.defaults.debug(format('Waiting for changeset %s on stack %s to finish creating...', changeSetDisplayName, stackDisplayName)); @@ -117,19 +112,13 @@ export async function waitForChangeSet( fetchAll, }); - // The following doesn't use a switch because tsc will not allow fall-through, UNLESS it is allows - // EVERYWHERE that uses this library directly or indirectly, which is undesirable. if (description.Status === 'CREATE_PENDING' || description.Status === 'CREATE_IN_PROGRESS') { await ioHelper.defaults.debug(format('Changeset %s on stack %s is still creating', changeSetDisplayName, stackDisplayName)); return undefined; } - const diag = await diagnoser.diagnoseChangeSet(description); - if (diag.type === 'no-problem') { - return description; - } - - throwDeploymentErrorFromDiagnosis(diag); + const diagnosis = await diagnoser.diagnoseChangeSet(description); + return { description, diagnosis }; }); if (!ret) { @@ -139,6 +128,32 @@ export async function waitForChangeSet( return ret; } +/** + * Waits for a ChangeSet to be available for triggering a StackUpdate. + * + * Will return a changeset that is either ready to be executed or has no changes. + * Will throw in other cases. + * + * @param cfn a CloudFormation client + * @param stackNameOrArn the name or ARN of the Stack the ChangeSet belongs to, prefer ARN + * @param changeSetNameOrArn the name or ARN of the ChangeSet, prefer ARN + * @param fetchAll if true, fetches all pages of the ChangeSet before returning. + * @returns the CloudFormation description of the ChangeSet + */ +export async function waitForChangeSet( + cfn: ICloudFormationClient, + ioHelper: IoHelper, + stackNameOrArn: string, + changeSetNameOrArn: string, + { fetchAll, diagnoser }: { fetchAll: boolean; diagnoser: CloudFormationStackDiagnoser }, +): Promise { + const { description, diagnosis } = await waitForChangeSetReport(cfn, ioHelper, stackNameOrArn, changeSetNameOrArn, { fetchAll, diagnoser }); + if (diagnosis.type !== 'no-problem') { + throwDeploymentErrorFromDiagnosis(diagnosis); + } + return description; +} + export async function waitForChangeSetGone( cfn: ICloudFormationClient, ioHelper: IoHelper, @@ -212,8 +227,7 @@ export async function createDiffChangeSet( options: Omit, ): Promise { try { - const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); - return await uploadBodyParameterAndCreateChangeSet(ioHelper, env, { + return await uploadBodyParameterAndCreateChangeSet(ioHelper, { ...options, includeNestedStacks: true, }); @@ -258,14 +272,19 @@ function templatesFromAssetManifestArtifact( return [assetManifest, assets]; } -/** - * Only ever called for 'cdk diff' - */ -async function uploadBodyParameterAndCreateChangeSet( +interface PreparedChangeSetEnv { + cfn: ICloudFormationClient; + bodyParameter: TemplateBodyParameter; + exists: boolean; + executionRoleArn: string | undefined; + diagnoser: CloudFormationStackDiagnoser; +} + +async function prepareChangeSetEnv( ioHelper: IoHelper, - env: TargetEnvironment, - options: PrepareChangeSetOptions, -): Promise { + options: { stack: cxapi.CloudFormationStackArtifact; deployments: Deployments }, +): Promise { + const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack); await uploadStackTemplateAssets(options.stack, options.deployments); const bodyParameter = await makeBodyParameter( ioHelper, @@ -279,8 +298,24 @@ async function uploadBodyParameterAndCreateChangeSet( // A stack in REVIEW_IN_PROGRESS was created by a previous CREATE changeset // that was never executed. Treat it as non-existent for changeset purposes. const exists = stack.exists && stack.stackStatus.name !== 'REVIEW_IN_PROGRESS' && stack.stackStatus.name !== 'DELETE_IN_PROGRESS'; - const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn); + const diagnoser = new CloudFormationStackDiagnoser({ + sdk: env.sdk, + envResources: env.resources, + sourceTracer: new StackArtifactSourceTracer(options.stack), + ioHelper, + topLevelStackHierarchicalId: options.stack.hierarchicalId, + }); + + return { cfn, bodyParameter, exists, executionRoleArn, diagnoser }; +} + +async function uploadBodyParameterAndCreateChangeSet( + ioHelper: IoHelper, + options: PrepareChangeSetOptions, +): Promise { + const { cfn, bodyParameter, exists, executionRoleArn, diagnoser } = await prepareChangeSetEnv(ioHelper, options); + await ioHelper.defaults.info( 'Hold on while we create a read-only change set to get a diff with accurate replacement information (use --method=template to use a less accurate but faster template-only diff)\n', ); @@ -298,13 +333,7 @@ async function uploadBodyParameterAndCreateChangeSet( importExistingResources: options.importExistingResources, includeNestedStacks: options.includeNestedStacks, role: executionRoleArn, - diagnoser: new CloudFormationStackDiagnoser({ - sdk: env.sdk, - envResources: env.resources, - sourceTracer: new StackArtifactSourceTracer(options.stack), - ioHelper, - topLevelStackHierarchicalId: options.stack.hierarchicalId, - }), + diagnoser, }); } @@ -398,6 +427,60 @@ async function createChangeSetAndCleanup( return createdChangeSet; } +/** + * Create a change set for online validation (never executes, returns diagnosis instead of throwing). + * + * Uses the same env preparation as diff, but calls `waitForChangeSetReport` to return + * the diagnosis rather than throwing on failure. Always cleans up the change set afterwards. + */ +export async function createValidationChangeSet( + ioHelper: IoHelper, + options: Omit, +): Promise { + const { cfn, bodyParameter, exists, executionRoleArn, diagnoser } = await prepareChangeSetEnv(ioHelper, options); + const changeSetName = 'cdk-validate-change-set'; + + if (exists) { + await cleanupOldChangeset(cfn, ioHelper, changeSetName, options.stack.stackName); + } + + const templateParams = TemplateParameters.fromTemplate(options.stack.template); + const stackParams = templateParams.supplyAll(options.parameters); + + const changeSet = await cfn.createChangeSet({ + StackName: options.stack.stackName, + ChangeSetName: changeSetName, + ChangeSetType: exists ? 'UPDATE' : 'CREATE', + Description: `CDK Changeset for validation ${options.uuid}`, + ClientToken: `validate${options.uuid}`, + TemplateURL: bodyParameter.TemplateURL, + TemplateBody: bodyParameter.TemplateBody, + Parameters: stackParams.apiParameters, + IncludeNestedStacks: true, + RoleARN: executionRoleArn, + Tags: toCfnTags(options.stack.tags), + Capabilities: ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', 'CAPABILITY_AUTO_EXPAND'], + }); + + const report = await waitForChangeSetReport( + cfn, ioHelper, + changeSet.StackId ?? options.stack.stackName, + changeSet.Id ?? changeSetName, + { fetchAll: false, diagnoser }, + ); + + await cleanupOldChangeset(cfn, ioHelper, changeSet.Id ?? changeSetName, changeSet.StackId ?? options.stack.stackName); + + if (!exists) { + await cfn.deleteStack({ + StackName: changeSet.StackId ?? options.stack.stackName, + ClientRequestToken: randomUUID(), + }); + } + + return report; +} + function toCfnTags(tags: { [id: string]: string }): Tag[] { return Object.entries(tags).map(([k, v]) => ({ Key: k, 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..f6b8397c0 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/validate/validate-formatting.ts @@ -0,0 +1,138 @@ +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 'Policy validation passed. No violations 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 blocks = violations.map((v) => formatViolationBlock(v)); + return 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 2be20f742..d58919725 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -1,7 +1,8 @@ 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 { randomUUID } from 'node:crypto'; +import type { FeatureFlagReportProperties, PolicyValidationReportConclusion, PluginReportJson } 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 +58,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'; @@ -79,6 +80,7 @@ import { AsyncDisposableBox } from '../api/cloud-assembly/private/disposable-box import { CloudAssemblySourceBuilder } from '../api/cloud-assembly/source-builder'; import type { StackCollection } from '../api/cloud-assembly/stack-collection'; import { Deployments } from '../api/deployments'; +import { createValidationChangeSet } from '../api/deployments/cfn-api'; import { hostMessageFromDiagnosis } from '../api/diagnosing/diagnosis-formatting'; import { CloudFormationStackDiagnoser } from '../api/diagnosing/stack-diagnoser'; import { DiffFormatter } from '../api/diff'; @@ -89,6 +91,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 +117,7 @@ import { pLimit } from '../util/concurrency'; import { createIgnoreMatcher } from '../util/glob-matcher'; import { promiseWithResolvers } from '../util/promises'; -const POLICY_VALIDATION_REPORT_FILE = 'policy-validation-report.json'; +const VALIDATION_REPORT_FILE = 'validation-report.json'; export interface ToolkitOptions { /** @@ -181,7 +184,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,38 +666,106 @@ 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 pluginReports: PluginReportJson[] = []; + let title: string | undefined; - if (!await fs.pathExists(reportPath)) { - const result: ValidateResult = { - conclusion: 'success', - pluginReports: [], - }; + // Offline validation: read the policy validation report from the cloud assembly + const reportPath = path.join(assembly.directory, VALIDATION_REPORT_FILE); + if (await fs.pathExists(reportPath)) { + const report = Manifest.loadValidationReport(reportPath); + title = report.title; + pluginReports.push(...report.pluginReports); + } else if (options.online === false) { await ioHelper.notify(IO.CDK_TOOLKIT_I9601.msg('No validation plugins configured. Add a plugin to your CDK app to enable validation.')); - return result; } - const report = Manifest.loadValidationReport(reportPath); + // Online validation: submit templates to CloudFormation for early validation + if (options.online ?? true) { + const stacks = await assembly.selectStacksV2(selectStacks); + const deployments = await this.deploymentsForAction('validate'); + + const onlineReport = await this.validateOnline(ioHelper, stacks, deployments); + if (onlineReport) { + pluginReports.push(onlineReport); + } + } + + if (pluginReports.length === 0) { + await ioHelper.notify(IO.CDK_TOOLKIT_I9601.msg('No validation plugins configured. Add a plugin to your CDK app to enable validation.')); + } - const conclusion: PolicyValidationReportConclusion = report.pluginReports.some( + const conclusion: PolicyValidationReportConclusion = pluginReports.some( (pr) => pr.conclusion === 'failure', ) ? 'failure' : 'success'; const result: ValidateResult = { conclusion, - title: report.title, - pluginReports: report.pluginReports, + title, + 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; } + private async validateOnline( + ioHelper: IoHelper, + stacks: StackCollection, + deployments: Deployments, + ): Promise { + const violations: PluginReportJson['violations'] = []; + + for (const stack of stacks.stackArtifacts) { + try { + const report = await createValidationChangeSet(ioHelper, { + deployments, + stack, + parameters: {}, + uuid: randomUUID(), + willExecute: false, + failOnError: true, + }); + + if (report.diagnosis.type === 'problem') { + for (const problem of report.diagnosis.problems) { + violations.push({ + ruleName: problem.errorCode ?? 'CloudFormationValidation', + description: problem.message, + severity: 'fatal', + violatingConstructs: [{ + constructPath: problem.logicalId ? `${stack.hierarchicalId}/${problem.logicalId}` : stack.hierarchicalId, + cloudFormationResource: problem.logicalId ? { + templatePath: `${stack.stackName}.template.json`, + logicalId: problem.logicalId, + } : undefined, + }], + }); + } + } + } catch (e: any) { + violations.push({ + ruleName: 'CloudFormationValidation', + description: e.message, + severity: 'fatal', + violatingConstructs: [{ + constructPath: stack.hierarchicalId, + }], + }); + } + } + + if (violations.length === 0) { + return undefined; + } + + return { + pluginName: 'CloudFormation', + conclusion: 'failure', + violations, + }; + } + /** * Deploy Action * diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-malformed-validation-report/cdk.out/validation-report.json 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/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-no-title-validation/cdk.out/validation-report.json diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-passing-validation/cdk.out/validation-report.json diff --git a/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json b/packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/validation-report.json similarity index 100% rename from packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/policy-validation-report.json rename to packages/@aws-cdk/toolkit-lib/test/_fixtures/stack-with-validation-report/cdk.out/validation-report.json 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..6f1682d47 100644 --- a/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/actions/validate.test.ts @@ -1,11 +1,17 @@ import { StackSelectionStrategy } from '../../lib/api/cloud-assembly'; +import * as awsauth from '../../lib/api/aws-auth/private'; +import * as cfnApi from '../../lib/api/deployments/cfn-api'; import { Toolkit } from '../../lib/toolkit'; import { cdkOutFixture, TestIoHost } from '../_helpers'; +import { MockSdk, restoreSdkMocksToDefault, setDefaultSTSMocks } from '../_helpers/mock-sdk'; let ioHost: TestIoHost; let toolkit: Toolkit; beforeEach(() => { + jest.restoreAllMocks(); + restoreSdkMocksToDefault(); + setDefaultSTSMocks(); ioHost = new TestIoHost(); toolkit = new Toolkit({ ioHost }); }); @@ -13,7 +19,7 @@ beforeEach(() => { describe('validate', () => { test('returns failure when report contains failing plugin', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('failure'); expect(result.title).toBe('Validation Report'); @@ -27,7 +33,7 @@ describe('validate', () => { test('returns success when all plugins pass', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-passing-validation'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('success'); expect(result.pluginReports).toHaveLength(1); @@ -37,30 +43,23 @@ describe('validate', () => { test('returns success with no reports when no report file exists', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('success'); expect(result.pluginReports).toHaveLength(0); 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); + await toolkit.validate(cx, { online: false }); - ioHost.expectMessage({ containing: 'No problems found', level: 'info' }); + ioHost.expectMessage({ containing: 'No violations found', level: 'info' }); }); test('can invoke without options', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('success'); }); @@ -69,6 +68,7 @@ describe('validate', () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); const result = await toolkit.validate(cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + online: false, }); expect(result.conclusion).toBe('failure'); @@ -76,7 +76,7 @@ describe('validate', () => { test('parses violation details correctly', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); const violation = result.pluginReports[0].violations[0]; expect(violation.severity).toBe('error'); @@ -89,15 +89,21 @@ describe('validate', () => { test('includes plugin version in report', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.pluginReports[0].pluginVersion).toBe('1.0.0'); 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, { online: false })).rejects.toThrow(); + }); + test('parses stack traces correctly', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); const construct = result.pluginReports[0].violations[0].violatingConstructs[0]; expect(construct.stackTraces).toBeDefined(); @@ -107,13 +113,13 @@ describe('validate', () => { test('IO message payload contains full ValidateResult', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); - await toolkit.validate(cx); + await toolkit.validate(cx, { online: false }); - 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([ @@ -127,7 +133,7 @@ describe('validate', () => { test('handles report with missing title field', async () => { const cx = await cdkOutFixture(toolkit, 'stack-with-no-title-validation'); - const result = await toolkit.validate(cx); + const result = await toolkit.validate(cx, { online: false }); expect(result.conclusion).toBe('failure'); expect(result.title).toBeUndefined(); @@ -135,3 +141,101 @@ describe('validate', () => { expect(result.pluginReports[0].violations[0].ruleName).toBe('no-public-buckets'); }); }); + +describe('validate --online', () => { + beforeEach(() => { + jest.spyOn(awsauth.SdkProvider.prototype, '_makeSdk').mockReturnValue(new MockSdk()); + }); + + test('reports CloudFormation validation errors as a plugin report', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockResolvedValue({ + description: { $metadata: {} } as any, + diagnosis: { + type: 'problem', + detectedBy: { type: 'early-validation', changeSetName: 'cdk-validate-change-set' }, + problems: [ + { + stackArn: 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1', + topLevelStackHierarchicalId: 'Stack1', + parentStackLogicalIds: [], + logicalId: 'BadResource', + resourceType: 'AWS::Fake::DoesNotExist', + message: 'Resource type AWS::Fake::DoesNotExist does not exist', + errorCode: 'InvalidResourceType', + sourceTrace: undefined, + }, + ], + }, + }); + + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('failure'); + expect(result.pluginReports).toHaveLength(1); + expect(result.pluginReports[0].pluginName).toBe('CloudFormation'); + expect(result.pluginReports[0].conclusion).toBe('failure'); + expect(result.pluginReports[0].violations).toHaveLength(1); + expect(result.pluginReports[0].violations[0].ruleName).toBe('InvalidResourceType'); + expect(result.pluginReports[0].violations[0].description).toBe('Resource type AWS::Fake::DoesNotExist does not exist'); + expect(result.pluginReports[0].violations[0].violatingConstructs[0].cloudFormationResource?.logicalId).toBe('BadResource'); + }); + + test('passes when CloudFormation finds no problems', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockResolvedValue({ + description: { $metadata: {} } as any, + diagnosis: { type: 'no-problem' }, + }); + + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('success'); + expect(result.pluginReports).toHaveLength(0); + }); + + test('merges offline and online results', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockResolvedValue({ + description: { $metadata: {} } as any, + diagnosis: { + type: 'problem', + detectedBy: { type: 'early-validation', changeSetName: 'cdk-validate-change-set' }, + problems: [ + { + stackArn: 'arn:aws:cloudformation:us-east-1:123456789012:stack/Stack1', + topLevelStackHierarchicalId: 'Stack1', + parentStackLogicalIds: [], + logicalId: 'MyBucket', + message: 'Property validation failure', + errorCode: 'InvalidProperty', + sourceTrace: undefined, + }, + ], + }, + }); + + const cx = await cdkOutFixture(toolkit, 'stack-with-validation-report'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('failure'); + // 2 from offline report + 1 from online + expect(result.pluginReports).toHaveLength(3); + expect(result.pluginReports[0].pluginName).toBe('TestPlugin'); + expect(result.pluginReports[1].pluginName).toBe('Construct Annotations'); + expect(result.pluginReports[2].pluginName).toBe('CloudFormation'); + }); + + test('reports thrown errors as violations', async () => { + jest.spyOn(cfnApi, 'createValidationChangeSet').mockRejectedValue( + new Error('Template format error: Unrecognized resource types: [AWS::Fake::DoesNotExist]'), + ); + + const cx = await cdkOutFixture(toolkit, 'stack-with-bucket'); + const result = await toolkit.validate(cx, { online: true }); + + expect(result.conclusion).toBe('failure'); + expect(result.pluginReports).toHaveLength(1); + expect(result.pluginReports[0].pluginName).toBe('CloudFormation'); + expect(result.pluginReports[0].violations[0].description).toContain('Unrecognized resource types'); + }); +}); 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..2a3dfb94e --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/validate/validate-formatting.test.ts @@ -0,0 +1,170 @@ +import * as chalk from 'chalk'; +import { formatValidateResult } from '../../../lib/api/validate/validate-formatting'; +import type { ValidateResult } from '../../../lib/actions/validate'; + +// Disable chalk for predictable assertions +chalk.level = 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)).toBe('Policy validation passed. No violations 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'); + expect(lines[0]).toContain('fatal issue'); + expect(lines[1]).toContain('error issue'); + expect(lines[2]).toContain('warning issue'); + expect(lines[3]).toContain('info issue'); + expect(lines[4]).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 9c39a5083..e326b987e 100644 --- a/packages/aws-cdk/lib/cli/cli-config.ts +++ b/packages/aws-cdk/lib/cli/cli-config.ts @@ -221,6 +221,16 @@ export async function makeConfig(): Promise { variadic: true, }, }, + 'validate': { + description: 'Validate synthesized CloudFormation templates against policy rules', + options: { + 'online': { type: 'boolean', desc: 'Submit templates to CloudFormation for early validation (requires AWS credentials)', default: true }, + }, + 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 fbab3f012..5d8fdd9cd 100644 --- a/packages/aws-cdk/lib/cli/cli-type-registry.json +++ b/packages/aws-cdk/lib/cli/cli-type-registry.json @@ -585,6 +585,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 173b2e193..275535f2a 100644 --- a/packages/aws-cdk/lib/cli/cli.ts +++ b/packages/aws-cdk/lib/cli/cli.ts @@ -443,6 +443,15 @@ 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 + .option('online', { + type: 'boolean', + desc: 'Submit templates to CloudFormation for early validation (requires AWS credentials)', + default: true, + }), + ) .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 afb41f997..8a8aa33ea 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 */ @@ -974,6 +979,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 fe8ef21d4..4c61e9530 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 @@ -287,12 +287,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