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
9 changes: 7 additions & 2 deletions apps/api/src/findings/dto/create-finding.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/findings/finding-audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export class FindingAuditService {
const labels: Record<FindingType, string> = {
[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;
}
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/findings/finding-notifier.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/findings/finding-notifier.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ const STATUS_LABELS: Record<FindingStatus, string> = {
const TYPE_LABELS: Record<FindingType, string> = {
[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) {
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/findings/findings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/frameworks/frameworks-timeline.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const FRAMEWORK_TO_FINDING_TYPE: Record<string, FindingType> = {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<unknown>(
'/v1/frameworks?includeScores=false',
{ refreshInterval: 0 },
);
const orgFrameworkTypes = useMemo<FindingType[]>(() => {
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<FindingType>();
for (const raw of list) {
const item = raw as Record<string, unknown>;
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<FindingType[]>(
() => extractOrgFrameworkTypes(frameworksData),
[frameworksData],
);

const form = useForm<FormValues>({
resolver: zodResolver(createFindingSchema),
Expand All @@ -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]);

Expand Down Expand Up @@ -351,7 +339,14 @@ export function CreateFindingSheet({
<SelectItem
key={value}
value={value}
disabled={!(value in FINDING_TYPE_LABELS)}
// Gate by the org's enabled frameworks once they're
// loaded. While loading (frameworksLoaded === false)
// leave everything enabled so the dropdown doesn't
// flicker into a fully-disabled state on first paint.
disabled={
frameworksLoaded &&
!orgFrameworkTypes.includes(value as FindingType)
}
>
{label}
</SelectItem>
Expand Down
123 changes: 123 additions & 0 deletions apps/app/src/hooks/use-findings-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest';
import { extractOrgFrameworkTypes } from './use-findings-api';

// Mirrors the wrapped shape useApiSWR returns: { data: <API response> }.
// 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']);
});
});
65 changes: 59 additions & 6 deletions apps/app/src/hooks/use-findings-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -313,8 +309,65 @@ export const FINDING_TYPE_FRAMEWORK_OPTIONS = [
export const FINDING_TYPE_LABELS: Record<FindingType, string> = {
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: <api> }`, 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<FindingType>();
for (const raw of unwrapFrameworksList(payload)) {
const item = raw as Record<string, unknown>;
// 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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading