diff --git a/packages/integration-platform/src/manifests/__tests__/environment-classification.test.ts b/packages/integration-platform/src/manifests/__tests__/environment-classification.test.ts index 30fd3a947..5d134d58d 100644 --- a/packages/integration-platform/src/manifests/__tests__/environment-classification.test.ts +++ b/packages/integration-platform/src/manifests/__tests__/environment-classification.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'bun:test'; +import { parseEnvironmentAliases } from '../environment-aliases'; import { classifyEnvironment, + classifyEnvironmentWithAliases, confirmsEnvironmentSeparation, envTagValues, } from '../environment-classification'; @@ -145,3 +147,71 @@ describe('envTagValues — only env-key tags, case-insensitive', () => { expect(envTagValues(undefined)).toEqual([]); }); }); + +describe('environment aliases — customer naming conventions', () => { + it('keeps ambiguous names unclassified unless the customer maps them', () => { + expect(classifyEnvironment(['app-release'])).toBeNull(); + + const config = parseEnvironmentAliases({ + environment_aliases: 'release=production, preview=staging', + }); + + expect( + classifyEnvironmentWithAliases({ + candidates: ['app-release'], + aliases: config.aliases, + }), + ).toBe('production'); + expect( + classifyEnvironmentWithAliases({ + candidates: ['app-preview'], + aliases: config.aliases, + }), + ).toBe('staging'); + }); + + it('honors production qualifiers for mapped production aliases', () => { + const config = parseEnvironmentAliases({ + environment_aliases: 'release=production', + }); + + expect( + classifyEnvironmentWithAliases({ + candidates: ['app-pre-release'], + aliases: config.aliases, + }), + ).toBe('staging'); + expect( + classifyEnvironmentWithAliases({ + candidates: ['app-non-release'], + aliases: config.aliases, + }), + ).toBe('non-production'); + }); + + it('reports invalid alias entries instead of guessing', () => { + const config = parseEnvironmentAliases({ + environment_aliases: 'release=production, weird=customer, no-delimiter', + }); + + expect(config.aliases).toHaveLength(1); + expect(config.invalidEntries).toEqual(['weird=customer', 'no-delimiter']); + }); + + it('rejects duplicate or conflicting aliases instead of letting the last entry win', () => { + const config = parseEnvironmentAliases({ + environment_aliases: 'release=production, preview=staging, release=staging', + }); + + expect(config.aliases).toHaveLength(0); + expect(config.invalidEntries).toEqual( + expect.arrayContaining(['release=production', 'release=staging']), + ); + expect( + classifyEnvironmentWithAliases({ + candidates: ['app-release'], + aliases: config.aliases, + }), + ).toBeNull(); + }); +}); diff --git a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts index 481cf4dc9..0eb071334 100644 --- a/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts +++ b/packages/integration-platform/src/manifests/aws/checks/__tests__/aws-checks.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from 'bun:test'; +import type { CheckContext } from '../../../../types'; +import { parseEnvironmentAliases } from '../../../environment-aliases'; +import { awsManifest } from '../../index'; import { evaluateCloudTrail } from '../cloudtrail'; import { evaluateSecurityGroups } from '../ec2'; import { buildEnvironmentSeparationOutcomes, classifyVpcEnv, + classifyVpcEnvWithAliases, evaluateEnvironmentSeparation, } from '../environment-separation'; -import { - evaluateAccountSummary, - evaluateIamAccount, - evaluatePasswordPolicy, -} from '../iam'; +import { evaluateAccountSummary, evaluateIamAccount, evaluatePasswordPolicy } from '../iam'; import { evaluateKmsRotation } from '../kms'; import { evaluateRdsBackups, @@ -23,15 +23,14 @@ import { gatherBuckets } from '../s3-buckets'; import { assumeAwsSession, awsAccountIdFromCtx, + combineReadFailures, emitOutcomes, + remediationForReadFailure, resolveAwsCredentialInputs, resolveAwsSessionOrFail, - combineReadFailures, - remediationForReadFailure, toReadFailure, type CheckOutcome, } from '../shared'; -import type { CheckContext } from '../../../../types'; const kinds = (os: { kind: string }[]) => os.map((o) => o.kind); @@ -132,9 +131,7 @@ describe('AWS credential resolution (regions shape)', () => { }); it('returns null when roleArn or externalId is missing', () => { - expect( - resolveAwsCredentialInputs({ externalId: 'eid', regions: ['us-east-1'] }), - ).toBeNull(); + expect(resolveAwsCredentialInputs({ externalId: 'eid', regions: ['us-east-1'] })).toBeNull(); expect( resolveAwsCredentialInputs({ roleArn: base.roleArn, regions: ['us-east-1'] }), ).toBeNull(); @@ -172,7 +169,9 @@ describe('AWS IAM account evaluator', () => { expect(out[0]!.kind).toBe('fail'); expect(out[0]!.title).toMatch(/password policy/i); // and the summary evaluator is independent - expect(evaluateAccountSummary({ AccountMFAEnabled: 1, AccountAccessKeysPresent: 0 })).toHaveLength(2); + expect( + evaluateAccountSummary({ AccountMFAEnabled: 1, AccountAccessKeysPresent: 0 }), + ).toHaveLength(2); }); }); @@ -186,10 +185,28 @@ const ALL_BLOCKED = { describe('AWS S3 evaluators', () => { it('encryption: pass when encrypted, fail (high) when not, "could not verify" (medium) when indeterminate', () => { const out = evaluateS3Encryption([ - { name: 'a', encrypted: true, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }, - { name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }, + { + name: 'a', + encrypted: true, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: null, + }, + { + name: 'b', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: null, + }, // read error → indeterminate → "could not verify" (not a false high, not silently dropped) - { name: 'c', encrypted: false, encryptionDetermined: false, publicAccessDetermined: true, bucketBpa: null }, + { + name: 'c', + encrypted: false, + encryptionDetermined: false, + publicAccessDetermined: true, + bucketBpa: null, + }, ]); expect(out).toHaveLength(3); expect(out[0]!.kind).toBe('pass'); @@ -202,7 +219,13 @@ describe('AWS S3 evaluators', () => { it('encryption: all-indeterminate buckets do not pass silently', () => { const out = evaluateS3Encryption([ - { name: 'x', encrypted: false, encryptionDetermined: false, publicAccessDetermined: true, bucketBpa: null }, + { + name: 'x', + encrypted: false, + encryptionDetermined: false, + publicAccessDetermined: true, + bucketBpa: null, + }, ]); expect(out).toHaveLength(1); expect(out[0]!.kind).toBe('fail'); @@ -212,8 +235,20 @@ describe('AWS S3 evaluators', () => { it('public access: bucket-level all-blocked passes, missing fails', () => { const out = evaluateS3PublicAccess( [ - { name: 'a', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: ALL_BLOCKED }, - { name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }, + { + name: 'a', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: ALL_BLOCKED, + }, + { + name: 'b', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: null, + }, ], null, ); @@ -222,7 +257,15 @@ describe('AWS S3 evaluators', () => { it('public access: account-level BPA covers buckets lacking bucket config', () => { const out = evaluateS3PublicAccess( - [{ name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }], + [ + { + name: 'b', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: null, + }, + ], ALL_BLOCKED, ); expect(out[0]!.kind).toBe('pass'); @@ -272,7 +315,15 @@ describe('AWS S3 evaluators', () => { it('public access: indeterminate without failure detail keeps the legacy permission hint', () => { const out = evaluateS3PublicAccess( - [{ name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: false, bucketBpa: null }], + [ + { + name: 'b', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: false, + bucketBpa: null, + }, + ], null, ); expect(out[0]!.kind).toBe('fail'); @@ -307,7 +358,12 @@ describe('AWS S3 evaluators', () => { }); describe('gatherBuckets — per-bucket region routing', () => { - type FakeClient = { send: (cmd: { constructor: { name: string }; input: Record }) => Promise }; + type FakeClient = { + send: (cmd: { + constructor: { name: string }; + input: Record; + }) => Promise; + }; const asS3 = (c: FakeClient) => c as unknown as import('@aws-sdk/client-s3').S3Client; const BPA_OK = { @@ -462,7 +518,11 @@ describe('toReadFailure — read-error classification', () => { it('classifies AccessDenied by error name', () => { const err = new Error('Access Denied'); err.name = 'AccessDenied'; - expect(toReadFailure(err)).toEqual({ error: 'AccessDenied: Access Denied', denied: true, regionDisabled: false }); + expect(toReadFailure(err)).toEqual({ + error: 'AccessDenied: Access Denied', + denied: true, + regionDisabled: false, + }); }); it('classifies 403 by http status even with a generic name', () => { @@ -476,7 +536,11 @@ describe('toReadFailure — read-error classification', () => { it('treats network/timeout errors as not denied', () => { const err = new Error('socket hang up'); err.name = 'TimeoutError'; - expect(toReadFailure(err)).toEqual({ error: 'TimeoutError: socket hang up', denied: false, regionDisabled: false }); + expect(toReadFailure(err)).toEqual({ + error: 'TimeoutError: socket hang up', + denied: false, + regionDisabled: false, + }); }); it('stringifies non-Error throwables', () => { @@ -510,7 +574,10 @@ describe('combineReadFailures / remediationForReadFailure', () => { it('combine: any denied wins; regionDisabled only when ALL are', () => { expect(combineReadFailures([])).toBeUndefined(); expect(combineReadFailures([transient, denied])!.denied).toBe(true); - expect(combineReadFailures([disabled, disabled])).toMatchObject({ regionDisabled: true, denied: false }); + expect(combineReadFailures([disabled, disabled])).toMatchObject({ + regionDisabled: true, + denied: false, + }); // mixed disabled + transient must NOT advise removing a region expect(combineReadFailures([disabled, transient])!.regionDisabled).toBe(false); }); @@ -553,7 +620,11 @@ describe('AWS EC2 security-group evaluator', () => { it('flags all-protocols (-1) open as critical', () => { const out = evaluateSecurityGroups([ - { groupId: 'sg-2', region: 'us-east-1', permissions: [{ ipProtocol: '-1', cidrs: ['0.0.0.0/0'] }] }, + { + groupId: 'sg-2', + region: 'us-east-1', + permissions: [{ ipProtocol: '-1', cidrs: ['0.0.0.0/0'] }], + }, ]); expect(out[0]!.severity).toBe('critical'); }); @@ -589,8 +660,20 @@ describe('AWS EC2 security-group evaluator', () => { describe('AWS RDS evaluators', () => { it('encryption: pass when encrypted, fail (high) when not', () => { const out = evaluateRdsEncryption([ - { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'postgres' }, - { id: 'db2', region: 'us-east-1', encrypted: false, backupRetentionDays: 7, engine: 'postgres' }, + { + id: 'db1', + region: 'us-east-1', + encrypted: true, + backupRetentionDays: 7, + engine: 'postgres', + }, + { + id: 'db2', + region: 'us-east-1', + encrypted: false, + backupRetentionDays: 7, + engine: 'postgres', + }, ]); expect(out[0]!.kind).toBe('pass'); expect(out[1]!.severity).toBe('high'); @@ -598,17 +681,41 @@ describe('AWS RDS evaluators', () => { it('backups: pass when retention > 0, fail when 0, skip Aurora (cluster-level)', () => { const out = evaluateRdsBackups([ - { id: 'db1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'postgres' }, + { + id: 'db1', + region: 'us-east-1', + encrypted: true, + backupRetentionDays: 7, + engine: 'postgres', + }, { id: 'db2', region: 'us-east-1', encrypted: true, backupRetentionDays: 0, engine: 'mysql' }, - { id: 'aur', region: 'us-east-1', encrypted: true, backupRetentionDays: 0, engine: 'aurora-mysql' }, + { + id: 'aur', + region: 'us-east-1', + encrypted: true, + backupRetentionDays: 0, + engine: 'aurora-mysql', + }, ]); expect(kinds(out)).toEqual(['pass', 'fail']); // aurora excluded, not failed }); it('cluster encryption: Aurora evaluated at cluster level (pass/fail)', () => { const out = evaluateRdsClusterEncryption([ - { id: 'c1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'aurora-postgresql' }, - { id: 'c2', region: 'us-east-1', encrypted: false, backupRetentionDays: 7, engine: 'aurora-mysql' }, + { + id: 'c1', + region: 'us-east-1', + encrypted: true, + backupRetentionDays: 7, + engine: 'aurora-postgresql', + }, + { + id: 'c2', + region: 'us-east-1', + encrypted: false, + backupRetentionDays: 7, + engine: 'aurora-mysql', + }, ]); expect(out[0]!.kind).toBe('pass'); expect(out[1]!.kind).toBe('fail'); @@ -617,8 +724,20 @@ describe('AWS RDS evaluators', () => { it('cluster backups: Aurora retention evaluated at cluster level (pass/fail)', () => { const out = evaluateRdsClusterBackups([ - { id: 'c1', region: 'us-east-1', encrypted: true, backupRetentionDays: 7, engine: 'aurora-mysql' }, - { id: 'c2', region: 'us-east-1', encrypted: true, backupRetentionDays: 0, engine: 'aurora-mysql' }, + { + id: 'c1', + region: 'us-east-1', + encrypted: true, + backupRetentionDays: 7, + engine: 'aurora-mysql', + }, + { + id: 'c2', + region: 'us-east-1', + encrypted: true, + backupRetentionDays: 0, + engine: 'aurora-mysql', + }, ]); expect(out[0]!.kind).toBe('pass'); expect(out[1]!.kind).toBe('fail'); @@ -628,12 +747,36 @@ describe('AWS RDS evaluators', () => { describe('AWS KMS rotation evaluator', () => { it('evaluates eligible keys; unreadable rotation status → could-not-verify (not dropped)', () => { const out = evaluateKmsRotation([ - { keyId: 'sym-on', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: true }, - { keyId: 'sym-off', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: false }, + { + keyId: 'sym-on', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: true, + rotationEnabled: true, + }, + { + keyId: 'sym-off', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: true, + rotationEnabled: false, + }, // RSA/HMAC/etc. — not rotation-eligible → no finding - { keyId: 'rsa', region: 'us-east-1', rotationEligible: false, rotationStatusKnown: false, rotationEnabled: false }, + { + keyId: 'rsa', + region: 'us-east-1', + rotationEligible: false, + rotationStatusKnown: false, + rotationEnabled: false, + }, // eligible but status unreadable → "could not verify" (masking a permission gap as clean would be wrong) - { keyId: 'unknown', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false }, + { + keyId: 'unknown', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: false, + rotationEnabled: false, + }, ]); expect(out).toHaveLength(3); expect(out[0]!.kind).toBe('pass'); @@ -645,7 +788,13 @@ describe('AWS KMS rotation evaluator', () => { it('does not pass silently when rotation status is unreadable for all eligible keys', () => { const out = evaluateKmsRotation([ - { keyId: 'k1', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false }, + { + keyId: 'k1', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: false, + rotationEnabled: false, + }, ]); expect(out).toHaveLength(1); expect(out[0]!.kind).toBe('fail'); @@ -657,7 +806,11 @@ describe('KMS rotation read-failure gating', () => { it('transient rotation-status failure surfaces readError and avoids the permission claim', () => { const out = evaluateKmsRotation([ { - keyId: 'k1', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false, + keyId: 'k1', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: false, + rotationEnabled: false, rotationReadFailure: { error: 'ThrottlingException: Rate exceeded', denied: false }, }, ]); @@ -670,8 +823,15 @@ describe('KMS rotation read-failure gating', () => { it('denied rotation-status failure keeps the grant remediation', () => { const out = evaluateKmsRotation([ { - keyId: 'k1', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false, - rotationReadFailure: { error: 'AccessDeniedException: no kms:GetKeyRotationStatus', denied: true }, + keyId: 'k1', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: false, + rotationEnabled: false, + rotationReadFailure: { + error: 'AccessDeniedException: no kms:GetKeyRotationStatus', + denied: true, + }, }, ]); expect(out[0]!.remediation).toContain('Grant kms:GetKeyRotationStatus'); @@ -679,7 +839,13 @@ describe('KMS rotation read-failure gating', () => { it('without failure detail the legacy permission hint is kept', () => { const out = evaluateKmsRotation([ - { keyId: 'k1', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: false, rotationEnabled: false }, + { + keyId: 'k1', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: false, + rotationEnabled: false, + }, ]); expect(out[0]!.remediation).toContain('Grant kms:GetKeyRotationStatus'); }); @@ -730,7 +896,11 @@ describe('AWS CloudTrail evaluator', () => { it('status-read failure: transient error surfaces readError and does not claim a missing permission', () => { const out = evaluateCloudTrail([ { - name: 't1', multiRegion: true, logValidation: true, logging: false, loggingKnown: false, + name: 't1', + multiRegion: true, + logValidation: true, + logging: false, + loggingKnown: false, statusReadFailure: { error: 'TimeoutError: socket hang up', denied: false }, }, ]); @@ -743,7 +913,11 @@ describe('AWS CloudTrail evaluator', () => { it('status-read failure: AccessDenied keeps the grant-permission remediation', () => { const out = evaluateCloudTrail([ { - name: 't1', multiRegion: true, logValidation: true, logging: false, loggingKnown: false, + name: 't1', + multiRegion: true, + logValidation: true, + logging: false, + loggingKnown: false, statusReadFailure: { error: 'AccessDeniedException: nope', denied: true }, }, ]); @@ -758,9 +932,7 @@ describe('IAM/CloudTrail outcomes carry evidence (so the UI shows "View Evidence it('every password-policy outcome has evidence (none / weak / strong)', () => { expect(evaluatePasswordPolicy(null).every(hasEvidence)).toBe(true); - expect( - evaluatePasswordPolicy({ MinimumPasswordLength: 8 }).every(hasEvidence), - ).toBe(true); + expect(evaluatePasswordPolicy({ MinimumPasswordLength: 8 }).every(hasEvidence)).toBe(true); expect( evaluatePasswordPolicy({ MinimumPasswordLength: 14, @@ -791,21 +963,48 @@ describe('IAM/CloudTrail outcomes carry evidence (so the UI shows "View Evidence const out = evaluateCloudTrail([], { scannedRegions: ['us-east-1', 'eu-west-1'] }); expect(out).toHaveLength(1); expect(out[0]!.title).toMatch(/No CloudTrail trail found/); - expect(out[0]!.evidence).toMatchObject({ trailsFound: 0, scannedRegions: ['us-east-1', 'eu-west-1'] }); + expect(out[0]!.evidence).toMatchObject({ + trailsFound: 0, + scannedRegions: ['us-east-1', 'eu-west-1'], + }); expect(hasEvidence(out[0]!)).toBe(true); }); it('pass/fail evidence carries the determining value (S3 encryption, KMS rotation)', () => { const enc = evaluateS3Encryption([ - { name: 'enc', encrypted: true, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }, - { name: 'plain', encrypted: false, encryptionDetermined: true, publicAccessDetermined: true, bucketBpa: null }, + { + name: 'enc', + encrypted: true, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: null, + }, + { + name: 'plain', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: true, + bucketBpa: null, + }, ]); expect(enc[0]!.evidence?.encrypted).toBe(true); expect(enc[1]!.evidence?.encrypted).toBe(false); const rot = evaluateKmsRotation([ - { keyId: 'on', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: true }, - { keyId: 'off', region: 'us-east-1', rotationEligible: true, rotationStatusKnown: true, rotationEnabled: false }, + { + keyId: 'on', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: true, + rotationEnabled: true, + }, + { + keyId: 'off', + region: 'us-east-1', + rotationEligible: true, + rotationStatusKnown: true, + rotationEnabled: false, + }, ]); expect(rot[0]!.evidence?.rotationEnabled).toBe(true); expect(rot[1]!.evidence?.rotationEnabled).toBe(false); @@ -820,10 +1019,8 @@ function captureCtx(credentials: Record, checkId?: string) { const ctx = { credentials, checkId, - pass: (r: { description: string; evidence?: Record }) => - passed.push(r), - fail: (r: { description: string; evidence?: Record }) => - failed.push(r), + pass: (r: { description: string; evidence?: Record }) => passed.push(r), + fail: (r: { description: string; evidence?: Record }) => failed.push(r), } as unknown as CheckContext; return { ctx, passed, failed }; } @@ -847,9 +1044,7 @@ describe('awsAccountIdFromCtx', () => { }); it('returns null when the role ARN is missing or malformed', () => { - expect( - awsAccountIdFromCtx({ credentials: {} } as unknown as CheckContext), - ).toBeNull(); + expect(awsAccountIdFromCtx({ credentials: {} } as unknown as CheckContext)).toBeNull(); expect( awsAccountIdFromCtx({ credentials: { roleArn: 'not-an-arn' }, @@ -896,9 +1091,7 @@ describe('emitOutcomes — attributes findings to the AWS account', () => { }); emitOutcomes(ctx, [PASS_OUTCOME]); expect(passed[0]!.evidence?.awsConnectionName).toBe('Production AWS'); - expect(passed[0]!.description).toContain( - '(AWS account 123456789012 — Production AWS)', - ); + expect(passed[0]!.description).toContain('(AWS account 123456789012 — Production AWS)'); }); it('stamps a stable findingKey of `${checkId}-${resourceId}` so findings can be excepted', () => { @@ -971,6 +1164,28 @@ describe('AWS environment separation', () => { expect(classifyVpcEnv(undefined)).toBeNull(); }); + it('classifyVpcEnv: honors customer-configured aliases', () => { + const aliasesConfig = parseEnvironmentAliases({ + environment_aliases: 'release=production, preview=staging', + }); + expect( + classifyVpcEnvWithAliases({ + tags: [{ Key: 'Name', Value: 'app-release' }], + aliases: aliasesConfig.aliases, + }), + ).toBe('production'); + expect( + classifyVpcEnvWithAliases({ + tags: [{ Key: 'Environment', Value: 'preview' }], + aliases: aliasesConfig.aliases, + }), + ).toBe('staging'); + }); + + it('exposes environment aliases as an AWS connection variable', () => { + expect(awsManifest.variables?.some((v) => v.id === 'environment_aliases')).toBe(true); + }); + it('passes on production + non-production, without claiming cross-account isolation', () => { const out = evaluateEnvironmentSeparation([ { vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' }, @@ -1008,6 +1223,17 @@ describe('AWS environment separation', () => { expect(out[0]!.kind).toBe('fail'); }); + it('fails clearly when production is detected but another VPC is unclassified', () => { + const out = evaluateEnvironmentSeparation([ + { vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' }, + { vpcId: 'vpc-2', region: 'us-east-1', environment: null }, + ]); + expect(out).toHaveLength(1); + expect(out[0]!.kind).toBe('fail'); + expect(out[0]!.description).toMatch(/1 VPC\(s\) were unclassified/); + expect(out[0]!.evidence).toMatchObject({ unclassifiedVpcCount: 1 }); + }); + it('fails (low) with guidance when there are no non-default VPCs', () => { const out = evaluateEnvironmentSeparation([]); expect(out).toHaveLength(1); @@ -1021,7 +1247,7 @@ describe('buildEnvironmentSeparationOutcomes — region failures vs verdict (cub const failure = { error: 'AccessDenied: ec2:DescribeVpcs', denied: true }; const regionFailures = [{ region: 'eu-west-1', failure }]; - it('does NOT pair a region-failure fail with a confirmed pass', () => { + it('does not pass when some regions could not be read, even if scanned regions show separation', () => { const out = buildEnvironmentSeparationOutcomes( [ { vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' }, @@ -1029,11 +1255,9 @@ describe('buildEnvironmentSeparationOutcomes — region failures vs verdict (cub ], regionFailures, ); - // A confirmed pass stands alone — more regions can only ADD environments, so - // the unread region can't un-confirm it; emitting a fail too would be a - // contradictory pass+fail in one run. expect(out).toHaveLength(1); - expect(out[0]!.kind).toBe('pass'); + expect(out[0]!.kind).toBe('fail'); + expect(out[0]!.title).toMatch(/Could not verify VPCs in some regions/); }); it('surfaces the region failure alongside an UNconfirmed verdict (both negative)', () => { diff --git a/packages/integration-platform/src/manifests/aws/checks/environment-separation.ts b/packages/integration-platform/src/manifests/aws/checks/environment-separation.ts index 89021947b..f804be2ce 100644 --- a/packages/integration-platform/src/manifests/aws/checks/environment-separation.ts +++ b/packages/integration-platform/src/manifests/aws/checks/environment-separation.ts @@ -2,7 +2,12 @@ import { DescribeVpcsCommand, EC2Client } from '@aws-sdk/client-ec2'; import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; import { - classifyEnvironment, + applyEnvironmentAliasEvidence, + parseEnvironmentAliases, + type EnvironmentAlias, +} from '../../environment-aliases'; +import { + classifyEnvironmentWithAliases, confirmsEnvironmentSeparation, envTagValues, } from '../../environment-classification'; @@ -22,12 +27,26 @@ export interface VpcInfo { environment: string | null; } +function summarizeVpcs(vpcs: VpcInfo[]) { + const sample = vpcs.slice(0, 50).map((v) => ({ + vpcId: v.vpcId, + region: v.region, + environment: v.environment ?? 'unclassified', + })); + const detected = [ + ...new Set(vpcs.map((v) => v.environment).filter((e): e is string => e !== null)), + ]; + const unclassifiedVpcCount = vpcs.filter((v) => v.environment === null).length; + + return { sample, detected, unclassifiedVpcCount }; +} + // Shown on every "could not confirm" outcome. AWS's recommended separation is a // separate ACCOUNT per environment, which is invisible from one connection (one // account's role), so a single-account result is the EXPECTED shape for those // customers — guide, never accuse. const ACCOUNT_GUIDANCE = - 'If you separate environments using a separate AWS account per environment (the recommended pattern), connect each environment account as its own connection — this check evaluates one account at a time. Otherwise separate prod/non-prod into distinct VPCs and tag each (Environment=production / Environment=staging), or upload an architecture diagram as evidence.'; + 'If you separate environments using a separate AWS account per environment (the recommended pattern), connect each environment account as its own connection — this check evaluates one account at a time. Otherwise separate prod/non-prod into distinct VPCs and tag each (Environment=production / Environment=staging). If your organization uses different environment names, configure Environment aliases (e.g. release=production), or upload an architecture diagram as evidence.'; /** * Classify a VPC into an environment from its tags: an explicit `environment` @@ -38,16 +57,27 @@ const ACCOUNT_GUIDANCE = export function classifyVpcEnv( tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined, ): string | null { + return classifyVpcEnvWithAliases({ tags, aliases: [] }); +} + +export function classifyVpcEnvWithAliases({ + tags, + aliases, +}: { + tags: ReadonlyArray<{ Key?: string; Value?: string }> | undefined; + aliases: readonly EnvironmentAlias[]; +}): string | null { const tagMap: Record = {}; for (const t of tags ?? []) { if (typeof t.Key === 'string' && typeof t.Value === 'string') { tagMap[t.Key] = t.Value; } } - const nameTag = Object.entries(tagMap).find( - ([k]) => k.toLowerCase() === 'name', - )?.[1]; - return classifyEnvironment([...envTagValues(tagMap), nameTag]); + const nameTag = Object.entries(tagMap).find(([k]) => k.toLowerCase() === 'name')?.[1]; + return classifyEnvironmentWithAliases({ + candidates: [...envTagValues(tagMap), nameTag], + aliases, + }); } /** @@ -59,12 +89,6 @@ export function classifyVpcEnv( * peered / share the account boundary). */ export function evaluateEnvironmentSeparation(vpcs: VpcInfo[]): CheckOutcome[] { - const sample = vpcs.slice(0, 50).map((v) => ({ - vpcId: v.vpcId, - region: v.region, - environment: v.environment ?? 'unclassified', - })); - if (vpcs.length === 0) { return [ { @@ -81,11 +105,7 @@ export function evaluateEnvironmentSeparation(vpcs: VpcInfo[]): CheckOutcome[] { ]; } - const detected = [ - ...new Set( - vpcs.map((v) => v.environment).filter((e): e is string => e !== null), - ), - ]; + const { sample, detected, unclassifiedVpcCount } = summarizeVpcs(vpcs); // A confirmed pass requires a production environment separated from a // non-production one — two non-production VPCs alone do not demonstrate that @@ -102,19 +122,25 @@ export function evaluateEnvironmentSeparation(vpcs: VpcInfo[]): CheckOutcome[] { detectedEnvironments: detected, vpcCount: vpcs.length, vpcs: sample, + unclassifiedVpcCount, }, }, ]; } + const unclassifiedDetail = + unclassifiedVpcCount > 0 + ? ` ${unclassifiedVpcCount} VPC(s) were unclassified and need an Environment tag or environment token in the Name tag.` + : ''; + return [ { kind: 'fail', title: 'Could not confirm environment separation', description: detected.length === 0 - ? "No VPC in this account could be classified by environment, so environment separation could not be confirmed." - : `Detected environment(s) ${detected.join(', ')} among this account's VPCs, but could not confirm a production environment separated from a non-production one; this connection evaluates a single AWS account.`, + ? `No VPC in this account could be classified by environment, so environment separation could not be confirmed.${unclassifiedDetail}` + : `Detected environment(s) ${detected.join(', ')} among this account's VPCs, but could not confirm a production environment separated from a non-production one; this connection evaluates a single AWS account.${unclassifiedDetail}`, resourceType: 'aws-environment-separation', resourceId: 'vpcs', severity: 'low', @@ -123,6 +149,7 @@ export function evaluateEnvironmentSeparation(vpcs: VpcInfo[]): CheckOutcome[] { detectedEnvironments: detected, vpcCount: vpcs.length, vpcs: sample, + unclassifiedVpcCount, }, }, ]; @@ -131,10 +158,9 @@ export function evaluateEnvironmentSeparation(vpcs: VpcInfo[]): CheckOutcome[] { /** * Combine the VPCs we read with any per-region read failures into the outcomes * to emit. A region we couldn't read leaves coverage incomplete and is surfaced - * as its own "could not verify" finding — UNLESS the VPCs we DID read already - * confirm separation. Reading more regions can only ADD environments, so it can - * never un-confirm a positive result; pairing a confirmed pass with a - * verification-failure would only emit a contradictory pass+fail in one run. + * as its own "could not verify" finding. A partial regional footprint must not + * auto-complete the evidence task, even if the regions we did read already show + * production and non-production VPCs. * When zero VPCs were read AND a region failed, only the region-failure finding * is returned — a "no VPCs" verdict layered on unread data would mislead. */ @@ -145,10 +171,11 @@ export function buildEnvironmentSeparationOutcomes( const separation = evaluateEnvironmentSeparation(vpcs); const confirmed = separation.some((o) => o.kind === 'pass'); - // No coverage gap, or separation already proven → the verdict stands alone. - if (regionFailures.length === 0 || confirmed) return separation; + // No coverage gap → the verdict stands alone. + if (regionFailures.length === 0) return separation; const regions = regionFailures.map((r) => r.region); + const { sample, detected, unclassifiedVpcCount } = summarizeVpcs(vpcs); const regionFailure: CheckOutcome = { kind: 'fail', title: 'Could not verify VPCs in some regions', @@ -165,6 +192,10 @@ export function buildEnvironmentSeparationOutcomes( region: r.region, error: r.failure.error, })), + vpcCount: vpcs.length, + detectedEnvironments: detected, + unclassifiedVpcCount, + vpcs: sample, }, }; @@ -172,6 +203,11 @@ export function buildEnvironmentSeparationOutcomes( // tells the story on its own. if (vpcs.length === 0) return [regionFailure]; + // The scanned regions showed separation, but the account-level coverage is + // incomplete. Return only the verification failure so the task does not + // auto-complete from a partial regional footprint. + if (confirmed) return [regionFailure]; + // Coverage gap AND we couldn't confirm from what we read: surface both — // they're consistent (both negative). return [regionFailure, ...separation]; @@ -197,14 +233,18 @@ export const environmentSeparationCheck: IntegrationCheck = { run: async (ctx: CheckContext) => { const session = await resolveAwsSessionOrFail(ctx); if (!session) { - ctx.log( - 'AWS environment-separation check: connection not configured — skipping', - ); + ctx.log('AWS environment-separation check: connection not configured — skipping'); return; } const vpcs: VpcInfo[] = []; const regionFailures: Array<{ region: string; failure: ReadFailure }> = []; + const aliasesConfig = parseEnvironmentAliases(ctx.variables); + if (aliasesConfig.invalidEntries.length > 0) { + ctx.warn('AWS environment-separation check: ignored invalid environment aliases', { + invalidEntries: aliasesConfig.invalidEntries, + }); + } for (const region of session.regions) { try { @@ -226,7 +266,10 @@ export const environmentSeparationCheck: IntegrationCheck = { vpcs.push({ vpcId: v.VpcId ?? 'unknown', region, - environment: classifyVpcEnv(v.Tags), + environment: classifyVpcEnvWithAliases({ + tags: v.Tags, + aliases: aliasesConfig.aliases, + }), }); } token = resp.NextToken; @@ -238,6 +281,12 @@ export const environmentSeparationCheck: IntegrationCheck = { } } - emitOutcomes(ctx, buildEnvironmentSeparationOutcomes(vpcs, regionFailures)); + emitOutcomes( + ctx, + applyEnvironmentAliasEvidence({ + items: buildEnvironmentSeparationOutcomes(vpcs, regionFailures), + aliasesConfig, + }), + ); }, }; diff --git a/packages/integration-platform/src/manifests/aws/index.ts b/packages/integration-platform/src/manifests/aws/index.ts index cf6716e3d..6f4e9dd58 100644 --- a/packages/integration-platform/src/manifests/aws/index.ts +++ b/packages/integration-platform/src/manifests/aws/index.ts @@ -1,4 +1,5 @@ import type { IntegrationManifest } from '../../types'; +import { environmentAliasesVariable } from '../environment-aliases'; import { cloudTrailEnabledCheck, ec2SecurityGroupsCheck, @@ -92,6 +93,8 @@ export const awsManifest: IntegrationManifest = { { id: 'appflow', name: 'AppFlow', description: 'Flow encryption, VPC configuration, and data transfer security checks', enabledByDefault: false, implemented: true }, ], + variables: [environmentAliasesVariable], + checks: [ iamAccountSecurityCheck, s3EncryptionCheck, diff --git a/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts b/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts index d763601ea..1997fbde5 100644 --- a/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts +++ b/packages/integration-platform/src/manifests/azure/checks/__tests__/azure-checks.test.ts @@ -896,6 +896,10 @@ describe('azure subscription picker fetchOptions', () => { const options = await variable!.fetchOptions!(ctx); expect(options).toEqual([]); }); + + it('exposes environment aliases as an Azure connection variable', () => { + expect(azureManifest.variables?.some((v) => v.id === 'environment_aliases')).toBe(true); + }); }); describe('Azure environment separation', () => { @@ -950,6 +954,24 @@ describe('Azure environment separation', () => { expect(passed).toContain('Environments separated across resource groups'); }); + it('passes with customer-configured environment aliases', async () => { + const { passed, failed } = await run( + environmentSeparationCheck, + azFetch({ + names: { 'sub-1': 'Company' }, + rgs: { + 'sub-1': [{ id: 'a', name: 'app-release' }, { id: 'b', name: 'app-preview' }], + }, + }), + { + subscription_id: 'sub-1', + environment_aliases: 'release=production, preview=staging', + }, + ); + expect(failed).toHaveLength(0); + expect(passed).toContain('Environments separated across resource groups'); + }); + it('fails on two non-production environments (no production)', async () => { const { passed, failed } = await run( environmentSeparationCheck, diff --git a/packages/integration-platform/src/manifests/azure/checks/__tests__/environment-separation.test.ts b/packages/integration-platform/src/manifests/azure/checks/__tests__/environment-separation.test.ts index a9c421175..a8dcb1182 100644 --- a/packages/integration-platform/src/manifests/azure/checks/__tests__/environment-separation.test.ts +++ b/packages/integration-platform/src/manifests/azure/checks/__tests__/environment-separation.test.ts @@ -53,6 +53,33 @@ async function runEnvironmentSeparation({ } describe('Azure environment separation pagination coverage', () => { + it('does not emit a subscription pass when one selected subscription cannot be read', async () => { + const out = await runEnvironmentSeparation({ + variables: { subscription_ids: ['s-prod', 's-dev', 's-unread'] }, + fetch: (url) => { + if (url.match(/\/subscriptions\/s-prod\?api-version/)) { + return { displayName: 'Production' }; + } + if (url.match(/\/subscriptions\/s-dev\?api-version/)) { + return { displayName: 'Development' }; + } + if (url.match(/\/subscriptions\/s-unread\?api-version/)) { + throw new Error('HTTP 403: Forbidden'); + } + if (url.includes('/resourcegroups')) { + return { value: [] }; + } + + return {}; + }, + }); + + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.title).toMatch(/Could not verify environment separation/); + expect(out.failed[0]!.evidence).toMatchObject({ coverageIncomplete: true }); + }); + it('does not emit a resource-group pass when ARM pagination hits the page cap', async () => { const out = await runEnvironmentSeparation({ fetch: (url) => { @@ -86,4 +113,33 @@ describe('Azure environment separation pagination coverage', () => { resourceGroupCoverageGapSubscriptions: ['sub-1'], }); }); + + it('fails clearly when production is detected but another resource group is unclassified', async () => { + const out = await runEnvironmentSeparation({ + fetch: (url) => { + if (url.match(/\/subscriptions\/sub-1\?api-version/)) { + return { displayName: 'Company' }; + } + if (url.includes('/resourcegroups')) { + return { + value: [ + { id: 'rg-prod', name: 'rg-prod' }, + { id: 'backend', name: 'backend' }, + ], + }; + } + + return {}; + }, + }); + + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.description).toMatch(/1 resource group\(s\) were unclassified/); + expect(out.failed[0]!.evidence).toMatchObject({ + resourceGroupsScanned: 2, + resourceGroupsClassified: 1, + unclassifiedResourceGroupCount: 1, + }); + }); }); diff --git a/packages/integration-platform/src/manifests/azure/checks/environment-separation.ts b/packages/integration-platform/src/manifests/azure/checks/environment-separation.ts index d48c1ebce..c7c77222e 100644 --- a/packages/integration-platform/src/manifests/azure/checks/environment-separation.ts +++ b/packages/integration-platform/src/manifests/azure/checks/environment-separation.ts @@ -1,7 +1,13 @@ import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + environmentAliasEvidence, + parseEnvironmentAliases, + type EnvironmentAlias, +} from '../../environment-aliases'; import { classifyEnvironment, + classifyEnvironmentWithAliases, confirmsEnvironmentSeparation, envTagValues, } from '../../environment-classification'; @@ -30,8 +36,21 @@ export function classifyResourceGroupEnv(rg: { return classifyEnvironment([...envTagValues(rg.tags), rg.name]); } +function classifyResourceGroupEnvWithAliases({ + rg, + aliases, +}: { + rg: { name: string; tags?: Record }; + aliases: readonly EnvironmentAlias[]; +}): string | null { + return classifyEnvironmentWithAliases({ + candidates: [...envTagValues(rg.tags), rg.name], + aliases, + }); +} + const GUIDANCE = - 'Separate production and non-production into distinct subscriptions (strongest), or tag each resource group with an `environment` tag (e.g. environment=production / environment=staging). If you separate environments another way, upload a console screenshot or architecture diagram as evidence.'; + 'Separate production and non-production into distinct subscriptions (strongest), or tag each resource group with an `environment` tag (e.g. environment=production / environment=staging). If your organization uses different environment names, configure Environment aliases (e.g. release=production). If you separate environments another way, upload a console screenshot or architecture diagram as evidence.'; /** * Separation of Environments check (heuristic). Evaluates ONLY the subscriptions @@ -59,6 +78,13 @@ export const environmentSeparationCheck: IntegrationCheck = { // resolveAzureSubscriptionIds already emitted a finding when scope is empty. if (subscriptionIds.length === 0) return; + const aliasesConfig = parseEnvironmentAliases(ctx.variables); + if (aliasesConfig.invalidEntries.length > 0) { + ctx.warn('Azure env-separation: ignored invalid environment aliases', { + invalidEntries: aliasesConfig.invalidEntries, + }); + } + // Tier 1 (strong): subscription-level separation. Read each IN-SCOPE // subscription's display name only — we never touch subscriptions outside // the configured selection. @@ -69,7 +95,10 @@ export const environmentSeparationCheck: IntegrationCheck = { const sub = await ctx.fetch<{ displayName?: string }>( `${ARM_BASE}/subscriptions/${id}?api-version=${SUBSCRIPTION_API_VERSION}`, ); - const env = classifyEnvironment([sub.displayName]); + const env = classifyEnvironmentWithAliases({ + candidates: [sub.displayName], + aliases: aliasesConfig.aliases, + }); if (env) subscriptionEnvSet.add(env); } catch (err) { anySubscriptionReadFailed = true; @@ -79,7 +108,8 @@ export const environmentSeparationCheck: IntegrationCheck = { } } const subscriptionEnvs = [...subscriptionEnvSet]; - if (confirmsEnvironmentSeparation(subscriptionEnvs)) { + const subscriptionSeparationDetected = confirmsEnvironmentSeparation(subscriptionEnvs); + if (!anySubscriptionReadFailed && subscriptionSeparationDetected) { ctx.pass({ title: 'Environments separated across subscriptions', description: `Detected production separated from non-production across ${subscriptionIds.length} in-scope Azure subscription(s): ${subscriptionEnvs.join(', ')} (subscription-level boundary).`, @@ -89,6 +119,7 @@ export const environmentSeparationCheck: IntegrationCheck = { boundary: 'subscription', detectedEnvironments: subscriptionEnvs, subscriptionsScanned: subscriptionIds.length, + ...environmentAliasEvidence(aliasesConfig), }, }); return; @@ -99,6 +130,7 @@ export const environmentSeparationCheck: IntegrationCheck = { const rgEnvSet = new Set(); const rgSamples: Array<{ name: string; environment: string }> = []; let anyRgReadFailed = false; + let resourceGroupsScanned = 0; let resourceGroupsClassified = 0; const rgCoverageGaps = new Set(); const rgCoverageGapSubscriptions = new Set(); @@ -122,7 +154,11 @@ export const environmentSeparationCheck: IntegrationCheck = { continue; } for (const rg of resourceGroups) { - const env = classifyResourceGroupEnv(rg); + resourceGroupsScanned++; + const env = classifyResourceGroupEnvWithAliases({ + rg, + aliases: aliasesConfig.aliases, + }); if (env) { rgEnvSet.add(env); resourceGroupsClassified++; @@ -133,7 +169,11 @@ export const environmentSeparationCheck: IntegrationCheck = { const resourceGroupEnvs = [...rgEnvSet]; const resourceGroupCoverageIncomplete = rgCoverageGaps.size > 0; const resourceGroupSeparationDetected = confirmsEnvironmentSeparation(resourceGroupEnvs); - if (!resourceGroupCoverageIncomplete && resourceGroupSeparationDetected) { + if ( + !anySubscriptionReadFailed && + !resourceGroupCoverageIncomplete && + resourceGroupSeparationDetected + ) { ctx.pass({ title: 'Environments separated across resource groups', description: `Detected production separated from non-production across resource groups in ${subscriptionIds.length} in-scope subscription(s): ${resourceGroupEnvs.join(', ')}. Resource-group separation is logical — RGs share the subscription's access and network boundary — not full isolation.`, @@ -144,6 +184,7 @@ export const environmentSeparationCheck: IntegrationCheck = { detectedEnvironments: resourceGroupEnvs, subscriptionsScanned: subscriptionIds.length, resourceGroups: rgSamples, + ...environmentAliasEvidence(aliasesConfig), }, }); return; @@ -161,17 +202,23 @@ export const environmentSeparationCheck: IntegrationCheck = { coverageGaps.push('resource-group pagination stopped before all groups were evaluated'); } const coverageIncomplete = coverageGaps.length > 0; + const separationDetected = subscriptionSeparationDetected || resourceGroupSeparationDetected; + const unclassifiedResourceGroupCount = resourceGroupsScanned - resourceGroupsClassified; + const unclassifiedDetail = + unclassifiedResourceGroupCount > 0 + ? `; ${unclassifiedResourceGroupCount} resource group(s) were unclassified and need an environment tag or environment token in the name` + : ''; const base = detectedAll.length === 0 ? `No in-scope Azure subscription or resource group could be classified by environment across ${subscriptionIds.length} subscription(s)` - : resourceGroupSeparationDetected && resourceGroupCoverageIncomplete - ? `Detected production separated from non-production in the scanned resource groups (${resourceGroupEnvs.join(', ')}), but not all resource groups were evaluated across ${subscriptionIds.length} in-scope subscription(s)` + : separationDetected && coverageIncomplete + ? `Detected production separated from non-production in the scanned Azure scope (${detectedAll.join(', ')}), but coverage is incomplete across ${subscriptionIds.length} in-scope subscription(s)` : `Detected environment(s) ${detectedAll.join(', ')}, but could not confirm a production environment separated from a non-production one across ${subscriptionIds.length} in-scope subscription(s)`; ctx.fail({ title: coverageIncomplete ? 'Could not verify environment separation' : 'Could not confirm environment separation', - description: `${base}${coverageIncomplete ? ` (${coverageGaps.join('; ')})` : ''}.`, + description: `${base}${unclassifiedDetail}${coverageIncomplete ? ` (${coverageGaps.join('; ')})` : ''}.`, resourceType: 'azure-environment-separation', resourceId: 'subscriptions', severity: 'medium', @@ -180,7 +227,9 @@ export const environmentSeparationCheck: IntegrationCheck = { subscriptionEnvironments: subscriptionEnvs, resourceGroupEnvironments: resourceGroupEnvs, subscriptionsScanned: subscriptionIds.length, + resourceGroupsScanned, resourceGroupsClassified, + unclassifiedResourceGroupCount, ...(coverageIncomplete ? { coverageIncomplete: true } : {}), ...(resourceGroupCoverageIncomplete ? { @@ -188,6 +237,7 @@ export const environmentSeparationCheck: IntegrationCheck = { resourceGroupCoverageGapSubscriptions: [...rgCoverageGapSubscriptions], } : {}), + ...environmentAliasEvidence(aliasesConfig), }, }); }, diff --git a/packages/integration-platform/src/manifests/azure/index.ts b/packages/integration-platform/src/manifests/azure/index.ts index 8659becc3..748bd8550 100644 --- a/packages/integration-platform/src/manifests/azure/index.ts +++ b/packages/integration-platform/src/manifests/azure/index.ts @@ -1,4 +1,5 @@ import type { IntegrationManifest } from '../../types'; +import { environmentAliasesVariable } from '../environment-aliases'; import { environmentSeparationCheck, keyVaultProtectionCheck, @@ -161,6 +162,7 @@ Our integration only makes read-only API calls for security scanning.`, 'Auto-detected after connecting. If not detected, find it at portal.azure.com → Subscriptions', placeholder: 'Auto-detected', }, + environmentAliasesVariable, ], checks: [ diff --git a/packages/integration-platform/src/manifests/environment-aliases.ts b/packages/integration-platform/src/manifests/environment-aliases.ts new file mode 100644 index 000000000..94faedd71 --- /dev/null +++ b/packages/integration-platform/src/manifests/environment-aliases.ts @@ -0,0 +1,125 @@ +import type { CheckVariable, CheckVariableValues } from '../types'; +import { normalizeEnvironmentName, tokenizeEnvironmentValue } from './environment-classification'; + +export interface EnvironmentAlias { + alias: string; + environment: string; + tokens: readonly string[]; +} + +export interface EnvironmentAliasesConfig { + aliases: readonly EnvironmentAlias[]; + invalidEntries: readonly string[]; +} + +export const environmentAliasesVariable: CheckVariable = { + id: 'environment_aliases', + label: 'Environment aliases', + type: 'text', + required: false, + placeholder: 'release=production, preview=staging', + helpText: + 'Optional custom environment names. Use alias=environment pairs, separated by commas. Supported environments: production, staging, development, test, sandbox, non-production.', +}; + +function normalizeConfiguredValue(raw: CheckVariableValues[string]): string { + if (typeof raw === 'string') return raw; + if (Array.isArray(raw)) return raw.join(','); + return ''; +} + +function addInvalidEntry({ + invalidEntries, + entry, +}: { + invalidEntries: string[]; + entry: string; +}): void { + if (!invalidEntries.includes(entry)) invalidEntries.push(entry); +} + +export function parseEnvironmentAliases(variables: CheckVariableValues): EnvironmentAliasesConfig { + const raw = normalizeConfiguredValue(variables.environment_aliases); + if (!raw.trim()) return { aliases: [], invalidEntries: [] }; + + const aliasesByTokenKey = new Map(); + const entriesByTokenKey = new Map(); + const invalidEntries: string[] = []; + let duplicateAliasDetected = false; + for (const entry of raw.split(/[,;\n]+/)) { + const trimmed = entry.trim(); + if (!trimmed) continue; + + const separatorIndex = trimmed.search(/[=:]/); + if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) { + invalidEntries.push(trimmed); + continue; + } + + const alias = trimmed.slice(0, separatorIndex).trim(); + const target = trimmed.slice(separatorIndex + 1).trim(); + const tokens = tokenizeEnvironmentValue(alias); + const environment = normalizeEnvironmentName(target); + if (tokens.length === 0 || !environment) { + invalidEntries.push(trimmed); + continue; + } + + const tokenKey = tokens.join('\u0000'); + const existingEntry = entriesByTokenKey.get(tokenKey); + if (existingEntry) { + duplicateAliasDetected = true; + addInvalidEntry({ invalidEntries, entry: existingEntry }); + addInvalidEntry({ invalidEntries, entry: trimmed }); + continue; + } + + entriesByTokenKey.set(tokenKey, trimmed); + aliasesByTokenKey.set(tokenKey, { + alias, + environment, + tokens, + }); + } + + if (duplicateAliasDetected) return { aliases: [], invalidEntries }; + + return { aliases: [...aliasesByTokenKey.values()], invalidEntries }; +} + +export function environmentAliasEvidence( + config: EnvironmentAliasesConfig, +): Record { + return { + ...(config.aliases.length > 0 + ? { + environmentAliases: config.aliases.map(({ alias, environment }) => ({ + alias, + environment, + })), + } + : {}), + ...(config.invalidEntries.length > 0 + ? { invalidEnvironmentAliases: config.invalidEntries } + : {}), + }; +} + +export function applyEnvironmentAliasEvidence }>({ + items, + aliasesConfig, +}: { + items: readonly T[]; + aliasesConfig: EnvironmentAliasesConfig; +}): T[] { + const aliasEvidence = environmentAliasEvidence(aliasesConfig); + if (Object.keys(aliasEvidence).length === 0) return [...items]; + + return items.map((item) => ({ + ...item, + evidence: { + ...(item.evidence ?? {}), + ...aliasEvidence, + }, + })); +} diff --git a/packages/integration-platform/src/manifests/environment-classification.ts b/packages/integration-platform/src/manifests/environment-classification.ts index 843df25d0..e003f63d6 100644 --- a/packages/integration-platform/src/manifests/environment-classification.ts +++ b/packages/integration-platform/src/manifests/environment-classification.ts @@ -17,13 +17,10 @@ * read as production and corrupt the prod-vs-non-prod separation verdict. */ +import type { EnvironmentAlias } from './environment-aliases'; + /** Production keywords — defined once so the qualifier pass below can reuse them. */ -const PRODUCTION_TOKENS: ReadonlySet = new Set([ - 'prod', - 'production', - 'prd', - 'live', -]); +const PRODUCTION_TOKENS: ReadonlySet = new Set(['prod', 'production', 'prd', 'live']); const ENV_TOKEN_SETS: ReadonlyArray<{ env: string; tokens: ReadonlySet }> = [ // Production is first so it wins ties when a string carries multiple tokens. @@ -74,6 +71,15 @@ const NON_PRODUCTION_TOKENS: ReadonlySet = new Set([ const PRODUCTION_NEGATORS: ReadonlySet = new Set(['non', 'not']); const PREPROD_QUALIFIERS: ReadonlySet = new Set(['pre']); +const CANONICAL_ENVIRONMENTS: ReadonlySet = new Set([ + PRODUCTION_ENV, + STAGING_ENV, + 'development', + 'test', + 'sandbox', + NON_PRODUCTION_ENV, +]); + /** * Whether a set of detected environments confirms environment SEPARATION as the * control intends: production must be present AND at least one non-production @@ -81,22 +87,24 @@ const PREPROD_QUALIFIERS: ReadonlySet = new Set(['pre']); * environments alone (e.g. dev + staging) do NOT demonstrate that production is * segregated, so they must not pass. */ -export function confirmsEnvironmentSeparation( - envs: ReadonlyArray, -): boolean { - return ( - envs.includes(PRODUCTION_ENV) && envs.some((e) => e !== PRODUCTION_ENV) - ); +export function confirmsEnvironmentSeparation(envs: ReadonlyArray): boolean { + return envs.includes(PRODUCTION_ENV) && envs.some((e) => e !== PRODUCTION_ENV); } /** Split on any run of non-alphanumeric chars; lowercased, empties removed. */ -function tokenize(value: string): string[] { +export function tokenizeEnvironmentValue(value: string): string[] { return value .toLowerCase() .split(/[^a-z0-9]+/) .filter((t) => t.length > 0); } +export function normalizeEnvironmentName(raw: string): string | null { + const env = classifyTokens({ tokens: tokenizeEnvironmentValue(raw), aliases: [] }); + if (!env) return null; + return CANONICAL_ENVIRONMENTS.has(env) ? env : null; +} + /** * Classify a single tokenized candidate, or null. A qualified production token * is resolved FIRST — "non-prod"/"not-prod" (and the joined "nonprod") → @@ -104,7 +112,40 @@ function tokenize(value: string): string[] { * can't win it back. Otherwise tokens are matched exactly against each * environment set (production first, so it wins when several tokens are present). */ -function classifyTokens(tokens: string[]): string | null { +function hasTokenSequence({ + tokens, + sequence, + startIndex, +}: { + tokens: readonly string[]; + sequence: readonly string[]; + startIndex: number; +}): boolean { + if (startIndex + sequence.length > tokens.length) return false; + return sequence.every((token, offset) => tokens[startIndex + offset] === token); +} + +function qualifiedProductionEnv({ + tokens, + startIndex, +}: { + tokens: readonly string[]; + startIndex: number; +}): string | null { + const previous = tokens[startIndex - 1]; + if (!previous) return null; + if (PRODUCTION_NEGATORS.has(previous)) return NON_PRODUCTION_ENV; + if (PREPROD_QUALIFIERS.has(previous)) return STAGING_ENV; + return null; +} + +function classifyTokens({ + tokens, + aliases, +}: { + tokens: readonly string[]; + aliases: readonly EnvironmentAlias[]; +}): string | null { let prev: string | undefined; for (const token of tokens) { if (NON_PRODUCTION_TOKENS.has(token)) return NON_PRODUCTION_ENV; @@ -114,6 +155,15 @@ function classifyTokens(tokens: string[]): string | null { } prev = token; } + for (let i = 0; i < tokens.length; i++) { + for (const alias of aliases) { + if (!hasTokenSequence({ tokens, sequence: alias.tokens, startIndex: i })) continue; + if (alias.environment === PRODUCTION_ENV) { + return qualifiedProductionEnv({ tokens, startIndex: i }) ?? PRODUCTION_ENV; + } + return alias.environment; + } + } for (const { env, tokens: keywords } of ENV_TOKEN_SETS) { if (tokens.some((t) => keywords.has(t))) return env; } @@ -126,12 +176,20 @@ function classifyTokens(tokens: string[]): string | null { * Candidates are tried in order, so callers should pass the most authoritative * source (explicit env tag/label value) before the resource name. */ -export function classifyEnvironment( - candidates: ReadonlyArray, -): string | null { +export function classifyEnvironment(candidates: ReadonlyArray): string | null { + return classifyEnvironmentWithAliases({ candidates, aliases: [] }); +} + +export function classifyEnvironmentWithAliases({ + candidates, + aliases, +}: { + candidates: ReadonlyArray; + aliases: readonly EnvironmentAlias[]; +}): string | null { for (const candidate of candidates) { if (!candidate) continue; - const env = classifyTokens(tokenize(candidate)); + const env = classifyTokens({ tokens: tokenizeEnvironmentValue(candidate), aliases }); if (env) return env; } return null; @@ -151,9 +209,7 @@ export function envTagValues( // Iterate the configured keys in PRIORITY order (not the tag map's insertion // order) so a more authoritative key (`environment`) is returned before a // less authoritative one (`stage`) — `classifyEnvironment` trusts order. - const normalized = new Map( - Object.entries(tags).map(([k, v]) => [k.toLowerCase(), v]), - ); + const normalized = new Map(Object.entries(tags).map(([k, v]) => [k.toLowerCase(), v])); return keys .map((k) => normalized.get(k.toLowerCase())) .filter((v): v is string => typeof v === 'string' && v.length > 0); diff --git a/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts index 2f7703101..f1220bda0 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/__tests__/gcp-checks.test.ts @@ -1,22 +1,16 @@ import { describe, expect, it } from 'bun:test'; -import type { - CheckContext, - CheckVariableValues, - IntegrationCheck, -} from '../../../../types'; +import type { CheckContext, CheckVariableValues, IntegrationCheck } from '../../../../types'; +import { gcpManifest } from '../../index'; import { cloudMonitoringAlertingCheck } from '../cloud-monitoring-alerting'; import { cloudSqlBackupsCheck } from '../cloud-sql-backups'; import { cloudSqlEncryptionCheck } from '../cloud-sql-encryption'; import { cloudSqlSslCheck } from '../cloud-sql-ssl'; -import { - classifyProjectEnv, - environmentSeparationCheck, -} from '../environment-separation'; +import { classifyProjectEnv, environmentSeparationCheck } from '../environment-separation'; import { iamPrimitiveRolesCheck } from '../iam-primitive-roles'; +import { isGcpApiDisabled } from '../shared'; import { storageEncryptionCheck } from '../storage-encryption'; import { storagePublicAccessCheck } from '../storage-public-access'; import { vpcOpenFirewallsCheck } from '../vpc-open-firewalls'; -import { isGcpApiDisabled } from '../shared'; interface Captured { passed: Array<{ @@ -68,9 +62,9 @@ async function runCheck( remediation: r.remediation, evidence: r.evidence, }), - fetch: (async (url: string): Promise => + fetch: (async (url: string): Promise => (opts.fetch ? opts.fetch(url) : {}) as T) as CheckContext['fetch'], - post: (async (url: string, body?: unknown): Promise => + post: (async (url: string, body?: unknown): Promise => (opts.post ? opts.post(url, body) : {}) as T) as CheckContext['post'], put: (async () => ({})) as CheckContext['put'], patch: (async () => ({})) as CheckContext['patch'], @@ -96,14 +90,20 @@ describe('isGcpApiDisabled — service-not-enabled detection', () => { it('matches the real SERVICE_DISABLED 403 body', () => { expect( isGcpApiDisabled( - httpErr(403, '{"error":{"code":403,"message":"Cloud SQL Admin API has not been used in project gen-lang-client-0670714718 before or it is disabled.","status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED"}]}}'), + httpErr( + 403, + '{"error":{"code":403,"message":"Cloud SQL Admin API has not been used in project gen-lang-client-0670714718 before or it is disabled.","status":"PERMISSION_DENIED","details":[{"reason":"SERVICE_DISABLED"}]}}', + ), ), ).toBe(true); }); it('does NOT match a genuine permission denial', () => { expect( isGcpApiDisabled( - httpErr(403, '{"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}'), + httpErr( + 403, + '{"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}', + ), ), ).toBe(false); }); @@ -114,14 +114,18 @@ describe('isGcpApiDisabled — service-not-enabled detection', () => { describe('GCP checks skip projects whose service API is disabled', () => { const apiDisabled = () => { - const e = new Error('HTTP 403: Forbidden - Cloud SQL Admin API has not been used in project p before or it is disabled. (SERVICE_DISABLED)'); + const e = new Error( + 'HTTP 403: Forbidden - Cloud SQL Admin API has not been used in project p before or it is disabled. (SERVICE_DISABLED)', + ); (e as Error & { status: number }).status = 403; return e; }; it('cloud-sql-ssl emits NO finding when the API is disabled (vs a false "grant permission")', async () => { const out = await runCheck(cloudSqlSslCheck, { variables: { project_ids: ['p'] }, - fetch: () => { throw apiDisabled(); }, + fetch: () => { + throw apiDisabled(); + }, }); expect(out.failed).toHaveLength(0); expect(out.passed).toHaveLength(0); @@ -134,7 +138,9 @@ describe('GCP checks skip projects whose service API is disabled', () => { }; const out = await runCheck(cloudSqlSslCheck, { variables: { project_ids: ['p'] }, - fetch: () => { throw denied(); }, + fetch: () => { + throw denied(); + }, }); expect(out.failed).toHaveLength(1); expect(out.failed[0]!.title).toMatch(/Could not verify Cloud SQL SSL/); @@ -495,10 +501,28 @@ describe('GCP VPC open-firewalls check', () => { const { passed, failed } = await runCheck(vpcOpenFirewallsCheck, { fetch: () => ({ items: [ - { name: 'https', sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'tcp', ports: ['443'] }] }, - { name: 'internal-ssh', sourceRanges: ['10.0.0.0/8'], allowed: [{ IPProtocol: 'tcp', ports: ['22'] }] }, - { name: 'disabled', disabled: true, sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'tcp', ports: ['22'] }] }, - { name: 'egress', direction: 'EGRESS', sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'all' }] }, + { + name: 'https', + sourceRanges: ['0.0.0.0/0'], + allowed: [{ IPProtocol: 'tcp', ports: ['443'] }], + }, + { + name: 'internal-ssh', + sourceRanges: ['10.0.0.0/8'], + allowed: [{ IPProtocol: 'tcp', ports: ['22'] }], + }, + { + name: 'disabled', + disabled: true, + sourceRanges: ['0.0.0.0/0'], + allowed: [{ IPProtocol: 'tcp', ports: ['22'] }], + }, + { + name: 'egress', + direction: 'EGRESS', + sourceRanges: ['0.0.0.0/0'], + allowed: [{ IPProtocol: 'all' }], + }, ], }), }); @@ -510,7 +534,11 @@ describe('GCP VPC open-firewalls check', () => { const { failed } = await runCheck(vpcOpenFirewallsCheck, { fetch: () => ({ items: [ - { name: 'range', sourceRanges: ['0.0.0.0/0'], allowed: [{ IPProtocol: 'tcp', ports: ['20-25'] }] }, + { + name: 'range', + sourceRanges: ['0.0.0.0/0'], + allowed: [{ IPProtocol: 'tcp', ports: ['20-25'] }], + }, ], }), }); @@ -522,7 +550,9 @@ describe('GCP VPC open-firewalls check', () => { it('flags IPv6 ::/0 and sensitive ports across multiple tcp tuples', async () => { const ipv6 = await runCheck(vpcOpenFirewallsCheck, { fetch: () => ({ - items: [{ name: 'v6', sourceRanges: ['::/0'], allowed: [{ IPProtocol: 'tcp', ports: ['3389'] }] }], + items: [ + { name: 'v6', sourceRanges: ['::/0'], allowed: [{ IPProtocol: 'tcp', ports: ['3389'] }] }, + ], }), }); expect(ipv6.failed[0]!.severity).toBe('critical'); @@ -533,7 +563,10 @@ describe('GCP VPC open-firewalls check', () => { { name: 'm', sourceRanges: ['0.0.0.0/0'], - allowed: [{ IPProtocol: 'tcp', ports: ['443'] }, { IPProtocol: 'tcp', ports: ['22'] }], + allowed: [ + { IPProtocol: 'tcp', ports: ['443'] }, + { IPProtocol: 'tcp', ports: ['22'] }, + ], }, ], }), @@ -545,7 +578,9 @@ describe('GCP VPC open-firewalls check', () => { describe('GCP Cloud SQL checks', () => { it('SSL: passes ENCRYPTED_ONLY, fails when unset', async () => { const ok = await runCheck(cloudSqlSslCheck, { - fetch: () => ({ items: [{ name: 'db1', settings: { ipConfiguration: { sslMode: 'ENCRYPTED_ONLY' } } }] }), + fetch: () => ({ + items: [{ name: 'db1', settings: { ipConfiguration: { sslMode: 'ENCRYPTED_ONLY' } } }], + }), }); expect(ok.passed).toHaveLength(1); expect(ok.failed).toHaveLength(0); @@ -558,12 +593,16 @@ describe('GCP Cloud SQL checks', () => { it('backups: passes when enabled, fails when disabled', async () => { const ok = await runCheck(cloudSqlBackupsCheck, { - fetch: () => ({ items: [{ name: 'db1', settings: { backupConfiguration: { enabled: true } } }] }), + fetch: () => ({ + items: [{ name: 'db1', settings: { backupConfiguration: { enabled: true } } }], + }), }); expect(ok.passed).toHaveLength(1); const bad = await runCheck(cloudSqlBackupsCheck, { - fetch: () => ({ items: [{ name: 'db2', settings: { backupConfiguration: { enabled: false } } }] }), + fetch: () => ({ + items: [{ name: 'db2', settings: { backupConfiguration: { enabled: false } } }], + }), }); expect(bad.failed).toHaveLength(1); }); @@ -572,7 +611,11 @@ describe('GCP Cloud SQL checks', () => { const out = await runCheck(cloudSqlBackupsCheck, { fetch: () => ({ items: [ - { name: 'replica', masterInstanceName: 'primary', settings: { backupConfiguration: { enabled: false } } }, + { + name: 'replica', + masterInstanceName: 'primary', + settings: { backupConfiguration: { enabled: false } }, + }, ], }), }); @@ -586,9 +629,7 @@ describe('No projects resolved → check no-ops (no false pass)', () => { const { passed, failed } = await runCheck(iamPrimitiveRolesCheck, { variables: {}, // no project_ids → falls back to detection fetch: (url) => - url.includes('organizations:search') - ? { organizations: [] } - : { projects: [] }, + url.includes('organizations:search') ? { organizations: [] } : { projects: [] }, }); expect(passed).toHaveLength(0); expect(failed).toHaveLength(0); @@ -597,14 +638,13 @@ describe('No projects resolved → check no-ops (no false pass)', () => { describe('GCP Cloud Monitoring — alerting and log export check', () => { // Branch a single mock by which API the check is calling. - const monitorFetch = - (opts: { policies?: unknown[]; sinks?: unknown[] }) => (url: string) => { - if (url.includes('/alertPolicies')) { - return { alertPolicies: opts.policies ?? [] }; - } - if (url.includes('/sinks')) return { sinks: opts.sinks ?? [] }; - return {}; - }; + const monitorFetch = (opts: { policies?: unknown[]; sinks?: unknown[] }) => (url: string) => { + if (url.includes('/alertPolicies')) { + return { alertPolicies: opts.policies ?? [] }; + } + if (url.includes('/sinks')) return { sinks: opts.sinks ?? [] }; + return {}; + }; const status = (err: Error, code: number) => { (err as Error & { status: number }).status = code; @@ -638,9 +678,7 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { const out = await runCheck(cloudMonitoringAlertingCheck, { fetch: monitorFetch({ policies: [{ name: 'p1', enabled: true, notificationChannels: [] }], - sinks: [ - { name: 'export-bq', destination: 'storage.googleapis.com/b1', disabled: false }, - ], + sinks: [{ name: 'export-bq', destination: 'storage.googleapis.com/b1', disabled: false }], }), }); expect(out.failed).toHaveLength(1); @@ -683,14 +721,12 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { sinks: [ { name: '_Default', - destination: - 'logging.googleapis.com/projects/x/locations/global/buckets/_Default', + destination: 'logging.googleapis.com/projects/x/locations/global/buckets/_Default', disabled: false, }, { name: '_Required', - destination: - 'logging.googleapis.com/projects/x/locations/global/buckets/_Required', + destination: 'logging.googleapis.com/projects/x/locations/global/buckets/_Required', disabled: false, }, ], @@ -717,8 +753,7 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { sinks: [ { name: 'my-sink', // non-default NAME, but routes to the default bucket - destination: - 'logging.googleapis.com/projects/x/locations/global/buckets/_Default', + destination: 'logging.googleapis.com/projects/x/locations/global/buckets/_Default', disabled: false, }, ], @@ -734,8 +769,7 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { sinks: [ { name: 'audit', - destination: - 'logging.googleapis.com/projects/x/locations/global/buckets/audit-7yr', + destination: 'logging.googleapis.com/projects/x/locations/global/buckets/audit-7yr', disabled: false, }, ], @@ -749,10 +783,7 @@ describe('GCP Cloud Monitoring — alerting and log export check', () => { const out = await runCheck(cloudMonitoringAlertingCheck, { fetch: (url) => { if (url.includes('/alertPolicies')) { - throw status( - new Error('HTTP 403: Forbidden - The caller does not have permission'), - 403, - ); + throw status(new Error('HTTP 403: Forbidden - The caller does not have permission'), 403); } if (url.includes('/sinks')) { return { @@ -900,9 +931,7 @@ describe('classifyProjectEnv — token matching', () => { expect( classifyProjectEnv({ projectId: 'proj-001', labels: { environment: 'production' } }), ).toBe('production'); - expect( - classifyProjectEnv({ projectId: 'proj-002', labels: { env: 'qa' } }), - ).toBe('test'); + expect(classifyProjectEnv({ projectId: 'proj-002', labels: { env: 'qa' } })).toBe('test'); }); it('does NOT false-match substrings like product/developer', () => { @@ -954,6 +983,30 @@ describe('GCP environment-separation check', () => { expect(out.passed).toHaveLength(1); }); + it('passes with customer-configured environment aliases', async () => { + const out = await runCheck(environmentSeparationCheck, { + variables: { + environment_aliases: 'release=production, preview=staging', + }, + fetch: () => ({ + projects: [{ projectId: 'app-release' }, { projectId: 'app-preview' }], + }), + }); + expect(out.failed).toHaveLength(0); + expect(out.passed).toHaveLength(1); + expect(out.passed[0]!.evidence).toMatchObject({ + detectedEnvironments: expect.arrayContaining(['production', 'staging']), + environmentAliases: [ + { alias: 'release', environment: 'production' }, + { alias: 'preview', environment: 'staging' }, + ], + }); + }); + + it('exposes environment aliases as a GCP connection variable', () => { + expect(gcpManifest.variables?.some((v) => v.id === 'environment_aliases')).toBe(true); + }); + it('evaluates projects across multiple pages (unscoped discovery)', async () => { const out = await runCheck(environmentSeparationCheck, { variables: UNSCOPED, @@ -968,6 +1021,23 @@ describe('GCP environment-separation check', () => { expect(out.passed).toHaveLength(1); }); + it('does not pass when discovery is capped even if scanned projects show separation', async () => { + const out = await runCheck(environmentSeparationCheck, { + variables: UNSCOPED, + fetch: (url) => { + const page = Number(new URLSearchParams(url.split('?')[1] ?? '').get('pageToken') ?? '0'); + return { + projects: [{ projectId: page === 0 ? 'myapp-prod' : 'myapp-dev' }], + nextPageToken: String(page + 1), + }; + }, + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.title).toMatch(/Could not verify environment separation/); + expect(out.failed[0]!.evidence).toMatchObject({ discoveryTruncated: true }); + }); + it('honors project_ids scope: fetches selected projects, never lists all', async () => { const out = await runCheck(environmentSeparationCheck, { variables: { project_ids: ['p-prod', 'p-dev'] }, @@ -1009,6 +1079,18 @@ describe('GCP environment-separation check', () => { expect(out.failed[0]!.remediation).toMatch(/distinct GCP projects/); }); + it('fails clearly when production is detected but another project is unclassified', async () => { + const out = await runCheck(environmentSeparationCheck, { + variables: UNSCOPED, + fetch: () => ({ + projects: [{ projectId: 'nymph-prod-480000' }, { projectId: 'nymph-480000' }], + }), + }); + expect(out.passed).toHaveLength(0); + expect(out.failed).toHaveLength(1); + expect(out.failed[0]!.evidence).toMatchObject({ unclassifiedProjectCount: 1 }); + }); + it('fails when no project can be classified', async () => { const out = await runCheck(environmentSeparationCheck, { variables: UNSCOPED, diff --git a/packages/integration-platform/src/manifests/gcp/checks/environment-separation.ts b/packages/integration-platform/src/manifests/gcp/checks/environment-separation.ts index 262dd53df..1d1f924fd 100644 --- a/packages/integration-platform/src/manifests/gcp/checks/environment-separation.ts +++ b/packages/integration-platform/src/manifests/gcp/checks/environment-separation.ts @@ -1,14 +1,17 @@ import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + environmentAliasEvidence, + parseEnvironmentAliases, + type EnvironmentAlias, +} from '../../environment-aliases'; import { classifyEnvironment, + classifyEnvironmentWithAliases, confirmsEnvironmentSeparation, envTagValues, } from '../../environment-classification'; -import { - remediationForReadFailure, - toHttpReadFailure, -} from '../../http-read-failure'; +import { remediationForReadFailure, toHttpReadFailure } from '../../http-read-failure'; interface GcpProject { projectId: string; @@ -32,11 +35,20 @@ interface ResolvedProjects { * (`-`/`_`/`.`) doesn't matter. */ export function classifyProjectEnv(project: GcpProject): string | null { - return classifyEnvironment([ - ...envTagValues(project.labels), - project.projectId, - project.name, - ]); + return classifyEnvironment([...envTagValues(project.labels), project.projectId, project.name]); +} + +function classifyProjectEnvWithAliases({ + project, + aliases, +}: { + project: GcpProject; + aliases: readonly EnvironmentAlias[]; +}): string | null { + return classifyEnvironmentWithAliases({ + candidates: [...envTagValues(project.labels), project.projectId, project.name], + aliases, + }); } /** The user-selected project scope (`project_ids` variable), trimmed. */ @@ -63,9 +75,7 @@ async function resolveProjects(ctx: CheckContext): Promise { let readError: string | undefined; for (const id of selected) { try { - const project = await ctx.fetch( - `/v1/projects/${encodeURIComponent(id)}`, - ); + const project = await ctx.fetch(`/v1/projects/${encodeURIComponent(id)}`); if (project && typeof project.projectId === 'string') { projects.push(project); } @@ -81,9 +91,7 @@ async function resolveProjects(ctx: CheckContext): Promise { let pageToken: string | undefined; let pages = 0; do { - const tokenParam = pageToken - ? `&pageToken=${encodeURIComponent(pageToken)}` - : ''; + const tokenParam = pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ''; const data = await ctx.fetch<{ projects?: GcpProject[]; nextPageToken?: string; @@ -91,8 +99,7 @@ async function resolveProjects(ctx: CheckContext): Promise { `/v1/projects?filter=${encodeURIComponent('lifecycleState:ACTIVE')}&pageSize=100${tokenParam}`, ); for (const p of data.projects ?? []) projects.push(p); - pageToken = - typeof data.nextPageToken === 'string' ? data.nextPageToken : undefined; + pageToken = typeof data.nextPageToken === 'string' ? data.nextPageToken : undefined; pages++; } while (pageToken && pages < 20); @@ -100,7 +107,7 @@ async function resolveProjects(ctx: CheckContext): Promise { } const GUIDANCE = - 'Separate production and non-production workloads into distinct GCP projects and label each with an `environment` label (e.g. environment=production, environment=staging). If you separate environments another way (e.g. VPCs or folders), upload a console screenshot or architecture diagram as evidence.'; + 'Separate production and non-production workloads into distinct GCP projects and label each with an `environment` label (e.g. environment=production, environment=staging). If your organization uses different environment names, configure Environment aliases (e.g. release=production). If you separate environments another way (e.g. VPCs or folders), upload a console screenshot or architecture diagram as evidence.'; /** * Separation of Environments check (heuristic). GCP's recommended pattern is a @@ -145,14 +152,18 @@ export const environmentSeparationCheck: IntegrationCheck = { } const { projects, truncated, readError } = resolved; + const aliasesConfig = parseEnvironmentAliases(ctx.variables); + if (aliasesConfig.invalidEntries.length > 0) { + ctx.warn('GCP env-separation: ignored invalid environment aliases', { + invalidEntries: aliasesConfig.invalidEntries, + }); + } if (projects.length === 0) { // A read failure (scoped projects unreadable) is "could not verify"; a // genuinely empty footprint is "no projects". ctx.fail({ - title: readError - ? 'Could not verify environment separation' - : 'No GCP projects detected', + title: readError ? 'Could not verify environment separation' : 'No GCP projects detected', description: readError ? `Selected GCP projects could not be read (${readError}), so environment separation could not be evaluated.` : 'No GCP projects were in scope, so environment separation could not be evaluated.', @@ -169,24 +180,30 @@ export const environmentSeparationCheck: IntegrationCheck = { const classified = projects.map((p) => ({ projectId: p.projectId, - environment: classifyProjectEnv(p), + environment: classifyProjectEnvWithAliases({ + project: p, + aliases: aliasesConfig.aliases, + }), })); const detected = [ - ...new Set( - classified - .map((c) => c.environment) - .filter((e): e is string => e !== null), - ), + ...new Set(classified.map((c) => c.environment).filter((e): e is string => e !== null)), ]; const sample = classified.slice(0, 50).map((c) => ({ projectId: c.projectId, environment: c.environment ?? 'unclassified', })); + const unclassifiedProjectCount = classified.filter((c) => c.environment === null).length; + + const coverageGaps: string[] = []; + if (truncated) { + coverageGaps.push('project discovery hit the page cap, so not all projects were evaluated'); + } + if (readError) { + coverageGaps.push('some selected projects could not be read'); + } - // A confirmed pass requires production + a non-production environment. - // Truncation/read gaps cannot turn a confirmed pass into a wrong one - // (scanning more projects only ADDS environments), so a pass stands. - if (confirmsEnvironmentSeparation(detected)) { + const separationDetected = confirmsEnvironmentSeparation(detected); + if (coverageGaps.length === 0 && separationDetected) { ctx.pass({ title: 'Environments separated across projects', description: `Detected production separated from non-production across ${projects.length} GCP project(s): ${detected.join(', ')}.`, @@ -196,6 +213,8 @@ export const environmentSeparationCheck: IntegrationCheck = { detectedEnvironments: detected, projectCount: projects.length, projects: sample, + unclassifiedProjectCount, + ...environmentAliasEvidence(aliasesConfig), }, }); return; @@ -203,25 +222,22 @@ export const environmentSeparationCheck: IntegrationCheck = { // Could not confirm. Surface any incomplete coverage so a partial footprint // is never presented as a complete "not separated" verdict. - const coverageGaps: string[] = []; - if (truncated) { - coverageGaps.push( - 'project discovery hit the page cap, so not all projects were evaluated', - ); - } - if (readError) { - coverageGaps.push('some selected projects could not be read'); - } + const unclassifiedDetail = + unclassifiedProjectCount > 0 + ? `; ${unclassifiedProjectCount} project(s) were unclassified and need an environment label or environment token in the project name` + : ''; const base = detected.length === 0 ? `No GCP project could be classified by environment across ${projects.length} project(s)` - : `Detected environment(s) ${detected.join(', ')}, but could not confirm a production environment separated from a non-production one across ${projects.length} project(s)`; + : separationDetected + ? `Detected production separated from non-production in the scanned GCP projects (${detected.join(', ')}), but coverage is incomplete across ${projects.length} project(s)` + : `Detected environment(s) ${detected.join(', ')}, but could not confirm a production environment separated from a non-production one across ${projects.length} project(s)`; ctx.fail({ title: coverageGaps.length > 0 ? 'Could not verify environment separation' : 'Could not confirm environment separation', - description: `${base}${coverageGaps.length ? ` (${coverageGaps.join('; ')})` : ''}.`, + description: `${base}${unclassifiedDetail}${coverageGaps.length ? ` (${coverageGaps.join('; ')})` : ''}.`, resourceType: 'gcp-environment-separation', resourceId: 'projects', severity: 'medium', @@ -229,8 +245,10 @@ export const environmentSeparationCheck: IntegrationCheck = { evidence: { detectedEnvironments: detected, projectCount: projects.length, + unclassifiedProjectCount, ...(truncated ? { discoveryTruncated: true } : {}), projects: sample, + ...environmentAliasEvidence(aliasesConfig), }, }); }, diff --git a/packages/integration-platform/src/manifests/gcp/index.ts b/packages/integration-platform/src/manifests/gcp/index.ts index d9c0b84f0..575fba0a9 100644 --- a/packages/integration-platform/src/manifests/gcp/index.ts +++ b/packages/integration-platform/src/manifests/gcp/index.ts @@ -1,4 +1,5 @@ import type { IntegrationManifest } from '../../types'; +import { environmentAliasesVariable } from '../environment-aliases'; import { cloudMonitoringAlertingCheck, cloudSqlBackupsCheck, @@ -158,6 +159,7 @@ This is industry standard - all GCP security monitoring tools use the same scope } }, }, + environmentAliasesVariable, ], checks: [