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
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,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<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
});

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)',
);
});
});
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
61 changes: 56 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,77 @@ 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;
}

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