From 06a237d772beca1147ec1e3eed9d08d4c5dc5611 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 9 Jun 2026 10:35:48 -0400 Subject: [PATCH 1/2] fix(integration-platform): attribute AWS check findings to their AWS account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer connected 3 AWS accounts; the "S3 — default encryption enabled" check showed 31 buckets with no indication of which account each belongs to, and it was unclear how the other accounts are covered. Checks already run per connection (1 AWS account = 1 connection), so all accounts ARE scanned — but the account was never surfaced in the findings, so the UI shows a merged, unlabeled list. All six AWS checks (s3, cloudtrail, ec2, iam, kms, rds) emit through the shared emitOutcomes(), so stamp the account there in one place: derive the 12-digit account id from the connection's roleArn (awsAccountIdFromCtx, moved to shared) and add it to every finding's evidence (awsAccountId) and visible description ("… (AWS account 123456789012)"). No schema/API/UI/migration needed — the account now shows per result regardless of how the UI groups them, which also makes it clear all connected accounts are covered. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aws/checks/__tests__/aws-checks.test.ts | 86 ++++++++++++++++++- .../src/manifests/aws/checks/s3.ts | 17 ++-- .../src/manifests/aws/checks/shared.ts | 39 +++++++-- 3 files changed, 126 insertions(+), 16 deletions(-) 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..1790e065f8 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,80 @@ 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 + }); +}); 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..51c7023330 100644 --- a/packages/integration-platform/src/manifests/aws/checks/shared.ts +++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts @@ -255,26 +255,55 @@ 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; +} + +/** + * 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 describe = (description: string) => + accountId ? `${description} (AWS account ${accountId})` : description; + 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: accountId + ? { ...(o.evidence ?? {}), awsAccountId: accountId } + : (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: accountId + ? { ...(o.evidence ?? {}), awsAccountId: accountId } + : o.evidence, }); } } From 69c84c85badc226c40d9773d561548e20b84b6b0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 9 Jun 2026 11:38:59 -0400 Subject: [PATCH 2/2] fix(integration-platform): show the customer's AWS connection name alongside the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on the account-attribution stamp: when the customer named the connection (the "Connection Name" field, e.g. "Production AWS" — stored in credentials and mirrored to connection.metadata), surface that real label too. The finding description becomes "… (AWS account 123456789012 — Production AWS)" and evidence gains awsConnectionName. This is the customer's OWN label — we do not infer prod/stage from AWS (no reliable signal); falls back to the account id alone when no name is set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aws/checks/__tests__/aws-checks.test.ts | 12 +++++++ .../src/manifests/aws/checks/shared.ts | 36 +++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) 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 1790e065f8..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 @@ -524,4 +524,16 @@ describe('emitOutcomes — attributes findings to the AWS account', () => { 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/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts index 51c7023330..84d0336f93 100644 --- a/packages/integration-platform/src/manifests/aws/checks/shared.ts +++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts @@ -268,6 +268,19 @@ export function awsAccountIdFromCtx(ctx: CheckContext): string | null { 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. * @@ -279,8 +292,21 @@ export function awsAccountIdFromCtx(ctx: CheckContext): string | null { */ 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) => - accountId ? `${description} (AWS account ${accountId})` : description; + 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') { @@ -289,9 +315,7 @@ export function emitOutcomes(ctx: CheckContext, outcomes: CheckOutcome[]): void description: describe(o.description), resourceType: o.resourceType, resourceId: o.resourceId, - evidence: accountId - ? { ...(o.evidence ?? {}), awsAccountId: accountId } - : (o.evidence ?? {}), + evidence: stamp(o.evidence) ?? {}, }); } else { ctx.fail({ @@ -301,9 +325,7 @@ export function emitOutcomes(ctx: CheckContext, outcomes: CheckOutcome[]): void resourceId: o.resourceId, severity: o.severity ?? 'medium', remediation: o.remediation ?? 'Review and remediate this finding.', - evidence: accountId - ? { ...(o.evidence ?? {}), awsAccountId: accountId } - : o.evidence, + evidence: stamp(o.evidence), }); } }