Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -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);

Expand Down Expand Up @@ -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<string, unknown>) {
const passed: Array<{ description: string; evidence?: Record<string, unknown> }> = [];
const failed: Array<{ description: string; evidence?: Record<string, unknown> }> = [];
const ctx = {
credentials,
pass: (r: { description: string; evidence?: Record<string, unknown> }) =>
passed.push(r),
fail: (r: { description: string; evidence?: Record<string, unknown> }) =>
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
});
});
17 changes: 7 additions & 10 deletions packages/integration-platform/src/manifests/aws/checks/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>).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',
Expand Down Expand Up @@ -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({
Expand Down
39 changes: 34 additions & 5 deletions packages/integration-platform/src/manifests/aws/checks/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,26 +255,55 @@ export interface CheckOutcome {
evidence?: Record<string, unknown>;
}

/** 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<string, unknown>).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,
});
}
}
Expand Down
Loading