diff --git a/apps/api/src/cloud-security/providers/aws/iam-root-access-keys.spec.ts b/apps/api/src/cloud-security/providers/aws/iam-root-access-keys.spec.ts new file mode 100644 index 0000000000..a118588d8d --- /dev/null +++ b/apps/api/src/cloud-security/providers/aws/iam-root-access-keys.spec.ts @@ -0,0 +1,332 @@ +import { + GenerateCredentialReportCommand, + GetCredentialReportCommand, + IAMClient, +} from '@aws-sdk/client-iam'; +import { + checkRootAccessKeys, + findRootAccountRow, + getCredentialReport, +} from './iam-root-access-keys'; + +const CREDENTIAL_REPORT_HEADER = [ + 'user', + 'arn', + 'user_creation_time', + 'password_enabled', + 'password_last_used', + 'password_last_changed', + 'password_next_rotation', + 'mfa_active', + 'access_key_1_active', + 'access_key_1_last_rotated', + 'access_key_1_last_used_date', + 'access_key_1_last_used_region', + 'access_key_1_last_used_service', + 'access_key_2_active', + 'access_key_2_last_rotated', + 'access_key_2_last_used_date', + 'access_key_2_last_used_region', + 'access_key_2_last_used_service', + 'cert_1_active', + 'cert_1_last_rotated', + 'cert_2_active', + 'cert_2_last_rotated', +].join(','); + +function buildRootRow(opts: { + key1Active: boolean; + key2Active: boolean; +}): string { + // Column order matches CREDENTIAL_REPORT_HEADER. + return [ + '', + 'arn:aws:iam::123456789012:root', + '2024-01-01T00:00:00+00:00', + 'not_supported', + '2024-01-15T12:00:00+00:00', + 'not_supported', + 'not_supported', + 'true', + opts.key1Active ? 'true' : 'false', + 'N/A', + 'N/A', + 'N/A', + 'N/A', + opts.key2Active ? 'true' : 'false', + 'N/A', + 'N/A', + 'N/A', + 'N/A', + 'false', + 'N/A', + 'false', + 'N/A', + ].join(','); +} + +function buildCsv(opts: { + rootKey1Active?: boolean; + rootKey2Active?: boolean; + includeRoot?: boolean; + extraUserLines?: string[]; +}): string { + const lines = [CREDENTIAL_REPORT_HEADER]; + if (opts.includeRoot !== false) { + lines.push( + buildRootRow({ + key1Active: opts.rootKey1Active ?? false, + key2Active: opts.rootKey2Active ?? false, + }), + ); + } + if (opts.extraUserLines) lines.push(...opts.extraUserLines); + return lines.join('\n'); +} + +type SendHandler = (command: unknown) => unknown; + +function buildIam(handler: SendHandler): IAMClient { + return { + send: jest.fn((command: unknown) => { + const result = handler(command); + if (result instanceof Error) return Promise.reject(result); + return Promise.resolve(result); + }), + } as unknown as IAMClient; +} + +function buildIamReturningCsv(csv: string): IAMClient { + return buildIam((command) => { + if (command instanceof GenerateCredentialReportCommand) return {}; + if (command instanceof GetCredentialReportCommand) { + return { Content: Buffer.from(csv, 'utf-8') }; + } + return {}; + }); +} + +describe('findRootAccountRow', () => { + it('returns null for an empty string', () => { + expect(findRootAccountRow('')).toBeNull(); + }); + + it('returns null for a header-only CSV', () => { + expect(findRootAccountRow(CREDENTIAL_REPORT_HEADER)).toBeNull(); + }); + + it('returns null when no row is present', () => { + const csv = buildCsv({ + includeRoot: false, + extraUserLines: ['user1,arn:aws:iam::123:user/user1,N/A,true'], + }); + expect(findRootAccountRow(csv)).toBeNull(); + }); + + it('returns the parsed root row keyed by header column names', () => { + const csv = buildCsv({ rootKey1Active: true, rootKey2Active: false }); + const row = findRootAccountRow(csv); + expect(row).not.toBeNull(); + expect(row!.user).toBe(''); + expect(row!.access_key_1_active).toBe('true'); + expect(row!.access_key_2_active).toBe('false'); + }); +}); + +describe('checkRootAccessKeys', () => { + it('passes when root has only an inactive access key (customer scenario)', async () => { + // Customer reports zero active keys; AWS may still have an inactive one + // attached. The old GetAccountSummary check flagged this as critical; + // the new credential-report check correctly says "no active keys". + const iam = buildIamReturningCsv( + buildCsv({ rootKey1Active: false, rootKey2Active: false }), + ); + + const findings = await checkRootAccessKeys({ + iam, + accountId: '615477685532', + }); + + expect(findings).toHaveLength(1); + expect(findings[0].id).toBe('iam-root-access-keys'); + expect(findings[0].passed).toBe(true); + expect(findings[0].severity).toBe('info'); + expect(findings[0].title).toBe('Root account has no active access keys'); + }); + + it('fails when root has access_key_1 active', async () => { + const iam = buildIamReturningCsv( + buildCsv({ rootKey1Active: true, rootKey2Active: false }), + ); + + const findings = await checkRootAccessKeys({ + iam, + accountId: '615477685532', + }); + + expect(findings).toHaveLength(1); + expect(findings[0].passed).toBe(false); + expect(findings[0].severity).toBe('critical'); + expect(findings[0].title).toBe('Root account has active access keys'); + expect(findings[0].remediation).toContain('[MANUAL]'); + }); + + it('fails when root has access_key_2 active', async () => { + const iam = buildIamReturningCsv( + buildCsv({ rootKey1Active: false, rootKey2Active: true }), + ); + + const findings = await checkRootAccessKeys({ iam }); + + expect(findings).toHaveLength(1); + expect(findings[0].passed).toBe(false); + expect(findings[0].severity).toBe('critical'); + }); + + it('fails when both root keys are active', async () => { + const iam = buildIamReturningCsv( + buildCsv({ rootKey1Active: true, rootKey2Active: true }), + ); + + const findings = await checkRootAccessKeys({ iam }); + + expect(findings).toHaveLength(1); + expect(findings[0].passed).toBe(false); + }); + + it('returns [] (skips check) when the credential report has no root row', async () => { + const iam = buildIamReturningCsv( + buildCsv({ + includeRoot: false, + extraUserLines: ['user1,arn:aws:iam::123:user/user1,N/A,true'], + }), + ); + + const findings = await checkRootAccessKeys({ iam }); + + expect(findings).toEqual([]); + }); + + it('returns [] when GetCredentialReport fails with a non-recoverable error', async () => { + const iam = buildIam((command) => { + if (command instanceof GenerateCredentialReportCommand) return {}; + if (command instanceof GetCredentialReportCommand) { + return Object.assign(new Error('AccessDenied'), { name: 'AccessDenied' }); + } + return {}; + }); + + const findings = await checkRootAccessKeys({ iam }); + + expect(findings).toEqual([]); + }); + + it('uses provided accountId in resourceId when present', async () => { + const iam = buildIamReturningCsv( + buildCsv({ rootKey1Active: true, rootKey2Active: false }), + ); + + const findings = await checkRootAccessKeys({ + iam, + accountId: '999888777666', + }); + + expect(findings[0].resourceId).toBe('999888777666'); + expect(findings[0].evidence).toEqual({ + awsAccountId: '999888777666', + service: 'IAM', + findingKey: 'iam-root-access-keys', + }); + }); + + it('falls back to "root" resourceId when accountId is missing', async () => { + const iam = buildIamReturningCsv( + buildCsv({ rootKey1Active: true, rootKey2Active: false }), + ); + + const findings = await checkRootAccessKeys({ iam }); + + expect(findings[0].resourceId).toBe('root'); + }); +}); + +describe('getCredentialReport', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the decoded CSV when the report is ready immediately', async () => { + const csv = buildCsv({ rootKey1Active: false }); + const iam = buildIamReturningCsv(csv); + + const result = await getCredentialReport({ iam }); + + expect(result).toBe(csv); + }); + + it('polls and succeeds after a CredentialReportNotReadyException', async () => { + let getCalls = 0; + const csv = buildCsv({ rootKey1Active: false }); + const iam = buildIam((command) => { + if (command instanceof GenerateCredentialReportCommand) return {}; + if (command instanceof GetCredentialReportCommand) { + getCalls += 1; + if (getCalls === 1) { + return Object.assign(new Error('not ready'), { + name: 'CredentialReportNotReadyException', + }); + } + return { Content: Buffer.from(csv, 'utf-8') }; + } + return {}; + }); + + const promise = getCredentialReport({ iam }); + // Advance past the 1s retry delay so the second attempt runs. + await jest.advanceTimersByTimeAsync(1500); + const result = await promise; + + expect(result).toBe(csv); + expect(getCalls).toBe(2); + }); + + it('returns null when the report is never ready within the retry budget', async () => { + const iam = buildIam((command) => { + if (command instanceof GenerateCredentialReportCommand) return {}; + if (command instanceof GetCredentialReportCommand) { + return Object.assign(new Error('still generating'), { + name: 'CredentialReportNotReadyException', + }); + } + return {}; + }); + + const promise = getCredentialReport({ iam }); + // Advance past the full retry window (10 attempts × 1s). + await jest.advanceTimersByTimeAsync(15000); + const result = await promise; + + expect(result).toBeNull(); + }); + + it('survives GenerateCredentialReport failing (e.g., already-in-progress)', async () => { + const csv = buildCsv({ rootKey1Active: false }); + const iam = buildIam((command) => { + if (command instanceof GenerateCredentialReportCommand) { + return Object.assign(new Error('throttled'), { name: 'Throttling' }); + } + if (command instanceof GetCredentialReportCommand) { + return { Content: Buffer.from(csv, 'utf-8') }; + } + return {}; + }); + + const result = await getCredentialReport({ iam }); + + expect(result).toBe(csv); + }); +}); diff --git a/apps/api/src/cloud-security/providers/aws/iam-root-access-keys.ts b/apps/api/src/cloud-security/providers/aws/iam-root-access-keys.ts new file mode 100644 index 0000000000..c178e54fa9 --- /dev/null +++ b/apps/api/src/cloud-security/providers/aws/iam-root-access-keys.ts @@ -0,0 +1,157 @@ +import { + GenerateCredentialReportCommand, + GetCredentialReportCommand, + IAMClient, +} from '@aws-sdk/client-iam'; +import type { SecurityFinding } from '../../cloud-security.service'; + +const REPORT_POLL_MAX_ATTEMPTS = 10; +const REPORT_POLL_DELAY_MS = 1000; +const ROOT_USER_MARKER = ''; + +/** + * Returns the `iam-root-access-keys` finding for the given AWS account. + * + * Uses the IAM Credential Report (GenerateCredentialReport + GetCredentialReport) + * to read the `` row's `access_key_1_active` / `access_key_2_active` + * columns — the same source AWS Console's "Root user has no active access keys" + * recommendation uses. + * + * Previously used GetAccountSummary's `AccountAccessKeysPresent`, which returns 1 + * if the root account has any keys — active OR inactive — producing a critical + * false positive for accounts with only disabled root keys. + */ +export async function checkRootAccessKeys(opts: { + iam: IAMClient; + accountId?: string; +}): Promise { + const csv = await getCredentialReport({ iam: opts.iam }); + if (!csv) return []; + + const rootRow = findRootAccountRow(csv); + if (!rootRow) return []; + + const hasActiveKey = + rootRow.access_key_1_active === 'true' || + rootRow.access_key_2_active === 'true'; + + if (hasActiveKey) { + return [ + buildFinding({ + title: 'Root account has active access keys', + description: + 'The root account has at least one active access key. Root access keys provide unrestricted access and should be removed.', + severity: 'critical', + accountId: opts.accountId, + passed: false, + }), + ]; + } + + return [ + buildFinding({ + title: 'Root account has no active access keys', + description: 'The root account does not have active access keys.', + severity: 'info', + accountId: opts.accountId, + passed: true, + }), + ]; +} + +/** + * Triggers credential-report generation and polls until the CSV is available or + * the operation fails. Returns null when the report can't be retrieved — the + * caller should treat that as "skip the check" rather than fail the scan. + * + * Exported for testing. + */ +export async function getCredentialReport(opts: { + iam: IAMClient; +}): Promise { + try { + await opts.iam.send(new GenerateCredentialReportCommand({})); + } catch { + // Non-fatal: a recent report may already exist; fall through to retrieval. + } + + for (let attempt = 0; attempt < REPORT_POLL_MAX_ATTEMPTS; attempt++) { + try { + const resp = await opts.iam.send(new GetCredentialReportCommand({})); + if (resp.Content) { + return Buffer.from(resp.Content).toString('utf-8'); + } + return null; + } catch (error) { + const name = (error as { name?: string }).name; + if ( + name === 'CredentialReportNotReadyException' || + name === 'CredentialReportNotPresentException' + ) { + await sleep(REPORT_POLL_DELAY_MS); + continue; + } + return null; + } + } + + return null; +} + +/** + * Parses the credential-report CSV and returns the `` row as a + * map of column-name → value. Returns null if the CSV is malformed or has no + * root row. + * + * Exported for testing. + */ +export function findRootAccountRow( + csv: string, +): Record | null { + const lines = csv.trim().split('\n'); + if (lines.length < 2) return null; + + const header = lines[0].split(','); + const rootLine = lines.find((line) => + line.startsWith(`${ROOT_USER_MARKER},`), + ); + if (!rootLine) return null; + + const cols = rootLine.split(','); + const row: Record = {}; + for (let i = 0; i < header.length; i++) { + row[header[i]] = cols[i] ?? ''; + } + return row; +} + +function buildFinding(opts: { + title: string; + description: string; + severity: SecurityFinding['severity']; + accountId?: string; + passed: boolean; +}): SecurityFinding { + return { + id: 'iam-root-access-keys', + title: opts.title, + description: opts.description, + severity: opts.severity, + resourceType: 'AwsAccount', + resourceId: opts.accountId || 'root', + remediation: opts.passed + ? undefined + : '[MANUAL] Cannot be auto-fixed. Root access keys must be deleted manually through the AWS Console root account security credentials page.', + evidence: { + awsAccountId: opts.accountId, + service: 'IAM', + findingKey: 'iam-root-access-keys', + }, + createdAt: new Date().toISOString(), + passed: opts.passed, + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/api/src/cloud-security/providers/aws/iam.adapter.ts b/apps/api/src/cloud-security/providers/aws/iam.adapter.ts index e11423920b..2276ace00e 100644 --- a/apps/api/src/cloud-security/providers/aws/iam.adapter.ts +++ b/apps/api/src/cloud-security/providers/aws/iam.adapter.ts @@ -4,10 +4,10 @@ import { ListUsersCommand, ListMFADevicesCommand, ListAccessKeysCommand, - GetAccountSummaryCommand, } from '@aws-sdk/client-iam'; import type { SecurityFinding } from '../../cloud-security.service'; import type { AwsCredentials, AwsServiceAdapter } from './aws-service-adapter'; +import { checkRootAccessKeys } from './iam-root-access-keys'; const STALE_KEY_DAYS = 90; @@ -29,7 +29,7 @@ export class IamAdapter implements AwsServiceAdapter { this.checkPasswordPolicy(iam, accountId), this.checkUsersWithoutMfa(iam, accountId), this.checkStaleAccessKeys(iam, accountId), - this.checkRootAccessKeys(iam, accountId), + checkRootAccessKeys({ iam, accountId }), ]); for (const result of results) { @@ -217,47 +217,6 @@ export class IamAdapter implements AwsServiceAdapter { return findings; } - private async checkRootAccessKeys( - iam: IAMClient, - accountId?: string, - ): Promise { - const resp = await iam.send(new GetAccountSummaryCommand({})); - const summary = resp.SummaryMap; - - if (!summary) return []; - - const rootKeys = summary['AccountAccessKeysPresent']; - - if (rootKeys && rootKeys > 0) { - return [ - this.makeFinding({ - id: 'iam-root-access-keys', - title: 'Root account has active access keys', - description: - 'The root account has active access keys. Root access keys provide unrestricted access and should be removed.', - severity: 'critical', - resourceType: 'AwsAccount', - resourceId: accountId || 'root', - remediation: - '[MANUAL] Cannot be auto-fixed. Root access keys must be deleted manually through the AWS Console root account security credentials page.', - passed: false, - accountId, - }), - ]; - } - - return [ - this.makeFinding({ - id: 'iam-root-access-keys', - title: 'Root account has no active access keys', - description: 'The root account does not have active access keys.', - severity: 'info', - passed: true, - accountId, - }), - ]; - } - private async listAllUsers(iam: IAMClient) { const users: Array<{ UserName?: string;