diff --git a/apps/api/src/findings/dto/create-finding.dto.ts b/apps/api/src/findings/dto/create-finding.dto.ts index 291f20ac98..3cd82dc365 100644 --- a/apps/api/src/findings/dto/create-finding.dto.ts +++ b/apps/api/src/findings/dto/create-finding.dto.ts @@ -80,11 +80,16 @@ export class CreateFindingDto { area?: FindingArea; @ApiProperty({ - description: 'Type of finding (SOC 2 or ISO 27001)', + description: + 'Framework this finding is attributed to (must match a framework the organization has enabled)', enum: FindingType, default: FindingType.soc2, }) - @IsEnum(FindingType) + // Use an explicit string list instead of @IsEnum(FindingType). The Prisma- + // generated enum is captured at decorator-eval time, so a dev server started + // before `prisma generate` picked up new enum values keeps rejecting them + // (see the same workaround on `area` above). + @IsIn(['soc2', 'iso27001', 'pci_dss', 'hipaa', 'gdpr', 'iso9001', 'iso42001']) @IsOptional() type?: FindingType; diff --git a/apps/api/src/findings/finding-audit.service.ts b/apps/api/src/findings/finding-audit.service.ts index e323936aca..b315bcd390 100644 --- a/apps/api/src/findings/finding-audit.service.ts +++ b/apps/api/src/findings/finding-audit.service.ts @@ -155,6 +155,11 @@ export class FindingAuditService { const labels: Record = { [FindingType.soc2]: 'SOC 2', [FindingType.iso27001]: 'ISO 27001', + [FindingType.pci_dss]: 'PCI DSS', + [FindingType.hipaa]: 'HIPAA', + [FindingType.gdpr]: 'GDPR', + [FindingType.iso9001]: 'ISO 9001', + [FindingType.iso42001]: 'ISO 42001', }; return labels[type] || type; } diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts index 0930fcc632..cab7bed519 100644 --- a/apps/api/src/findings/finding-notifier.service.spec.ts +++ b/apps/api/src/findings/finding-notifier.service.spec.ts @@ -18,7 +18,15 @@ jest.mock('@db', () => ({ needs_revision: 'needs_revision', closed: 'closed', }, - FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, + FindingType: { + soc2: 'soc2', + iso27001: 'iso27001', + pci_dss: 'pci_dss', + hipaa: 'hipaa', + gdpr: 'gdpr', + iso9001: 'iso9001', + iso42001: 'iso42001', + }, })); jest.mock('@trycompai/email', () => ({ diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts index 3b5f5d802f..c072edbe6f 100644 --- a/apps/api/src/findings/finding-notifier.service.ts +++ b/apps/api/src/findings/finding-notifier.service.ts @@ -76,6 +76,11 @@ const STATUS_LABELS: Record = { const TYPE_LABELS: Record = { [FindingType.soc2]: 'SOC 2', [FindingType.iso27001]: 'ISO 27001', + [FindingType.pci_dss]: 'PCI DSS', + [FindingType.hipaa]: 'HIPAA', + [FindingType.gdpr]: 'GDPR', + [FindingType.iso9001]: 'ISO 9001', + [FindingType.iso42001]: 'ISO 42001', }; function truncate(s: string, n: number) { diff --git a/apps/api/src/findings/findings.service.spec.ts b/apps/api/src/findings/findings.service.spec.ts index 61be0f15e2..a5c8ada812 100644 --- a/apps/api/src/findings/findings.service.spec.ts +++ b/apps/api/src/findings/findings.service.spec.ts @@ -32,7 +32,15 @@ jest.mock('@db', () => ({ needs_revision: 'needs_revision', closed: 'closed', }, - FindingType: { soc2: 'soc2', iso27001: 'iso27001' }, + FindingType: { + soc2: 'soc2', + iso27001: 'iso27001', + pci_dss: 'pci_dss', + hipaa: 'hipaa', + gdpr: 'gdpr', + iso9001: 'iso9001', + iso42001: 'iso42001', + }, FindingSeverity: { low: 'low', medium: 'medium', diff --git a/apps/api/src/frameworks/frameworks-timeline.helper.ts b/apps/api/src/frameworks/frameworks-timeline.helper.ts index f8412f8909..ada4e99e52 100644 --- a/apps/api/src/frameworks/frameworks-timeline.helper.ts +++ b/apps/api/src/frameworks/frameworks-timeline.helper.ts @@ -22,6 +22,11 @@ const FRAMEWORK_TO_FINDING_TYPE: Record = { SOC2: FindingType.soc2, SOC2V1: FindingType.soc2, ISO27001: FindingType.iso27001, + ISO42001: FindingType.iso42001, + ISO9001: FindingType.iso9001, + PCIDSS: FindingType.pci_dss, + HIPAA: FindingType.hipaa, + GDPR: FindingType.gdpr, }; function getFindingTypeForFrameworkName( diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/CreateFindingSheet.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/CreateFindingSheet.tsx index c82bac3521..5f903a445f 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/CreateFindingSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/CreateFindingSheet.tsx @@ -3,6 +3,7 @@ import { useApiSWR } from '@/hooks/use-api-swr'; import { DEFAULT_FINDING_TEMPLATES, + extractOrgFrameworkTypes, FINDING_CATEGORY_LABELS, FINDING_TYPE_FRAMEWORK_OPTIONS, FINDING_TYPE_LABELS, @@ -132,33 +133,18 @@ export function CreateFindingSheet({ const { data: templatesData } = useFindingTemplates(); const { createFinding } = useFindingActions(); - // Detect which frameworks the org has enabled so we can auto-select the - // Framework dropdown when there's only one (common case: org has adopted - // SOC 2 only). + // Detect which frameworks the org has enabled. Used to (a) gate the Framework + // dropdown so an auditor can only attribute a finding to a framework the org + // actually subscribes to, and (b) auto-select sensible defaults below. const { data: frameworksData } = useApiSWR( '/v1/frameworks?includeScores=false', { refreshInterval: 0 }, ); - const orgFrameworkTypes = useMemo(() => { - const payload = (frameworksData as { data?: unknown })?.data; - const list = Array.isArray(payload) - ? payload - : Array.isArray((payload as { data?: unknown })?.data) - ? ((payload as { data: unknown[] }).data) - : []; - const types = new Set(); - for (const raw of list) { - const item = raw as Record; - const fw = (item.framework ?? item) as { name?: unknown } | undefined; - const name = typeof fw?.name === 'string' ? fw.name.toLowerCase() : ''; - if (name.includes('soc 2') || name.includes('soc2')) { - types.add(FindingType.soc2); - } else if (name.includes('27001') || name.includes('iso 27001')) { - types.add(FindingType.iso27001); - } - } - return Array.from(types); - }, [frameworksData]); + const frameworksLoaded = frameworksData !== undefined; + const orgFrameworkTypes = useMemo( + () => extractOrgFrameworkTypes(frameworksData), + [frameworksData], + ); const form = useForm({ resolver: zodResolver(createFindingSchema), @@ -172,12 +158,14 @@ export function CreateFindingSheet({ }, }); - // When the org has exactly one supported framework, default the Framework - // dropdown to it so the auditor doesn't have to pick. + // Once the org's frameworks are known, ensure the Framework dropdown is on a + // value the org actually subscribes to — the form defaults to SOC 2, which + // would be invalid for an ISO-only / HIPAA-only / PCI-only org. useEffect(() => { - if (orgFrameworkTypes.length === 1) { - const only = orgFrameworkTypes[0]!; - if (form.getValues('type') !== only) form.setValue('type', only); + if (orgFrameworkTypes.length === 0) return; + const current = form.getValues('type'); + if (!orgFrameworkTypes.includes(current)) { + form.setValue('type', orgFrameworkTypes[0]!); } }, [orgFrameworkTypes, form]); @@ -351,7 +339,14 @@ export function CreateFindingSheet({ {label} diff --git a/apps/app/src/hooks/use-findings-api.test.ts b/apps/app/src/hooks/use-findings-api.test.ts new file mode 100644 index 0000000000..f51c4810d0 --- /dev/null +++ b/apps/app/src/hooks/use-findings-api.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { extractOrgFrameworkTypes } from './use-findings-api'; + +// Mirrors the wrapped shape useApiSWR returns: { data: }. +// The list endpoint at /v1/frameworks returns { data: [...], count, ... }. +function swrPayload(items: Array<{ framework?: { name: string }; name?: string }>) { + return { data: { data: items, count: items.length } }; +} + +describe('extractOrgFrameworkTypes', () => { + it('returns an empty list while frameworks are still loading', () => { + expect(extractOrgFrameworkTypes(undefined)).toEqual([]); + }); + + it('returns an empty list when the org has no frameworks', () => { + expect(extractOrgFrameworkTypes(swrPayload([]))).toEqual([]); + }); + + it('matches SOC 2 by canonical platform name', () => { + const result = extractOrgFrameworkTypes( + swrPayload([{ framework: { name: 'SOC 2' } }]), + ); + expect(result).toEqual(['soc2']); + }); + + it('matches every platform framework currently in the dropdown', () => { + const result = extractOrgFrameworkTypes( + swrPayload([ + { framework: { name: 'SOC 2' } }, + { framework: { name: 'ISO 27001' } }, + { framework: { name: 'PCI DSS' } }, + { framework: { name: 'HIPAA' } }, + { framework: { name: 'GDPR' } }, + { framework: { name: 'ISO 9001' } }, + { framework: { name: 'ISO 42001' } }, + ]), + ); + expect(result.sort()).toEqual( + ['gdpr', 'hipaa', 'iso27001', 'iso42001', 'iso9001', 'pci_dss', 'soc2'].sort(), + ); + }); + + it('reproduces the bug org_69d943ca3fbbf2c473e97b0a hit: ISO 42001 is detected', () => { + // The customer reported by Paul has SOC 2 + ISO 42001 enabled. Before the + // fix the dropdown only treated SOC 2 / ISO 27001 as detectable, so ISO + // 42001 stayed greyed out even with the module enabled. + const result = extractOrgFrameworkTypes( + swrPayload([ + { framework: { name: 'SOC 2' } }, + { framework: { name: 'ISO 42001' } }, + ]), + ); + expect(result.sort()).toEqual(['iso42001', 'soc2']); + }); + + it('does not confuse ISO 27001 / ISO 9001 / ISO 42001 with each other', () => { + expect( + extractOrgFrameworkTypes(swrPayload([{ framework: { name: 'ISO 27001' } }])), + ).toEqual(['iso27001']); + expect( + extractOrgFrameworkTypes(swrPayload([{ framework: { name: 'ISO 9001' } }])), + ).toEqual(['iso9001']); + expect( + extractOrgFrameworkTypes(swrPayload([{ framework: { name: 'ISO 42001' } }])), + ).toEqual(['iso42001']); + }); + + it('tolerates versioned / locale variants of the canonical name', () => { + // FrameworkEditorFramework seeds may carry version suffixes — make sure + // those still resolve to the same FindingType. + expect( + extractOrgFrameworkTypes( + swrPayload([{ framework: { name: 'ISO/IEC 27001:2022' } }]), + ), + ).toEqual(['iso27001']); + expect( + extractOrgFrameworkTypes(swrPayload([{ framework: { name: 'SOC 2 v.1' } }])), + ).toEqual(['soc2']); + expect( + extractOrgFrameworkTypes(swrPayload([{ framework: { name: 'PCI-DSS v4.0' } }])), + ).toEqual(['pci_dss']); + }); + + it('falls back to root-level `name` when there is no nested framework', () => { + // Custom frameworks come back with `name` at the root and no `framework` + // relation. The platform names we recognise won't match those, but the + // shape must not throw. + const result = extractOrgFrameworkTypes( + swrPayload([{ name: 'SOC 2' }, { name: 'Custom Internal Framework' }]), + ); + expect(result).toEqual(['soc2']); + }); + + it('deduplicates when the same framework appears twice', () => { + const result = extractOrgFrameworkTypes( + swrPayload([ + { framework: { name: 'SOC 2' } }, + { framework: { name: 'SOC 2' } }, + ]), + ); + expect(result).toEqual(['soc2']); + }); + + it('ignores unknown framework names without throwing', () => { + const result = extractOrgFrameworkTypes( + swrPayload([ + { framework: { name: 'NIST CSF' } }, + { framework: { name: '' } }, + { framework: { name: 'SOC 2' } }, + ]), + ); + expect(result).toEqual(['soc2']); + }); + + it('handles the flat-array envelope shape', () => { + // Some surfaces return the array directly under `data` without the inner + // { data, count } envelope — the helper accepts both. + const result = extractOrgFrameworkTypes({ + data: [{ framework: { name: 'HIPAA' } }], + }); + expect(result).toEqual(['hipaa']); + }); +}); diff --git a/apps/app/src/hooks/use-findings-api.ts b/apps/app/src/hooks/use-findings-api.ts index 43e6f4d60c..effc2bd3db 100644 --- a/apps/app/src/hooks/use-findings-api.ts +++ b/apps/app/src/hooks/use-findings-api.ts @@ -3,12 +3,8 @@ import { useApi } from '@/hooks/use-api'; import { useApiSWR, UseApiSWROptions } from '@/hooks/use-api-swr'; import type { EvidenceFormType } from '@trycompai/company'; -import type { - FindingArea, - FindingSeverity, - FindingStatus, - FindingType, -} from '@db'; +import { FindingType } from '@db'; +import type { FindingArea, FindingSeverity, FindingStatus } from '@db'; import { useCallback } from 'react'; // --------------------------------------------------------------------------- @@ -313,8 +309,65 @@ export const FINDING_TYPE_FRAMEWORK_OPTIONS = [ export const FINDING_TYPE_LABELS: Record = { soc2: 'SOC 2', iso27001: 'ISO 27001', + pci_dss: 'PCI DSS', + hipaa: 'HIPAA', + gdpr: 'GDPR', + iso9001: 'ISO 9001', + iso42001: 'ISO 42001', }; +/** + * Maps a FrameworkEditorFramework `name` to the matching FindingType. Order + * matters only as a tiebreaker — patterns are mutually exclusive on the canonical + * platform names ("SOC 2", "ISO 27001", "PCI DSS", "HIPAA", "GDPR", "ISO 9001", + * "ISO 42001"). Kept lenient on whitespace so versioned/locale variants still + * match (e.g. "ISO/IEC 27001:2022"). + */ +const FRAMEWORK_NAME_MATCHERS: { pattern: RegExp; type: FindingType }[] = [ + { pattern: /iso\s*\/?\s*(?:iec\s*)?27001/i, type: FindingType.iso27001 }, + { pattern: /iso\s*\/?\s*(?:iec\s*)?42001/i, type: FindingType.iso42001 }, + { pattern: /iso\s*\/?\s*(?:iec\s*)?9001/i, type: FindingType.iso9001 }, + { pattern: /pci[\s_-]*dss/i, type: FindingType.pci_dss }, + { pattern: /hipaa/i, type: FindingType.hipaa }, + { pattern: /gdpr/i, type: FindingType.gdpr }, + { pattern: /soc\s*2/i, type: FindingType.soc2 }, +]; + +/** + * Unwraps the `/v1/frameworks` response shape. SWR wraps it as + * `{ data: }`, and the list endpoint returns `{ data: [...], count, ... }`, + * so the items can sit one or two envelopes deep. + */ +function unwrapFrameworksList(payload: unknown): unknown[] { + const root = (payload as { data?: unknown })?.data; + if (Array.isArray(root)) return root; + const inner = (root as { data?: unknown })?.data; + if (Array.isArray(inner)) return inner; + return []; +} + +/** + * Derive the list of FindingTypes an org can log against, based on the + * `/v1/frameworks` response. Pure (no React/SWR coupling) so it can be unit- + * tested directly. Unknown framework names are ignored. + */ +export function extractOrgFrameworkTypes(payload: unknown): FindingType[] { + const types = new Set(); + for (const raw of unwrapFrameworksList(payload)) { + const item = raw as Record; + // FrameworkInstance rows nest the platform framework under `framework`; + // custom/platform-direct rows have `name` at the root. + const fw = (item.framework ?? item) as { name?: unknown } | undefined; + const name = typeof fw?.name === 'string' ? fw.name : ''; + if (!name) continue; + const match = FRAMEWORK_NAME_MATCHERS.find(({ pattern }) => + pattern.test(name), + ); + if (match) types.add(match.type); + } + return Array.from(types); +} + export const DEFAULT_FINDING_TEMPLATES: FindingTemplate[] = [ { id: 'default_evidence_issue_01', diff --git a/packages/db/prisma/migrations/20260527120000_extend_finding_types/migration.sql b/packages/db/prisma/migrations/20260527120000_extend_finding_types/migration.sql new file mode 100644 index 0000000000..e6add3fe7f --- /dev/null +++ b/packages/db/prisma/migrations/20260527120000_extend_finding_types/migration.sql @@ -0,0 +1,13 @@ +-- Extend FindingType beyond SOC 2 / ISO 27001 so orgs subscribed to the other +-- platform frameworks (PCI DSS, HIPAA, GDPR, ISO 9001, ISO 42001) can log +-- findings against them in-app instead of resorting to manual tracking. +-- +-- Each ADD VALUE is a separate ALTER TYPE because Postgres 12+ disallows +-- using a newly-added enum value in the same transaction it was added, and +-- Prisma wraps each migration file in a transaction. The `IF NOT EXISTS` +-- guard keeps this idempotent for anyone who already patched locally. +ALTER TYPE "FindingType" ADD VALUE IF NOT EXISTS 'pci_dss'; +ALTER TYPE "FindingType" ADD VALUE IF NOT EXISTS 'hipaa'; +ALTER TYPE "FindingType" ADD VALUE IF NOT EXISTS 'gdpr'; +ALTER TYPE "FindingType" ADD VALUE IF NOT EXISTS 'iso9001'; +ALTER TYPE "FindingType" ADD VALUE IF NOT EXISTS 'iso42001'; diff --git a/packages/db/prisma/schema/finding.prisma b/packages/db/prisma/schema/finding.prisma index fc3ea7836d..b7266608b0 100644 --- a/packages/db/prisma/schema/finding.prisma +++ b/packages/db/prisma/schema/finding.prisma @@ -1,6 +1,11 @@ enum FindingType { soc2 iso27001 + pci_dss + hipaa + gdpr + iso9001 + iso42001 } enum FindingStatus {