Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'bun:test';
import {
classifyEnvironment,
confirmsEnvironmentSeparation,
envTagValues,
} from '../environment-classification';

describe('confirmsEnvironmentSeparation — requires prod + non-prod', () => {
it('passes only with production AND a non-production environment', () => {
expect(confirmsEnvironmentSeparation(['production', 'development'])).toBe(true);
expect(confirmsEnvironmentSeparation(['production', 'staging', 'test'])).toBe(true);
});

it('fails on two non-production environments (no production)', () => {
expect(confirmsEnvironmentSeparation(['development', 'staging'])).toBe(false);
});

it('fails on production alone, or a single environment, or none', () => {
expect(confirmsEnvironmentSeparation(['production'])).toBe(false);
expect(confirmsEnvironmentSeparation(['development'])).toBe(false);
expect(confirmsEnvironmentSeparation([])).toBe(false);
});
});

describe('classifyEnvironment — token-exact matching', () => {
it('classifies common environment tokens', () => {
expect(classifyEnvironment(['myapp-prod'])).toBe('production');
expect(classifyEnvironment(['web-staging'])).toBe('staging');
expect(classifyEnvironment(['svc-dev'])).toBe('development');
expect(classifyEnvironment(['api-qa'])).toBe('test');
expect(classifyEnvironment(['demo'])).toBe('sandbox');
});

it('handles ANY separator including underscore (the bug the reviewer caught)', () => {
expect(classifyEnvironment(['myapp_prod'])).toBe('production');
expect(classifyEnvironment(['prod_network'])).toBe('production');
expect(classifyEnvironment(['dev_network'])).toBe('development');
expect(classifyEnvironment(['myapp.prod'])).toBe('production');
expect(classifyEnvironment(['rg/staging'])).toBe('staging');
});

it('does NOT false-match substrings (product/developer/etc.)', () => {
expect(classifyEnvironment(['product-catalog'])).toBeNull();
expect(classifyEnvironment(['developer-portal'])).toBeNull();
expect(classifyEnvironment(['data-warehouse'])).toBeNull();
expect(classifyEnvironment(['prod123'])).toBeNull(); // not a clean token
});

it('treats preprod as staging, not production', () => {
expect(classifyEnvironment(['app-preprod'])).toBe('staging');
expect(classifyEnvironment(['preprod'])).toBe('staging');
});

it('is case-insensitive and skips empty/undefined candidates', () => {
expect(classifyEnvironment(['PROD'])).toBe('production');
expect(classifyEnvironment([undefined, '', 'svc-dev'])).toBe('development');
});

it('returns the first matching candidate (authoritative source first)', () => {
// an explicit env value passed first wins over a later name
expect(classifyEnvironment(['production', 'thing-dev'])).toBe('production');
});

it('returns null when nothing matches', () => {
expect(classifyEnvironment(['backend', 'frontend', 'vpc-0abc'])).toBeNull();
});
});

describe('classifyEnvironment — negated/qualified production (cubic finding)', () => {
it('classifies separated negated production as NON-production, not production', () => {
expect(classifyEnvironment(['non-prod'])).toBe('non-production');
expect(classifyEnvironment(['non_prod'])).toBe('non-production');
expect(classifyEnvironment(['non.prod'])).toBe('non-production');
expect(classifyEnvironment(['not-prod'])).toBe('non-production');
expect(classifyEnvironment(['myapp-non-prod'])).toBe('non-production');
expect(classifyEnvironment(['non-production'])).toBe('non-production');
expect(classifyEnvironment(['NON-PROD'])).toBe('non-production'); // case-insensitive
});

it('classifies joined non-production spellings as NON-production', () => {
expect(classifyEnvironment(['nonprod'])).toBe('non-production');
expect(classifyEnvironment(['notprod'])).toBe('non-production');
expect(classifyEnvironment(['nonprd'])).toBe('non-production');
expect(classifyEnvironment(['notprd'])).toBe('non-production');
expect(classifyEnvironment(['nonproduction'])).toBe('non-production');
expect(classifyEnvironment(['notproduction'])).toBe('non-production');
expect(classifyEnvironment(['app-nonprod'])).toBe('non-production');
});

it('classifies pre-prod as staging (consistent with joined "preprod")', () => {
expect(classifyEnvironment(['pre-prod'])).toBe('staging');
expect(classifyEnvironment(['pre_prod'])).toBe('staging');
expect(classifyEnvironment(['app-pre-prod'])).toBe('staging');
expect(classifyEnvironment(['preprod'])).toBe('staging');
});

it('still classifies plain production (negation needs an ADJACENT qualifier)', () => {
expect(classifyEnvironment(['prod'])).toBe('production');
expect(classifyEnvironment(['myapp-prod'])).toBe('production');
// a "non" that does not immediately precede a prod token must NOT negate
expect(classifyEnvironment(['prod-non-critical'])).toBe('production');
});

it('end-to-end: prod + non-prod now CONFIRMS separation (was a false fail)', () => {
// Pre-fix, "non-prod" classified as production, so detected={production}
// and separation failed despite a real prod/non-prod split.
const detected = [
...new Set(['prod-vpc', 'non-prod-vpc'].map((n) => classifyEnvironment([n]))),
].filter((e): e is string => e !== null);
expect(detected).toContain('production');
expect(detected).toContain('non-production');
expect(confirmsEnvironmentSeparation(detected)).toBe(true);
});

it('end-to-end: a non-prod-only footprint does NOT fabricate production (was a false pass)', () => {
// Pre-fix, "non-prod-staging" classified as production, so dev + that string
// passed as if production existed.
const detected = [
...new Set(['dev', 'non-prod-staging'].map((n) => classifyEnvironment([n]))),
].filter((e): e is string => e !== null);
expect(detected).not.toContain('production');
expect(confirmsEnvironmentSeparation(detected)).toBe(false);
});
});

describe('envTagValues — only env-key tags, case-insensitive', () => {
it('reads environment-indicating keys regardless of case', () => {
expect(envTagValues({ Environment: 'production' })).toEqual(['production']);
});

it('returns values in env-key PRIORITY order, not tag insertion order', () => {
// `environment` outranks `stage` even though `stage` is inserted first.
expect(envTagValues({ stage: 'dev', environment: 'prod' })).toEqual(['prod', 'dev']);
// so the authoritative key wins classification
expect(classifyEnvironment(envTagValues({ stage: 'dev', environment: 'prod' }))).toBe(
'production',
);
});

it('ignores non-environment tags (false-positive guard)', () => {
expect(envTagValues({ team: 'dev-team', costCenter: 'prod-123' })).toEqual([]);
});

it('returns [] for undefined tags', () => {
expect(envTagValues(undefined)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'bun:test';
import { evaluateCloudTrail } from '../cloudtrail';
import { evaluateSecurityGroups } from '../ec2';
import {
buildEnvironmentSeparationOutcomes,
classifyVpcEnv,
evaluateEnvironmentSeparation,
} from '../environment-separation';
import {
evaluateAccountSummary,
evaluateIamAccount,
Expand Down Expand Up @@ -953,3 +958,109 @@ describe('account-level findings carry AWS account attribution (cubic finding on
expect(failed[0]!.description).toContain('AWS account 123456789012');
});
});

describe('AWS environment separation', () => {
it('classifyVpcEnv: env tag wins, then Name tag, incl. underscore', () => {
expect(classifyVpcEnv([{ Key: 'Environment', Value: 'production' }])).toBe('production');
expect(classifyVpcEnv([{ Key: 'Name', Value: 'prod-vpc' }])).toBe('production');
expect(classifyVpcEnv([{ Key: 'Name', Value: 'vpc_dev' }])).toBe('development');
});

it('classifyVpcEnv: ignores non-env tags (no fabricated environment)', () => {
expect(classifyVpcEnv([{ Key: 'team', Value: 'dev-team' }])).toBeNull();
expect(classifyVpcEnv(undefined)).toBeNull();
});

it('passes on production + non-production, without claiming cross-account isolation', () => {
const out = evaluateEnvironmentSeparation([
{ vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' },
{ vpcId: 'vpc-2', region: 'us-east-1', environment: 'development' },
]);
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('pass');
expect(out[0]!.description).toMatch(/not cross-account isolation/);
});

it('fails when only non-production environments are present (no production)', () => {
const out = evaluateEnvironmentSeparation([
{ vpcId: 'vpc-1', region: 'us-east-1', environment: 'development' },
{ vpcId: 'vpc-2', region: 'us-east-1', environment: 'staging' },
]);
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('fail');
});

it('fails (low) with guidance when only one environment is detected', () => {
const out = evaluateEnvironmentSeparation([
{ vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' },
]);
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('fail');
expect(out[0]!.severity).toBe('low');
expect(out[0]!.remediation).toMatch(/separate AWS account per environment/);
});

it('fails when no VPC can be classified', () => {
const out = evaluateEnvironmentSeparation([
{ vpcId: 'vpc-1', region: 'us-east-1', environment: null },
{ vpcId: 'vpc-2', region: 'us-east-1', environment: null },
]);
expect(out[0]!.kind).toBe('fail');
});

it('fails (low) with guidance when there are no non-default VPCs', () => {
const out = evaluateEnvironmentSeparation([]);
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('fail');
expect(out[0]!.severity).toBe('low');
expect(out[0]!.evidence).toMatchObject({ vpcCount: 0 });
});
});

describe('buildEnvironmentSeparationOutcomes — region failures vs verdict (cubic finding)', () => {
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', () => {
const out = buildEnvironmentSeparationOutcomes(
[
{ vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' },
{ vpcId: 'vpc-2', region: 'us-east-1', environment: 'development' },
],
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');
});

it('surfaces the region failure alongside an UNconfirmed verdict (both negative)', () => {
const out = buildEnvironmentSeparationOutcomes(
[{ vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' }],
regionFailures,
);
expect(out.length).toBeGreaterThanOrEqual(2);
expect(out.every((o) => o.kind === 'fail')).toBe(true);
expect(out.some((o) => /Could not verify VPCs in some regions/.test(o.title))).toBe(true);
});

it('returns only the region-failure finding when zero VPCs were read', () => {
const out = buildEnvironmentSeparationOutcomes([], regionFailures);
expect(out).toHaveLength(1);
expect(out[0]!.title).toMatch(/Could not verify VPCs in some regions/);
});

it('with no region failures, returns the separation verdict unchanged', () => {
const out = buildEnvironmentSeparationOutcomes(
[
{ vpcId: 'vpc-1', region: 'us-east-1', environment: 'production' },
{ vpcId: 'vpc-2', region: 'us-east-1', environment: 'staging' },
],
[],
);
expect(out).toHaveLength(1);
expect(out[0]!.kind).toBe('pass');
});
});
Loading
Loading