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 51bc6b6b70..d3e46ce0ec 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 @@ -14,7 +14,14 @@ import { evaluateRdsEncryption, } from '../rds'; import { evaluateS3Encryption, evaluateS3PublicAccess } from '../s3'; -import { assumeAwsSession, resolveAwsCredentialInputs } from '../shared'; +import { + assumeAwsSession, + awsAccountIdFromCtx, + emitOutcomes, + resolveAwsCredentialInputs, + type CheckOutcome, +} from '../shared'; +import type { CheckContext } from '../../../../types'; const kinds = (os: { kind: string }[]) => os.map((o) => o.kind); @@ -441,3 +448,92 @@ describe('IAM/CloudTrail outcomes carry evidence (so the UI shows "View Evidence expect(rot[1]!.evidence?.rotationEnabled).toBe(false); }); }); + +// ── AWS account attribution (multi-account findings) ─────────────────────── + +function captureCtx(credentials: Record) { + const passed: Array<{ description: string; evidence?: Record }> = []; + const failed: Array<{ description: string; evidence?: Record }> = []; + const ctx = { + credentials, + 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 }; +} + +const PASS_OUTCOME: CheckOutcome = { + kind: 'pass', + title: 'Default encryption enabled: my-bucket', + description: 'Bucket "my-bucket" has default encryption enabled.', + resourceType: 'aws-s3-bucket', + resourceId: 'my-bucket', + evidence: { bucket: 'my-bucket', encrypted: true }, +}; + +describe('awsAccountIdFromCtx', () => { + it('extracts the 12-digit account id from the role ARN', () => { + expect( + awsAccountIdFromCtx({ + credentials: { roleArn: 'arn:aws:iam::123456789012:role/CompAIAuditor' }, + } as unknown as CheckContext), + ).toBe('123456789012'); + }); + + it('returns null when the role ARN is missing or malformed', () => { + expect( + awsAccountIdFromCtx({ credentials: {} } as unknown as CheckContext), + ).toBeNull(); + expect( + awsAccountIdFromCtx({ + credentials: { roleArn: 'not-an-arn' }, + } as unknown as CheckContext), + ).toBeNull(); + }); +}); + +describe('emitOutcomes — attributes findings to the AWS account', () => { + it('stamps the account id into evidence and the visible description', () => { + const { ctx, passed } = captureCtx({ + roleArn: 'arn:aws:iam::123456789012:role/CompAIAuditor', + }); + emitOutcomes(ctx, [PASS_OUTCOME]); + expect(passed).toHaveLength(1); + expect(passed[0]!.evidence?.awsAccountId).toBe('123456789012'); + expect(passed[0]!.evidence?.bucket).toBe('my-bucket'); // original evidence preserved + expect(passed[0]!.description).toContain('(AWS account 123456789012)'); + }); + + it('attributes a fail outcome too', () => { + const { ctx, failed } = captureCtx({ + roleArn: 'arn:aws:iam::999988887777:role/x', + }); + emitOutcomes(ctx, [{ ...PASS_OUTCOME, kind: 'fail', severity: 'high' }]); + expect(failed[0]!.evidence?.awsAccountId).toBe('999988887777'); + expect(failed[0]!.description).toContain('(AWS account 999988887777)'); + }); + + it('leaves findings unattributed for key-auth connections (no role ARN)', () => { + const { ctx, passed } = captureCtx({ + access_key_id: 'AKIA', + secret_access_key: 'secret', + }); + emitOutcomes(ctx, [PASS_OUTCOME]); + expect(passed[0]!.evidence?.awsAccountId).toBeUndefined(); + expect(passed[0]!.description).toBe(PASS_OUTCOME.description); // unchanged + }); + + it("includes the customer's connection name alongside the account when set", () => { + const { ctx, passed } = captureCtx({ + roleArn: 'arn:aws:iam::123456789012:role/CompAIAuditor', + connectionName: 'Production AWS', + }); + emitOutcomes(ctx, [PASS_OUTCOME]); + expect(passed[0]!.evidence?.awsConnectionName).toBe('Production AWS'); + expect(passed[0]!.description).toContain( + '(AWS account 123456789012 — Production AWS)', + ); + }); +}); diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts index 6038f09865..c6f05b1353 100644 --- a/packages/integration-platform/src/manifests/aws/checks/s3.ts +++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts @@ -10,7 +10,12 @@ import { } from '@aws-sdk/client-s3-control'; import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; -import { resolveAwsSessionOrFail, type CheckOutcome, emitOutcomes } from './shared'; +import { + awsAccountIdFromCtx, + resolveAwsSessionOrFail, + type CheckOutcome, + emitOutcomes, +} from './shared'; export interface BpaFlags { blockPublicAcls: boolean; @@ -184,14 +189,6 @@ async function gatherBuckets( return infos; } -/** Account ID from the connection's role ARN (arn:aws:iam::ACCOUNT:role/...). */ -function accountIdFromCtx(ctx: CheckContext): string | null { - const arn = (ctx.credentials as Record).roleArn; - if (typeof arn !== 'string') return null; - const parts = arn.split(':'); - return parts.length >= 5 && parts[4] ? parts[4] : null; -} - export const s3EncryptionCheck: IntegrationCheck = { id: 'aws-s3-encryption', name: 'S3 — default encryption enabled', @@ -252,7 +249,7 @@ export const s3PublicAccessCheck: IntegrationCheck = { // Account-level Block Public Access applies to every bucket. Read it once; // if denied/absent, fall back to bucket-level only (graceful). let accountBpa: BpaFlags | null = null; - const accountId = accountIdFromCtx(ctx); + const accountId = awsAccountIdFromCtx(ctx); if (accountId) { try { const s3control = new S3ControlClient({ diff --git a/packages/integration-platform/src/manifests/aws/checks/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts index 7581ca024d..84d0336f93 100644 --- a/packages/integration-platform/src/manifests/aws/checks/shared.ts +++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts @@ -255,26 +255,77 @@ export interface CheckOutcome { evidence?: Record; } -/** Map pure evaluator outcomes onto ctx.pass / ctx.fail. */ +/** + * The 12-digit AWS account ID from the connection's role ARN + * (`arn:aws:iam::ACCOUNT_ID:role/...`). Returns null for key-auth connections or + * when no role ARN is present. Used to attribute every finding to the AWS + * account it came from — essential when a customer connects multiple accounts. + */ +export function awsAccountIdFromCtx(ctx: CheckContext): string | null { + const arn = (ctx.credentials as Record).roleArn; + if (typeof arn !== 'string') return null; + const parts = arn.split(':'); + return parts.length >= 5 && parts[4] ? parts[4] : null; +} + +/** + * The friendly connection name the customer gave this AWS account when they + * connected it (the "Connection Name" field, e.g. "Production AWS"). It is the + * customer's OWN label — we do not infer prod/stage from AWS — so it's shown + * alongside the account id when present. Returns null when unset. + */ +export function awsConnectionNameFromCtx(ctx: CheckContext): string | null { + const name = (ctx.credentials as Record).connectionName; + return typeof name === 'string' && name.trim().length > 0 + ? name.trim() + : null; +} + +/** + * Map pure evaluator outcomes onto ctx.pass / ctx.fail. + * + * Every finding is attributed to the AWS account it came from: checks run once + * per connected account, so without this the UI shows a single merged list with + * no way to tell which account each resource belongs to (a customer-reported + * gap when multiple AWS accounts are connected). The account id is added to the + * evidence and surfaced in the visible description. + */ export function emitOutcomes(ctx: CheckContext, outcomes: CheckOutcome[]): void { + const accountId = awsAccountIdFromCtx(ctx); + const connectionName = awsConnectionNameFromCtx(ctx); + // "AWS account 123456789012 — Production AWS" (name only shown when set). + const label = accountId + ? `AWS account ${accountId}${connectionName ? ` — ${connectionName}` : ''}` + : null; + const describe = (description: string) => + label ? `${description} (${label})` : description; + const stamp = (evidence: Record | undefined) => + accountId + ? { + ...(evidence ?? {}), + awsAccountId: accountId, + ...(connectionName ? { awsConnectionName: connectionName } : {}), + } + : evidence; + for (const o of outcomes) { if (o.kind === 'pass') { ctx.pass({ title: o.title, - description: o.description, + description: describe(o.description), resourceType: o.resourceType, resourceId: o.resourceId, - evidence: o.evidence ?? {}, + evidence: stamp(o.evidence) ?? {}, }); } else { ctx.fail({ title: o.title, - description: o.description, + description: describe(o.description), resourceType: o.resourceType, resourceId: o.resourceId, severity: o.severity ?? 'medium', remediation: o.remediation ?? 'Review and remediate this finding.', - evidence: o.evidence, + evidence: stamp(o.evidence), }); } }