diff --git a/apps/api/src/framework-editor/framework/dto/link-control.dto.ts b/apps/api/src/framework-editor/framework/dto/link-control.dto.ts new file mode 100644 index 0000000000..1a3d86088a --- /dev/null +++ b/apps/api/src/framework-editor/framework/dto/link-control.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsOptional, IsString } from 'class-validator'; + +export class LinkControlDto { + @ApiPropertyOptional({ + description: + 'Requirement ids (of this framework) to link the control to. ' + + 'Omit to link the control to every requirement in the framework ' + + '(legacy bulk behavior used by the CLI).', + type: [String], + example: ['frk_rq_abc123'], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + requirementIds?: string[]; +} diff --git a/apps/api/src/framework-editor/framework/framework.controller.ts b/apps/api/src/framework-editor/framework/framework.controller.ts index 0eb5733074..96dc63f43c 100644 --- a/apps/api/src/framework-editor/framework/framework.controller.ts +++ b/apps/api/src/framework-editor/framework/framework.controller.ts @@ -11,10 +11,11 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; import { CreateFrameworkDto } from './dto/create-framework.dto'; import { ImportFrameworkDto } from './dto/import-framework.dto'; +import { LinkControlDto } from './dto/link-control.dto'; import { UpdateFrameworkDto } from './dto/update-framework.dto'; import { FrameworkExportService } from './framework-export.service'; import { FrameworkEditorFrameworkService } from './framework.service'; @@ -100,12 +101,19 @@ export class FrameworkEditorFrameworkController { } @Post(':id/link-control/:controlId') - @ApiOperation({ summary: 'Link a control to a framework' }) + @ApiOperation({ + summary: 'Link a control to a framework', + description: + 'Links a control to the framework. Pass requirementIds to link only ' + + 'the selected requirements; omit it to link every requirement (legacy).', + }) + @ApiBody({ type: LinkControlDto, required: false }) async linkControl( @Param('id') id: string, @Param('controlId') controlId: string, + @Body() body: LinkControlDto = {}, ) { - return this.frameworkService.linkControl(id, controlId); + return this.frameworkService.linkControl(id, controlId, body.requirementIds); } @Post(':id/link-task/:taskId') diff --git a/apps/api/src/framework-editor/framework/framework.service.spec.ts b/apps/api/src/framework-editor/framework/framework.service.spec.ts new file mode 100644 index 0000000000..706c7916b6 --- /dev/null +++ b/apps/api/src/framework-editor/framework/framework.service.spec.ts @@ -0,0 +1,89 @@ +jest.mock('@db', () => { + const dbMock = { + frameworkEditorFramework: { + findUnique: jest.fn(), + }, + frameworkEditorRequirement: { + findMany: jest.fn(), + }, + frameworkEditorControlTemplate: { + update: jest.fn(), + }, + }; + return { db: dbMock, Prisma: { PrismaClientKnownRequestError: class {} } }; +}); + +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { db } from '@db'; +import { FrameworkEditorFrameworkService } from './framework.service'; + +const mockDb = db as jest.Mocked; + +describe('FrameworkEditorFrameworkService.linkControl', () => { + let service: FrameworkEditorFrameworkService; + + beforeEach(() => { + service = new FrameworkEditorFrameworkService(); + jest.clearAllMocks(); + (mockDb.frameworkEditorFramework.findUnique as jest.Mock).mockResolvedValue({ + id: 'frk_1', + requirements: [], + }); + (mockDb.frameworkEditorRequirement.findMany as jest.Mock).mockResolvedValue([ + { id: 'req_1' }, + { id: 'req_2' }, + { id: 'req_3' }, + ]); + (mockDb.frameworkEditorControlTemplate.update as jest.Mock).mockResolvedValue({ + id: 'ct_1', + }); + }); + + it('links only the selected requirements when requirementIds is provided', async () => { + await service.linkControl('frk_1', 'ct_1', ['req_2']); + + expect(mockDb.frameworkEditorControlTemplate.update).toHaveBeenCalledWith({ + where: { id: 'ct_1' }, + data: { requirements: { connect: [{ id: 'req_2' }] } }, + }); + }); + + it('links every framework requirement when requirementIds is omitted (legacy CLI path)', async () => { + await service.linkControl('frk_1', 'ct_1'); + + expect(mockDb.frameworkEditorControlTemplate.update).toHaveBeenCalledWith({ + where: { id: 'ct_1' }, + data: { + requirements: { connect: [{ id: 'req_1' }, { id: 'req_2' }, { id: 'req_3' }] }, + }, + }); + }); + + it('treats a null requirementIds (JSON null past @IsOptional) as link-all, not a crash', async () => { + await expect(service.linkControl('frk_1', 'ct_1', null)).resolves.toEqual({ + message: 'Control linked to framework', + }); + expect(mockDb.frameworkEditorControlTemplate.update).toHaveBeenCalledWith({ + where: { id: 'ct_1' }, + data: { + requirements: { connect: [{ id: 'req_1' }, { id: 'req_2' }, { id: 'req_3' }] }, + }, + }); + }); + + it('rejects requirement ids that do not belong to the framework', async () => { + await expect( + service.linkControl('frk_1', 'ct_1', ['req_2', 'req_outsider']), + ).rejects.toBeInstanceOf(BadRequestException); + expect(mockDb.frameworkEditorControlTemplate.update).not.toHaveBeenCalled(); + }); + + it('throws when the framework has no requirements at all', async () => { + (mockDb.frameworkEditorRequirement.findMany as jest.Mock).mockResolvedValue([]); + + await expect(service.linkControl('frk_1', 'ct_1')).rejects.toBeInstanceOf( + ConflictException, + ); + expect(mockDb.frameworkEditorControlTemplate.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/framework-editor/framework/framework.service.ts b/apps/api/src/framework-editor/framework/framework.service.ts index 26bc26a267..82754c574c 100644 --- a/apps/api/src/framework-editor/framework/framework.service.ts +++ b/apps/api/src/framework-editor/framework/framework.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, NotFoundException, ConflictException, @@ -231,25 +232,55 @@ export class FrameworkEditorFrameworkService { })); } - async linkControl(frameworkId: string, controlId: string) { + async linkControl( + frameworkId: string, + controlId: string, + requirementIds?: string[] | null, + ) { await this.findById(frameworkId); - const requirementIds = await db.frameworkEditorRequirement - .findMany({ where: { frameworkId }, select: { id: true } }) - .then((reqs) => reqs.map((r) => ({ id: r.id }))); + const frameworkRequirements = await db.frameworkEditorRequirement.findMany({ + where: { frameworkId }, + select: { id: true }, + }); - if (requirementIds.length === 0) { + if (frameworkRequirements.length === 0) { throw new ConflictException( 'Framework has no requirements to link the control to', ); } + // A control belongs to a framework only through its requirement links. When + // the caller passes requirementIds, link just those — this is the UI path, + // so adding an existing control no longer fans out to every requirement. + // When omitted (undefined/null), link all: the documented legacy bulk + // behavior the CLI uses. (@IsOptional lets a JSON null through, so guard it.) + let targetIds: { id: string }[]; + if (requirementIds === undefined || requirementIds === null) { + targetIds = frameworkRequirements.map((r) => ({ id: r.id })); + } else { + const frameworkRequirementIds = new Set( + frameworkRequirements.map((r) => r.id), + ); + const invalid = requirementIds.filter( + (id) => !frameworkRequirementIds.has(id), + ); + if (invalid.length > 0) { + throw new BadRequestException( + `Requirement(s) not in this framework: ${invalid.join(', ')}`, + ); + } + targetIds = requirementIds.map((id) => ({ id })); + } + await db.frameworkEditorControlTemplate.update({ where: { id: controlId }, - data: { requirements: { connect: requirementIds } }, + data: { requirements: { connect: targetIds } }, }); - this.logger.log(`Linked control ${controlId} to framework ${frameworkId}`); + this.logger.log( + `Linked control ${controlId} to framework ${frameworkId} (${targetIds.length} requirement(s))`, + ); return { message: 'Control linked to framework' }; } diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx index 326df586d5..9de5e4cee5 100644 --- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx @@ -16,6 +16,7 @@ import { AddExistingItemDialog, type ExistingItemRaw, } from '../../components/AddExistingItemDialog'; +import type { RequirementOption } from '../../components/ControlRequirementSelect'; import { ManageFamiliesDialog } from './ManageFamiliesDialog'; import { ComboboxCell, @@ -74,6 +75,24 @@ async function fetchRequirementsForFramework( ); } +// Requirement options for the "Add Existing Control" picker, oldest-first so +// the just-created requirement sits at the bottom of the list. +async function fetchFrameworkRequirementOptions( + frameworkId: string, +): Promise { + const framework = await apiClient<{ + requirements: Array<{ + id: string; + name: string; + identifier: string | null; + createdAt?: string; + }>; + }>(`/framework/${frameworkId}`); + return [...framework.requirements] + .sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? '')) + .map((r) => ({ id: r.id, name: r.name, identifier: r.identifier })); +} + async function fetchAllTaskTemplates(): Promise { return apiClient('/task-template'); } @@ -484,6 +503,7 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId itemType="control" existingItemIds={existingControlIds} fetchAllItems={fetchAllControlsForDialog} + fetchRequirements={() => fetchFrameworkRequirementOptions(frameworkId)} /> )} diff --git a/apps/framework-editor/app/components/AddExistingItemDialog.tsx b/apps/framework-editor/app/components/AddExistingItemDialog.tsx index 786f56da4e..1b555913e1 100644 --- a/apps/framework-editor/app/components/AddExistingItemDialog.tsx +++ b/apps/framework-editor/app/components/AddExistingItemDialog.tsx @@ -16,23 +16,17 @@ import { Loader2, Search } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { + extractApiErrorMessage, + extractFrameworkNames, + type ExistingItemRaw, +} from './add-existing-item-helpers'; +import { + ControlRequirementSelect, + type RequirementOption, +} from './ControlRequirementSelect'; -interface ControlTemplateWithFrameworks { - id: string; - name: string; - requirements?: Array<{ - framework?: { id: string; name: string | null } | null; - }>; -} - -export interface ExistingItemRaw { - id: string; - name: string; - controlTemplates?: ControlTemplateWithFrameworks[]; - requirements?: Array<{ - framework?: { id: string; name: string | null } | null; - }>; -} +export type { ExistingItemRaw }; interface ExistingItemDisplay { id: string; @@ -56,45 +50,9 @@ interface AddExistingItemDialogProps { itemType: ItemType; existingItemIds: Set; fetchAllItems: () => Promise; -} - -// apiClient throws Error(); pull the NestJS `message` -// field out so the user sees e.g. "Framework has no requirements to link -// the control to" instead of a generic failure. -function extractApiErrorMessage(error: unknown): string | null { - if (!(error instanceof Error) || !error.message) return null; - try { - const parsed: unknown = JSON.parse(error.message); - if (parsed && typeof parsed === 'object' && 'message' in parsed) { - const message = (parsed as { message?: unknown }).message; - if (typeof message === 'string') return message; - } - } catch { - // Not JSON — fall through to the raw message. - } - return error.message; -} - -function extractFrameworkNames(item: ExistingItemRaw): string[] { - const names = new Set(); - - if (item.controlTemplates) { - for (const ct of item.controlTemplates) { - if (ct.requirements) { - for (const req of ct.requirements) { - if (req.framework?.name) names.add(req.framework.name); - } - } - } - } - - if (item.requirements) { - for (const req of item.requirements) { - if (req.framework?.name) names.add(req.framework.name); - } - } - - return Array.from(names); + // When provided (controls only), linking runs a second step where the user + // picks which requirements to link to — instead of linking to all of them. + fetchRequirements?: () => Promise; } export function AddExistingItemDialog({ @@ -104,6 +62,7 @@ export function AddExistingItemDialog({ itemType, existingItemIds, fetchAllItems, + fetchRequirements, }: AddExistingItemDialogProps) { const router = useRouter(); const config = ITEM_TYPE_CONFIG[itemType]; @@ -114,11 +73,20 @@ export function AddExistingItemDialog({ const [search, setSearch] = useState(''); const [linkedIds, setLinkedIds] = useState>(new Set()); + // Requirement-selection step (controls only). + const [pendingControl, setPendingControl] = useState( + null, + ); + const [requirements, setRequirements] = useState([]); + const [isLoadingRequirements, setIsLoadingRequirements] = useState(false); + useEffect(() => { if (!isOpen) { setSearch(''); setAllItems([]); setLinkedIds(new Set()); + setPendingControl(null); + setRequirements([]); return; } @@ -149,15 +117,17 @@ export function AddExistingItemDialog({ ); }, [allItems, existingItemIds, linkedIds, search]); - const handleLink = useCallback( - async (item: ExistingItemDisplay) => { - setLinkingId(item.id); + const linkControl = useCallback( + async (controlId: string, controlName: string, requirementIds?: string[]) => { + setLinkingId(controlId); try { - await apiClient(`/framework/${frameworkId}/${config.linkPath}/${item.id}`, { + await apiClient(`/framework/${frameworkId}/${config.linkPath}/${controlId}`, { method: 'POST', + ...(requirementIds ? { body: JSON.stringify({ requirementIds }) } : {}), }); - setLinkedIds((prev) => new Set(prev).add(item.id)); - toast.success(`${config.label} "${item.name}" linked successfully`); + setLinkedIds((prev) => new Set(prev).add(controlId)); + setPendingControl(null); + toast.success(`${config.label} "${controlName}" linked successfully`); router.refresh(); } catch (error) { toast.error( @@ -171,6 +141,28 @@ export function AddExistingItemDialog({ [frameworkId, config, router], ); + const handleLink = useCallback( + async (item: ExistingItemDisplay) => { + // Controls get a requirement-selection step so they link only to the + // chosen requirements rather than all of them. + if (fetchRequirements) { + setPendingControl(item); + setIsLoadingRequirements(true); + try { + setRequirements(await fetchRequirements()); + } catch { + toast.error('Failed to load requirements'); + setPendingControl(null); + } finally { + setIsLoadingRequirements(false); + } + return; + } + await linkControl(item.id, item.name); + }, + [fetchRequirements, linkControl], + ); + return ( @@ -182,6 +174,19 @@ export function AddExistingItemDialog({ + {pendingControl ? ( + setPendingControl(null)} + onConfirm={(requirementIds) => + linkControl(pendingControl.id, pendingControl.name, requirementIds) + } + /> + ) : ( + <>
)} + + )}
); diff --git a/apps/framework-editor/app/components/ControlRequirementSelect.test.tsx b/apps/framework-editor/app/components/ControlRequirementSelect.test.tsx new file mode 100644 index 0000000000..0ab53b516d --- /dev/null +++ b/apps/framework-editor/app/components/ControlRequirementSelect.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ControlRequirementSelect, + type RequirementOption, +} from './ControlRequirementSelect'; + +vi.mock('@trycompai/ui', () => ({ + Button: ({ + children, + variant: _v, + size: _s, + ...props + }: { variant?: string; size?: string } & React.ComponentProps<'button'>) => ( + + ), + Input: (props: React.ComponentProps<'input'>) => , + ScrollArea: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const REQS: RequirementOption[] = [ + { id: 'req_1', identifier: 'AC-1', name: 'First' }, + { id: 'req_2', identifier: 'AC-2', name: 'Second' }, + { id: 'req_3', identifier: 'AC-2b', name: 'Newest' }, +]; + +function setup(overrides: Partial[0]> = {}) { + const onConfirm = vi.fn(); + const onBack = vi.fn(); + render( + , + ); + return { onConfirm, onBack }; +} + +describe('ControlRequirementSelect', () => { + beforeEach(() => vi.clearAllMocks()); + + it('disables the confirm button until at least one requirement is selected', () => { + setup(); + const confirm = screen.getByRole('button', { + name: /Link to .*requirement/i, + }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + + fireEvent.click(screen.getByText('AC-2b — Newest')); + expect(confirm.disabled).toBe(false); + }); + + it('confirms with only the selected requirement ids', () => { + const { onConfirm } = setup(); + fireEvent.click(screen.getByText('AC-1 — First')); + fireEvent.click(screen.getByText('AC-2b — Newest')); + fireEvent.click(screen.getByRole('button', { name: /Link to 2 requirements/i })); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm.mock.calls[0][0].sort()).toEqual(['req_1', 'req_3']); + }); + + it('renders requirements in the given order (newest last)', () => { + setup(); + const labels = screen + .getAllByRole('button') + .map((b) => b.textContent) + .filter((t) => t?.includes('—')); + expect(labels[labels.length - 1]).toContain('Newest'); + }); + + it('filters by search text', () => { + setup(); + fireEvent.change(screen.getByPlaceholderText('Search requirements...'), { + target: { value: 'newest' }, + }); + expect(screen.queryByText('AC-1 — First')).toBeNull(); + expect(screen.getByText('AC-2b — Newest')).toBeTruthy(); + }); + + it('calls onBack', () => { + const { onBack } = setup(); + fireEvent.click(screen.getByRole('button', { name: /Back/i })); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/apps/framework-editor/app/components/ControlRequirementSelect.tsx b/apps/framework-editor/app/components/ControlRequirementSelect.tsx new file mode 100644 index 0000000000..fd388d9fe8 --- /dev/null +++ b/apps/framework-editor/app/components/ControlRequirementSelect.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { Button, Input, ScrollArea } from '@trycompai/ui'; +import { ArrowLeft, Check, Loader2, Search } from 'lucide-react'; +import { useMemo, useState } from 'react'; + +export interface RequirementOption { + id: string; + identifier: string | null; + name: string; +} + +interface ControlRequirementSelectProps { + controlName: string; + requirements: RequirementOption[]; + isLoading: boolean; + isLinking: boolean; + onBack: () => void; + onConfirm: (requirementIds: string[]) => void; +} + +function requirementLabel(req: RequirementOption): string { + if (req.identifier && req.name) return `${req.identifier} — ${req.name}`; + return req.name || req.identifier || 'Unnamed requirement'; +} + +/** + * Second step of "Add Existing Control": pick which of the framework's + * requirements to link the control to. Requirements arrive oldest-first, so the + * just-created one sits at the bottom of the list. + */ +export function ControlRequirementSelect({ + controlName, + requirements, + isLoading, + isLinking, + onBack, + onConfirm, +}: ControlRequirementSelectProps) { + const [search, setSearch] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const filtered = useMemo(() => { + const term = search.toLowerCase().trim(); + if (!term) return requirements; + return requirements.filter((req) => + requirementLabel(req).toLowerCase().includes(term), + ); + }, [requirements, search]); + + const toggle = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( +
+
+ + + Link {controlName} to + requirements + +
+ +
+ + setSearch(e.target.value)} + className="rounded-sm pl-8" + autoFocus + /> +
+ + + {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ {search + ? `No requirements matching "${search}"` + : 'This framework has no requirements yet'} +
+ ) : ( +
+ {filtered.map((req) => { + const selected = selectedIds.has(req.id); + return ( + + ); + })} +
+ )} +
+ +
+ + {selectedIds.size} selected + + +
+
+ ); +} diff --git a/apps/framework-editor/app/components/add-existing-item-helpers.test.ts b/apps/framework-editor/app/components/add-existing-item-helpers.test.ts new file mode 100644 index 0000000000..0796107d37 --- /dev/null +++ b/apps/framework-editor/app/components/add-existing-item-helpers.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + extractApiErrorMessage, + extractFrameworkNames, + type ExistingItemRaw, +} from './add-existing-item-helpers'; + +describe('extractApiErrorMessage', () => { + it('pulls the NestJS message field out of a JSON error body', () => { + const error = new Error( + JSON.stringify({ message: 'Requirement(s) not in this framework: x', statusCode: 400 }), + ); + expect(extractApiErrorMessage(error)).toBe('Requirement(s) not in this framework: x'); + }); + + it('falls back to the raw message for non-JSON errors', () => { + expect(extractApiErrorMessage(new Error('boom'))).toBe('boom'); + }); + + it('returns null for non-Error / empty inputs', () => { + expect(extractApiErrorMessage(undefined)).toBeNull(); + expect(extractApiErrorMessage(new Error(''))).toBeNull(); + }); +}); + +describe('extractFrameworkNames', () => { + it('collects unique framework names from linked requirements', () => { + const item: ExistingItemRaw = { + id: 'ct_1', + name: 'Control', + requirements: [ + { framework: { id: 'f1', name: 'SOC 2' } }, + { framework: { id: 'f2', name: 'ISO 27001' } }, + { framework: { id: 'f1', name: 'SOC 2' } }, + ], + }; + expect(extractFrameworkNames(item).sort()).toEqual(['ISO 27001', 'SOC 2']); + }); + + it('also reads framework names nested under controlTemplates', () => { + const item: ExistingItemRaw = { + id: 'p_1', + name: 'Policy', + controlTemplates: [ + { id: 'ct', name: 'C', requirements: [{ framework: { id: 'f', name: 'HIPAA' } }] }, + ], + }; + expect(extractFrameworkNames(item)).toEqual(['HIPAA']); + }); + + it('returns an empty array when there are no framework links', () => { + expect(extractFrameworkNames({ id: 'x', name: 'X' })).toEqual([]); + }); +}); diff --git a/apps/framework-editor/app/components/add-existing-item-helpers.ts b/apps/framework-editor/app/components/add-existing-item-helpers.ts new file mode 100644 index 0000000000..6e9b51c742 --- /dev/null +++ b/apps/framework-editor/app/components/add-existing-item-helpers.ts @@ -0,0 +1,44 @@ +export interface ExistingItemRaw { + id: string; + name: string; + controlTemplates?: Array<{ + id: string; + name: string; + requirements?: Array<{ framework?: { id: string; name: string | null } | null }>; + }>; + requirements?: Array<{ framework?: { id: string; name: string | null } | null }>; +} + +// apiClient throws Error(); pull the NestJS `message` +// field out so the user sees e.g. "Framework has no requirements to link +// the control to" instead of a generic failure. +export function extractApiErrorMessage(error: unknown): string | null { + if (!(error instanceof Error) || !error.message) return null; + try { + const parsed: unknown = JSON.parse(error.message); + if (parsed && typeof parsed === 'object' && 'message' in parsed) { + const message = (parsed as { message?: unknown }).message; + if (typeof message === 'string') return message; + } + } catch { + // Not JSON — fall through to the raw message. + } + return error.message; +} + +// Frameworks an item already belongs to (shown as badges), derived from the +// frameworks of its linked requirements. +export function extractFrameworkNames(item: ExistingItemRaw): string[] { + const names = new Set(); + + for (const ct of item.controlTemplates ?? []) { + for (const req of ct.requirements ?? []) { + if (req.framework?.name) names.add(req.framework.name); + } + } + for (const req of item.requirements ?? []) { + if (req.framework?.name) names.add(req.framework.name); + } + + return Array.from(names); +} 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 d3e46ce0ec..02ad8ef7e8 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,11 +14,13 @@ import { evaluateRdsEncryption, } from '../rds'; import { evaluateS3Encryption, evaluateS3PublicAccess } from '../s3'; +import { gatherBuckets } from '../s3-buckets'; import { assumeAwsSession, awsAccountIdFromCtx, emitOutcomes, resolveAwsCredentialInputs, + toReadFailure, type CheckOutcome, } from '../shared'; import type { CheckContext } from '../../../../types'; @@ -217,6 +219,261 @@ describe('AWS S3 evaluators', () => { ); expect(out[0]!.kind).toBe('pass'); }); + + it('public access: a non-permission read failure surfaces the real error instead of claiming a missing permission', () => { + const out = evaluateS3PublicAccess( + [ + { + name: 'b', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: false, + bucketBpa: null, + publicAccessReadFailure: { error: 'TimeoutError: socket hang up', denied: false }, + }, + ], + null, + ); + expect(out[0]!.kind).toBe('fail'); + expect(out[0]!.severity).toBe('medium'); + expect(out[0]!.evidence).toMatchObject({ readError: 'TimeoutError: socket hang up' }); + expect(out[0]!.description).toContain('TimeoutError: socket hang up'); + // must NOT send the customer on a permissions hunt for a transient failure + expect(out[0]!.remediation).not.toContain('Grant s3:GetBucketPublicAccessBlock'); + expect(out[0]!.remediation).toMatch(/re-run/i); + }); + + it('public access: an AccessDenied read failure keeps the grant-permission remediation and records the error', () => { + const out = evaluateS3PublicAccess( + [ + { + name: 'b', + encrypted: false, + encryptionDetermined: true, + publicAccessDetermined: false, + bucketBpa: null, + publicAccessReadFailure: { error: 'AccessDenied: Access Denied', denied: true }, + }, + ], + null, + ); + expect(out[0]!.kind).toBe('fail'); + expect(out[0]!.remediation).toContain('Grant s3:GetBucketPublicAccessBlock'); + expect(out[0]!.evidence).toMatchObject({ readError: 'AccessDenied: Access Denied' }); + }); + + it('public access: indeterminate without failure detail keeps the legacy permission hint', () => { + const out = evaluateS3PublicAccess( + [{ name: 'b', encrypted: false, encryptionDetermined: true, publicAccessDetermined: false, bucketBpa: null }], + null, + ); + expect(out[0]!.kind).toBe('fail'); + expect(out[0]!.remediation).toContain('Grant s3:GetBucketPublicAccessBlock'); + }); + + it('encryption: read failures carry the real error and remediation matches the failure kind', () => { + const out = evaluateS3Encryption([ + { + name: 'transient', + encrypted: false, + encryptionDetermined: false, + publicAccessDetermined: true, + bucketBpa: null, + encryptionReadFailure: { error: 'TimeoutError: socket hang up', denied: false }, + }, + { + name: 'denied', + encrypted: false, + encryptionDetermined: false, + publicAccessDetermined: true, + bucketBpa: null, + encryptionReadFailure: { error: 'AccessDenied: Access Denied', denied: true }, + }, + ]); + expect(out[0]!.evidence).toMatchObject({ readError: 'TimeoutError: socket hang up' }); + expect(out[0]!.remediation).not.toContain('Grant s3:GetEncryptionConfiguration'); + expect(out[0]!.remediation).toMatch(/re-run/i); + expect(out[1]!.remediation).toContain('Grant s3:GetEncryptionConfiguration'); + expect(out[1]!.evidence).toMatchObject({ readError: 'AccessDenied: Access Denied' }); + }); +}); + +describe('gatherBuckets — per-bucket region routing', () => { + type FakeClient = { send: (cmd: { constructor: { name: string }; input: Record }) => Promise }; + const asS3 = (c: FakeClient) => c as unknown as import('@aws-sdk/client-s3').S3Client; + + const BPA_OK = { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + IgnorePublicAcls: true, + BlockPublicPolicy: true, + RestrictPublicBuckets: true, + }, + }; + + it('routes reads for cross-region buckets to that region client (customer bug: cross-region 301 dependence)', async () => { + const calls: Array<{ client: string; bucket: unknown }> = []; + const defaultClient: FakeClient = { + send: async (cmd) => { + if (cmd.constructor.name === 'ListBucketsCommand') { + return { + Buckets: [ + { Name: 'local-bucket', BucketRegion: 'us-east-2' }, + { Name: 'remote-bucket', BucketRegion: 'us-east-1' }, + ], + }; + } + calls.push({ client: 'default', bucket: cmd.input.Bucket }); + return BPA_OK; + }, + }; + const remoteClient: FakeClient = { + send: async (cmd) => { + calls.push({ client: 'us-east-1', bucket: cmd.input.Bucket }); + return BPA_OK; + }, + }; + + const requestedRegions: string[] = []; + const buckets = await gatherBuckets(asS3(defaultClient), { + encryption: false, + publicAccess: true, + clientForRegion: (region) => { + requestedRegions.push(region); + return region === 'us-east-1' ? asS3(remoteClient) : asS3(defaultClient); + }, + }); + + expect(buckets).toHaveLength(2); + expect(buckets.every((b) => b.publicAccessDetermined)).toBe(true); + expect(calls).toEqual([ + { client: 'default', bucket: 'local-bucket' }, + { client: 'us-east-1', bucket: 'remote-bucket' }, + ]); + expect(requestedRegions).toEqual(['us-east-2', 'us-east-1']); + }); + + it('falls back to the legacy ListBuckets (no regions, default client) when MaxBuckets is rejected', async () => { + let sawLegacyList = false; + const client: FakeClient = { + send: async (cmd) => { + if (cmd.constructor.name === 'ListBucketsCommand') { + if (cmd.input.MaxBuckets) { + const err = new Error('MaxBuckets not supported'); + err.name = 'InvalidArgument'; + throw err; + } + sawLegacyList = true; + return { Buckets: [{ Name: 'b1' }] }; + } + return BPA_OK; + }, + }; + + const buckets = await gatherBuckets(asS3(client), { + encryption: false, + publicAccess: true, + clientForRegion: () => { + throw new Error('must not be called when bucket region is unknown'); + }, + }); + + expect(sawLegacyList).toBe(true); + expect(buckets).toEqual([ + { + name: 'b1', + encrypted: false, + encryptionDetermined: true, + encryptionReadFailure: undefined, + bucketBpa: { + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: true, + restrictPublicBuckets: true, + }, + publicAccessDetermined: true, + publicAccessReadFailure: undefined, + }, + ]); + }); + + it('paginates ListBuckets via ContinuationToken', async () => { + const client: FakeClient = { + send: async (cmd) => { + if (cmd.constructor.name === 'ListBucketsCommand') { + return cmd.input.ContinuationToken + ? { Buckets: [{ Name: 'page2', BucketRegion: 'us-east-2' }] } + : { + Buckets: [{ Name: 'page1', BucketRegion: 'us-east-2' }], + ContinuationToken: 'next', + }; + } + return BPA_OK; + }, + }; + + const buckets = await gatherBuckets(asS3(client), { + encryption: false, + publicAccess: true, + }); + expect(buckets.map((b) => b.name)).toEqual(['page1', 'page2']); + }); + + it('records the read failure and keeps going when a per-bucket read throws', async () => { + const client: FakeClient = { + send: async (cmd) => { + if (cmd.constructor.name === 'ListBucketsCommand') { + return { Buckets: [{ Name: 'bad' }, { Name: 'good' }] }; + } + if (cmd.input.Bucket === 'bad') { + const err = new Error('socket hang up'); + err.name = 'TimeoutError'; + throw err; + } + return BPA_OK; + }, + }; + + const logs: string[] = []; + const buckets = await gatherBuckets(asS3(client), { + encryption: false, + publicAccess: true, + log: (m) => logs.push(m), + }); + expect(buckets[0]).toMatchObject({ + name: 'bad', + publicAccessDetermined: false, + publicAccessReadFailure: { error: 'TimeoutError: socket hang up', denied: false }, + }); + expect(buckets[1]!.publicAccessDetermined).toBe(true); + expect(logs.some((m) => m.includes('TimeoutError: socket hang up'))).toBe(true); + }); +}); + +describe('toReadFailure — read-error classification', () => { + it('classifies AccessDenied by error name', () => { + const err = new Error('Access Denied'); + err.name = 'AccessDenied'; + expect(toReadFailure(err)).toEqual({ error: 'AccessDenied: Access Denied', denied: true }); + }); + + it('classifies 403 by http status even with a generic name', () => { + const err = Object.assign(new Error('nope'), { + name: 'S3ServiceException', + $metadata: { httpStatusCode: 403 }, + }); + expect(toReadFailure(err).denied).toBe(true); + }); + + it('treats network/timeout errors as not denied', () => { + const err = new Error('socket hang up'); + err.name = 'TimeoutError'; + expect(toReadFailure(err)).toEqual({ error: 'TimeoutError: socket hang up', denied: false }); + }); + + it('stringifies non-Error throwables', () => { + expect(toReadFailure('boom')).toEqual({ error: 'boom', denied: false }); + }); }); describe('AWS EC2 security-group evaluator', () => { diff --git a/packages/integration-platform/src/manifests/aws/checks/s3-buckets.ts b/packages/integration-platform/src/manifests/aws/checks/s3-buckets.ts new file mode 100644 index 0000000000..7dea96ab48 --- /dev/null +++ b/packages/integration-platform/src/manifests/aws/checks/s3-buckets.ts @@ -0,0 +1,181 @@ +import { + GetBucketEncryptionCommand, + GetPublicAccessBlockCommand, + ListBucketsCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { toReadFailure, type AwsSession, type ReadFailure } from './shared'; + +export interface BpaFlags { + blockPublicAcls: boolean; + ignorePublicAcls: boolean; + blockPublicPolicy: boolean; + restrictPublicBuckets: boolean; +} + +export interface S3BucketInfo { + name: string; + encrypted: boolean; + /** false when encryption status couldn't be read (error) → excluded from eval */ + encryptionDetermined: boolean; + /** bucket-level Block Public Access flags, or null when none configured */ + bucketBpa: BpaFlags | null; + /** false when bucket-level Block Public Access couldn't be read (error) → excluded from eval */ + publicAccessDetermined: boolean; + /** set when the encryption read failed — the real error, surfaced in evidence */ + encryptionReadFailure?: ReadFailure; + /** set when the Block Public Access read failed — the real error, surfaced in evidence */ + publicAccessReadFailure?: ReadFailure; +} + +/** + * One S3 client per region, created lazily. Per-bucket reads must go to the + * bucket's own regional endpoint: cross-region calls depend on S3 301 + * redirects whose x-amz-bucket-region header is not guaranteed — observed in + * prod failing exactly the buckets outside the connection's region. + */ +export function regionalS3Clients(session: AwsSession): { + s3: S3Client; + clientForRegion: (region: string) => S3Client; +} { + const clients = new Map(); + const clientForRegion = (region: string) => { + let client = clients.get(region); + if (!client) { + client = new S3Client({ + region, + credentials: session.credentials, + followRegionRedirects: true, + // Reads are idempotent; extra attempts ride out transient network or + // throttling failures during the scheduled-run herd. + maxAttempts: 5, + }); + clients.set(region, client); + } + return client; + }; + // regions is guaranteed non-empty by resolveAwsCredentialInputs + return { s3: clientForRegion(session.regions[0]), clientForRegion }; +} + +/** + * List every bucket with its region. MaxBuckets opts into the paginated + * ListBuckets API — the only form that populates BucketRegion. Falls back to + * the legacy unpaginated form (no regions) if the partition rejects it, so a + * genuine ListBuckets failure still surfaces as the account-level finding. + */ +export async function listAllBuckets( + s3: S3Client, + log?: (message: string) => void, +): Promise> { + try { + const out: Array<{ name: string; region?: string }> = []; + let token: string | undefined; + do { + const page = await s3.send( + new ListBucketsCommand({ MaxBuckets: 1000, ContinuationToken: token }), + ); + for (const b of page.Buckets ?? []) { + if (typeof b.Name === 'string') { + out.push({ name: b.Name, region: b.BucketRegion }); + } + } + token = page.ContinuationToken; + } while (token); + return out; + } catch (err) { + log?.( + `S3: paginated ListBuckets failed (${toReadFailure(err).error}); falling back to legacy form — bucket regions unknown, cross-region reads depend on S3 redirects`, + ); + const list = await s3.send(new ListBucketsCommand({})); + return (list.Buckets ?? []) + .map((b) => b.Name) + .filter((n): n is string => typeof n === 'string') + .map((name) => ({ name })); + } +} + +export async function gatherBuckets( + s3: S3Client, + opts: { + encryption: boolean; + publicAccess: boolean; + log?: (message: string) => void; + /** regional client for buckets outside the default region */ + clientForRegion?: (region: string) => S3Client; + }, +): Promise { + const buckets = await listAllBuckets(s3, opts.log); + + const infos: S3BucketInfo[] = []; + for (const { name, region } of buckets) { + const client = + region && opts.clientForRegion ? opts.clientForRegion(region) : s3; + let encrypted = false; + let encryptionDetermined = true; + let encryptionReadFailure: ReadFailure | undefined; + let bucketBpa: BpaFlags | null = null; + let publicAccessDetermined = true; + let publicAccessReadFailure: ReadFailure | undefined; + + if (opts.encryption) { + try { + const enc = await client.send(new GetBucketEncryptionCommand({ Bucket: name })); + encrypted = (enc.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0; + } catch (err) { + // "no encryption configured" is a genuine finding; any other error + // (permissions/transient) is indeterminate → exclude from evaluation. + if ( + err instanceof Error && + /ServerSideEncryptionConfigurationNotFound/i.test(err.name) + ) { + encrypted = false; + } else { + encryptionDetermined = false; + encryptionReadFailure = toReadFailure(err); + opts.log?.( + `S3 ${name}: encryption read failed — ${encryptionReadFailure.error}`, + ); + } + } + } + if (opts.publicAccess) { + try { + const pab = await client.send(new GetPublicAccessBlockCommand({ Bucket: name })); + const c = pab.PublicAccessBlockConfiguration; + bucketBpa = { + blockPublicAcls: Boolean(c?.BlockPublicAcls), + ignorePublicAcls: Boolean(c?.IgnorePublicAcls), + blockPublicPolicy: Boolean(c?.BlockPublicPolicy), + restrictPublicBuckets: Boolean(c?.RestrictPublicBuckets), + }; + } catch (err) { + // "no bucket-level config" is a genuine finding (account-level may still + // cover it); any other error (AccessDenied/transient) is indeterminate → + // exclude from evaluation so we don't report a false public-access failure. + if ( + err instanceof Error && + /NoSuchPublicAccessBlockConfiguration/i.test(err.name) + ) { + bucketBpa = null; // no bucket-level config + } else { + publicAccessDetermined = false; + publicAccessReadFailure = toReadFailure(err); + opts.log?.( + `S3 ${name}: Block Public Access read failed — ${publicAccessReadFailure.error}`, + ); + } + } + } + infos.push({ + name, + encrypted, + encryptionDetermined, + encryptionReadFailure, + bucketBpa, + publicAccessDetermined, + publicAccessReadFailure, + }); + } + return infos; +} diff --git a/packages/integration-platform/src/manifests/aws/checks/s3.ts b/packages/integration-platform/src/manifests/aws/checks/s3.ts index c6f05b1353..dc91453ac3 100644 --- a/packages/integration-platform/src/manifests/aws/checks/s3.ts +++ b/packages/integration-platform/src/manifests/aws/checks/s3.ts @@ -1,15 +1,15 @@ -import { - GetBucketEncryptionCommand, - GetPublicAccessBlockCommand, - ListBucketsCommand, - S3Client, -} from '@aws-sdk/client-s3'; import { GetPublicAccessBlockCommand as GetAccountPublicAccessBlockCommand, S3ControlClient, } from '@aws-sdk/client-s3-control'; import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + gatherBuckets, + regionalS3Clients, + type BpaFlags, + type S3BucketInfo, +} from './s3-buckets'; import { awsAccountIdFromCtx, resolveAwsSessionOrFail, @@ -17,23 +17,10 @@ import { emitOutcomes, } from './shared'; -export interface BpaFlags { - blockPublicAcls: boolean; - ignorePublicAcls: boolean; - blockPublicPolicy: boolean; - restrictPublicBuckets: boolean; -} +export type { BpaFlags, S3BucketInfo } from './s3-buckets'; -export interface S3BucketInfo { - name: string; - encrypted: boolean; - /** false when encryption status couldn't be read (error) → excluded from eval */ - encryptionDetermined: boolean; - /** bucket-level Block Public Access flags, or null when none configured */ - bucketBpa: BpaFlags | null; - /** false when bucket-level Block Public Access couldn't be read (error) → excluded from eval */ - publicAccessDetermined: boolean; -} +const TRANSIENT_READ_REMEDIATION = + 'The read failed with the error shown in the evidence — not a missing permission. Re-run the check; if it keeps failing, contact support.'; const FLAG_KEYS: Array = [ 'blockPublicAcls', @@ -52,17 +39,27 @@ export function evaluateS3Encryption(buckets: S3BucketInfo[]): CheckOutcome[] { if (!b.encryptionDetermined) { // Read failed → unverified. Don't assert a false "no encryption" (high), // but don't silently drop it either (that would let an all-unreadable - // account pass with no findings). + // account pass with no findings). Only claim a missing permission when + // the error actually was one — otherwise surface the real error. + const failure = b.encryptionReadFailure; + const transient = failure !== undefined && !failure.denied; return { kind: 'fail', title: `Could not verify encryption: ${b.name}`, - description: `Encryption status for bucket "${b.name}" could not be read, so it is unverified.`, + description: failure + ? `Encryption status for bucket "${b.name}" could not be read (${failure.error}), so it is unverified.` + : `Encryption status for bucket "${b.name}" could not be read, so it is unverified.`, resourceType: 'aws-s3-bucket', resourceId: b.name, severity: 'medium', - remediation: - 'Grant s3:GetEncryptionConfiguration to the integration role so default encryption can be verified, then re-run.', - evidence: { bucket: b.name, encryptionDetermined: false }, + remediation: transient + ? TRANSIENT_READ_REMEDIATION + : 'Grant s3:GetEncryptionConfiguration to the integration role so default encryption can be verified, then re-run.', + evidence: { + bucket: b.name, + encryptionDetermined: false, + ...(failure ? { readError: failure.error } : {}), + }, }; } return b.encrypted @@ -93,16 +90,25 @@ export function evaluateS3PublicAccess( ): CheckOutcome[] { return buckets.map((b): CheckOutcome => { if (!b.publicAccessDetermined) { + const failure = b.publicAccessReadFailure; + const transient = failure !== undefined && !failure.denied; return { kind: 'fail', title: `Could not verify public access: ${b.name}`, - description: `Block Public Access status for bucket "${b.name}" could not be read, so its public-access posture is unverified.`, + description: failure + ? `Block Public Access status for bucket "${b.name}" could not be read (${failure.error}), so its public-access posture is unverified.` + : `Block Public Access status for bucket "${b.name}" could not be read, so its public-access posture is unverified.`, resourceType: 'aws-s3-bucket', resourceId: b.name, severity: 'medium', - remediation: - 'Grant s3:GetBucketPublicAccessBlock to the integration role so public-access settings can be verified, then re-run.', - evidence: { bucket: b.name, publicAccessDetermined: false }, + remediation: transient + ? TRANSIENT_READ_REMEDIATION + : 'Grant s3:GetBucketPublicAccessBlock to the integration role so public-access settings can be verified, then re-run.', + evidence: { + bucket: b.name, + publicAccessDetermined: false, + ...(failure ? { readError: failure.error } : {}), + }, }; } return isFullyBlocked(b.bucketBpa, accountBpa) @@ -127,68 +133,6 @@ export function evaluateS3PublicAccess( }); } -async function gatherBuckets( - s3: S3Client, - opts: { encryption: boolean; publicAccess: boolean }, -): Promise { - const list = await s3.send(new ListBucketsCommand({})); - const names = (list.Buckets ?? []) - .map((b) => b.Name) - .filter((n): n is string => typeof n === 'string'); - - const infos: S3BucketInfo[] = []; - for (const name of names) { - let encrypted = false; - let encryptionDetermined = true; - let bucketBpa: BpaFlags | null = null; - let publicAccessDetermined = true; - - if (opts.encryption) { - try { - const enc = await s3.send(new GetBucketEncryptionCommand({ Bucket: name })); - encrypted = (enc.ServerSideEncryptionConfiguration?.Rules?.length ?? 0) > 0; - } catch (err) { - // "no encryption configured" is a genuine finding; any other error - // (permissions/transient) is indeterminate → exclude from evaluation. - if ( - err instanceof Error && - /ServerSideEncryptionConfigurationNotFound/i.test(err.name) - ) { - encrypted = false; - } else { - encryptionDetermined = false; - } - } - } - if (opts.publicAccess) { - try { - const pab = await s3.send(new GetPublicAccessBlockCommand({ Bucket: name })); - const c = pab.PublicAccessBlockConfiguration; - bucketBpa = { - blockPublicAcls: Boolean(c?.BlockPublicAcls), - ignorePublicAcls: Boolean(c?.IgnorePublicAcls), - blockPublicPolicy: Boolean(c?.BlockPublicPolicy), - restrictPublicBuckets: Boolean(c?.RestrictPublicBuckets), - }; - } catch (err) { - // "no bucket-level config" is a genuine finding (account-level may still - // cover it); any other error (AccessDenied/transient) is indeterminate → - // exclude from evaluation so we don't report a false public-access failure. - if ( - err instanceof Error && - /NoSuchPublicAccessBlockConfiguration/i.test(err.name) - ) { - bucketBpa = null; // no bucket-level config - } else { - publicAccessDetermined = false; - } - } - } - infos.push({ name, encrypted, encryptionDetermined, bucketBpa, publicAccessDetermined }); - } - return infos; -} - export const s3EncryptionCheck: IntegrationCheck = { id: 'aws-s3-encryption', name: 'S3 — default encryption enabled', @@ -201,14 +145,15 @@ export const s3EncryptionCheck: IntegrationCheck = { ctx.log('AWS S3 encryption check: connection not configured — skipping'); return; } - const s3 = new S3Client({ - region: session.regions[0], - credentials: session.credentials, - followRegionRedirects: true, - }); + const { s3, clientForRegion } = regionalS3Clients(session); let buckets: S3BucketInfo[]; try { - buckets = await gatherBuckets(s3, { encryption: true, publicAccess: false }); + buckets = await gatherBuckets(s3, { + encryption: true, + publicAccess: false, + log: (message) => ctx.log(message), + clientForRegion, + }); } catch (err) { ctx.fail({ title: 'Could not verify S3 encryption', @@ -240,11 +185,7 @@ export const s3PublicAccessCheck: IntegrationCheck = { ctx.log('AWS S3 public-access check: connection not configured — skipping'); return; } - const s3 = new S3Client({ - region: session.regions[0], - credentials: session.credentials, - followRegionRedirects: true, - }); + const { s3, clientForRegion } = regionalS3Clients(session); // Account-level Block Public Access applies to every bucket. Read it once; // if denied/absent, fall back to bucket-level only (graceful). @@ -255,6 +196,7 @@ export const s3PublicAccessCheck: IntegrationCheck = { const s3control = new S3ControlClient({ region: session.regions[0], credentials: session.credentials, + maxAttempts: 5, }); const resp = await s3control.send( new GetAccountPublicAccessBlockCommand({ AccountId: accountId }), @@ -275,7 +217,12 @@ export const s3PublicAccessCheck: IntegrationCheck = { let buckets: S3BucketInfo[]; try { - buckets = await gatherBuckets(s3, { encryption: false, publicAccess: true }); + buckets = await gatherBuckets(s3, { + encryption: false, + publicAccess: true, + log: (message) => ctx.log(message), + clientForRegion, + }); } catch (err) { ctx.fail({ title: 'Could not verify S3 public access', @@ -293,4 +240,4 @@ export const s3PublicAccessCheck: IntegrationCheck = { if (buckets.length === 0) return; emitOutcomes(ctx, evaluateS3PublicAccess(buckets, accountBpa)); }, -}; \ No newline at end of file +}; diff --git a/packages/integration-platform/src/manifests/aws/checks/shared.ts b/packages/integration-platform/src/manifests/aws/checks/shared.ts index 84d0336f93..d9a810983c 100644 --- a/packages/integration-platform/src/manifests/aws/checks/shared.ts +++ b/packages/integration-platform/src/manifests/aws/checks/shared.ts @@ -243,6 +243,36 @@ export async function resolveAwsSessionOrFail( } } +/** Why a per-resource read failed: the real error plus whether it was an authorization failure. */ +export interface ReadFailure { + /** "ErrorName: message" — preserved in finding evidence so the failure is diagnosable. */ + error: string; + /** true for authorization failures (403/AccessDenied); false for transient/network errors. */ + denied: boolean; +} + +/** + * Classify a thrown read error so an "unverified" finding can tell a + * permissions problem ("grant X to the role") apart from a transient one + * ("re-run") — asserting a missing permission for what was actually a + * transient failure sends customers on a wild-goose IAM audit. + */ +export function toReadFailure(err: unknown): ReadFailure { + const error = + err instanceof Error + ? `${err.name}: ${err.message}`.slice(0, 300) + : String(err).slice(0, 300); + const status = (err as { $metadata?: { httpStatusCode?: number } } | null) + ?.$metadata?.httpStatusCode; + const denied = + status === 403 || + (err instanceof Error && + /AccessDenied|UnauthorizedOperation|Forbidden|NotAuthorized/i.test( + err.name, + )); + return { error, denied }; +} + /** A provider-agnostic pass/fail outcome produced by a pure evaluator. */ export interface CheckOutcome { kind: 'pass' | 'fail';