Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
545bc74
fix(api): add isActive filter to member query
chasprowebdev Jul 1, 2026
13aa3ef
fix(app): sort assignee list alphabetically in assignee dropdown
chasprowebdev Jul 1, 2026
98401f4
fix(risk-treatment): ensure live tasks filter before ranking in draft…
tofikwest Jul 2, 2026
c51b17c
fix(cloud-security): exclude per-task runs from latest scan selection
tofikwest Jul 2, 2026
70c393c
fix: address follow-up
tofikwest Jul 3, 2026
b4a5924
Merge branch 'main' into tofik/cs-702-bug-aws-cloud-security-tests
tofikwest Jul 3, 2026
cc493f0
fix(api): tie trust portal access link expiry to the grant duration
chasprowebdev Jul 3, 2026
1816d95
chore: merge release v3.97.0 back to main [skip ci]
github-actions[bot] Jul 6, 2026
e1b6c0b
Merge branch 'main' into tofik/cs-702-bug-aws-cloud-security-tests
tofikwest Jul 6, 2026
f89c323
feat(trust): add setting to show/hide the Security Questionnaire on t…
tofikwest Jul 6, 2026
1cef7fd
Merge pull request #3341 from trycompai/tofik/cs-702-bug-aws-cloud-se…
tofikwest Jul 6, 2026
a3eddac
Merge branch 'main' into chas/trust-portal-access-expiration
tofikwest Jul 6, 2026
a402487
Merge pull request #3342 from trycompai/chas/trust-portal-access-expi…
tofikwest Jul 6, 2026
01a86c3
Merge branch 'main' into chas/task-assignee
tofikwest Jul 6, 2026
7a5286b
Merge pull request #3320 from trycompai/chas/task-assignee
tofikwest Jul 6, 2026
1d3c3bd
Merge branch 'main' into tofik/trust-questionnaire-toggle
tofikwest Jul 6, 2026
662a487
Merge branch 'main' into tofik/cs-681-bug-draft-plan-suggest-links
tofikwest Jul 6, 2026
6e71c3f
fix(trust): address cubic review on the questionnaire toggle
tofikwest Jul 6, 2026
2a10daa
Merge remote-tracking branch 'origin/tofik/trust-questionnaire-toggle…
tofikwest Jul 6, 2026
eb3c097
fix(risk-treatment): harden orphan task-vector prune (CS-681 review)
tofikwest Jul 6, 2026
f555185
refactor(trust): share the public trust lookup + clarify a test name
tofikwest Jul 6, 2026
3e5ad6f
Merge pull request #3353 from trycompai/tofik/trust-questionnaire-toggle
tofikwest Jul 6, 2026
3a2bc95
Merge branch 'main' into tofik/cs-681-bug-draft-plan-suggest-links
tofikwest Jul 6, 2026
e2a66b0
Merge pull request #3340 from trycompai/tofik/cs-681-bug-draft-plan-s…
tofikwest Jul 6, 2026
dd2ea84
fix(trust-portal): derive public vendor badges from cert data (CS-688…
tofikwest Jul 6, 2026
692db72
Merge branch 'release' into main
tofikwest Jul 6, 2026
e8cb123
fix(trust-portal): restore soc3/pipeda/ccpa badge mappings (CS-688) (…
tofikwest Jul 6, 2026
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
102 changes: 102 additions & 0 deletions apps/api/src/trust-portal/cert-badge-mapper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
extractComplianceBadges,
mapCertificationToBadgeType,
} from './cert-badge-mapper';

describe('mapCertificationToBadgeType', () => {
// CS-688: the "IEC" infix and ":2022" suffix must not stop ISO 27001 from
// being recognized.
it('maps "ISO/IEC 27001:2022" to iso27001', () => {
expect(mapCertificationToBadgeType('ISO/IEC 27001:2022')).toBe('iso27001');
});

it('maps common certifications to their canonical badge types', () => {
expect(mapCertificationToBadgeType('SOC 2 Type II')).toBe('soc2');
expect(mapCertificationToBadgeType('ISO 9001:2015')).toBe('iso9001');
expect(mapCertificationToBadgeType('ISO/IEC 42001:2023')).toBe('iso42001');
expect(mapCertificationToBadgeType('GDPR Compliance')).toBe('gdpr');
expect(mapCertificationToBadgeType('HIPAA')).toBe('hipaa');
expect(mapCertificationToBadgeType('PCI DSS')).toBe('pci_dss');
expect(mapCertificationToBadgeType('NEN 7510')).toBe('nen7510');
});

// The fully spelled-out PCI name must map too — the scan-time mappers already
// recognize it, so the shared display mapper must not drop it.
it('maps the spelled-out "Payment Card Industry Data Security Standard"', () => {
expect(
mapCertificationToBadgeType('Payment Card Industry Data Security Standard'),
).toBe('pci_dss');
});

// Regression: soc3 / pipeda / ccpa were supported by the public portal's
// original mapper. Consolidation must not drop them or affected vendors lose
// their public Trust Centre badges.
it('maps soc3 / pipeda / ccpa (restored after consolidation)', () => {
expect(mapCertificationToBadgeType('SOC 3')).toBe('soc3');
expect(mapCertificationToBadgeType('PIPEDA')).toBe('pipeda');
expect(mapCertificationToBadgeType('CCPA')).toBe('ccpa');
});

// The digits alone must not classify an unrelated identifier.
it('does not misclassify ids that merely contain the standard digits', () => {
expect(mapCertificationToBadgeType('Catalog 19001')).toBeNull();
expect(mapCertificationToBadgeType('ISO 90010')).toBeNull();
});

// Distinct 2701x standards must not earn the 27001 badge.
it('does not read ISO/IEC 27017 or 27018 as iso27001', () => {
expect(mapCertificationToBadgeType('ISO/IEC 27017:2015')).toBeNull();
expect(mapCertificationToBadgeType('ISO/IEC 27018:2019')).toBeNull();
});

it('returns null for certifications with no badge', () => {
expect(mapCertificationToBadgeType('HDS')).toBeNull();
expect(mapCertificationToBadgeType('')).toBeNull();
});
});

describe('extractComplianceBadges', () => {
// CS-688 regression: Scaleway's verified certs must yield iso27001 + gdpr.
it('extracts verified badges and skips unrecognized certs', () => {
const badges = extractComplianceBadges({
certifications: [
{ type: 'ISO/IEC 27001:2022', status: 'verified' },
{ type: 'HDS', status: 'verified' },
{ type: 'GDPR Compliance', status: 'verified' },
],
});

const types = badges.map((b) => b.type);
expect(types).toContain('iso27001');
expect(types).toContain('gdpr');
expect(types).not.toContain('HDS');
});

it('ignores certifications that are not verified', () => {
const badges = extractComplianceBadges({
certifications: [
{ type: 'ISO/IEC 27001:2022', status: 'expired' },
{ type: 'GDPR Compliance', status: 'verified' },
],
});

expect(badges.map((b) => b.type)).toEqual(['gdpr']);
});

it('de-duplicates repeated badge types', () => {
const badges = extractComplianceBadges({
certifications: [
{ type: 'ISO/IEC 27001:2022', status: 'verified' },
{ type: 'ISO 27001:2013', status: 'verified' },
],
});

expect(badges).toEqual([{ type: 'iso27001', verified: true }]);
});

it('returns an empty array for missing or malformed data', () => {
expect(extractComplianceBadges(null)).toEqual([]);
expect(extractComplianceBadges({})).toEqual([]);
expect(extractComplianceBadges({ certifications: 'nope' })).toEqual([]);
});
});
101 changes: 101 additions & 0 deletions apps/api/src/trust-portal/cert-badge-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Single source of truth for turning AI-extracted certification names
* (`GlobalVendors.riskAssessmentData.certifications[].type`) into Trust Portal
* compliance badge types.
*
* Both the admin vendor sync (`trust-portal.service.ts`) and the public,
* visitor-facing portal (`trust-access.service.ts`) derive badges through this
* module so the two surfaces can never disagree — the mismatch that caused
* CS-688 (Scaleway showed ISO 27001 in the admin Vendors tab but only GDPR on
* the public Trust Centre).
*/

export type ComplianceBadge = {
type: string;
verified: boolean;
};

/**
* Whether a normalized cert string (lowercased, alphanumerics only) names the
* given ISO standard number.
*
* - Requires an "iso" / "iso iec" prefix, so unrelated ids that merely contain
* the digits ("19001", "127001") are not misclassified.
* - The optional "iec" handles joint ISO/IEC standards whose "IEC" infix would
* otherwise break the match ("ISO/IEC 27001:2022" -> "isoiec270012022").
* - Allows an optional trailing 4-digit year ("ISO 9001:2015" -> "iso90012015")
* but forbids any other trailing digit, so a longer number is not read as a
* shorter standard ("ISO 90010" is not "ISO 9001", "ISO 27017" is not 27001).
*
* `standardNumber` is always a hard-coded digit literal — never user input —
* so building the RegExp from it carries no injection risk.
*/
function matchesIsoStandard(normalized: string, standardNumber: string): boolean {
return new RegExp(`iso(?:iec)?${standardNumber}(?:\\d{4})?(?!\\d)`).test(
normalized,
);
}

/**
* Map a single certification name to its canonical badge type, or `null` when
* the certification is not one we render a badge for.
*/
export function mapCertificationToBadgeType(certType: string): string | null {
// Strip every non-alphanumeric char (spaces, slashes, colons, underscores)
// so separator variants collapse to one form — "PCI DSS", "PCI-DSS" and
// "pci_dss" all become "pcidss". Matches below therefore never need to spell
// out separator variants.
const normalized = certType.toLowerCase().replace(/[^a-z0-9]/g, '');

if (normalized.includes('soc2')) return 'soc2';
if (normalized.includes('soc3')) return 'soc3';
Comment thread
tofikwest marked this conversation as resolved.
if (matchesIsoStandard(normalized, '27001')) return 'iso27001';
if (matchesIsoStandard(normalized, '42001')) return 'iso42001';
if (normalized.includes('gdpr')) return 'gdpr';
if (normalized.includes('hipaa')) return 'hipaa';
// "pcidss" covers "PCI DSS"; "paymentcard" covers the fully spelled-out
// "Payment Card Industry Data Security Standard" (kept in sync with the
// scan-time mappers in trigger/vendor/vendor-risk-assessment*).
if (normalized.includes('pcidss') || normalized.includes('paymentcard'))
return 'pci_dss';
if (normalized.includes('nen7510')) return 'nen7510';
if (matchesIsoStandard(normalized, '9001')) return 'iso9001';
// soc3 / pipeda / ccpa were supported by the public portal's original mapper
// before consolidation; keep them so those vendors don't lose their public
// Trust Centre badges. The public portal renders them via label text and the
// admin UI safely skips badge types it has no icon for.
if (normalized.includes('pipeda')) return 'pipeda';
if (normalized.includes('ccpa')) return 'ccpa';

return null;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

/**
* Extract the deduplicated set of verified compliance badges from a
* `GlobalVendors.riskAssessmentData` object. Unrecognized certifications are
* skipped. Returns an empty array when there is nothing to map.
*/
export function extractComplianceBadges(data: unknown): ComplianceBadge[] {
if (!data || typeof data !== 'object') return [];

const certifications = (data as { certifications?: unknown }).certifications;
if (!Array.isArray(certifications)) return [];

const badges: ComplianceBadge[] = [];
const seenTypes = new Set<string>();

for (const cert of certifications) {
if (!cert || typeof cert !== 'object') continue;

const { type, status } = cert as { type?: unknown; status?: unknown };
if (status !== 'verified' || typeof type !== 'string') continue;

const badgeType = mapCertificationToBadgeType(type);
if (badgeType && !seenTypes.has(badgeType)) {
seenTypes.add(badgeType);
badges.push({ type: badgeType, verified: true });
}
}

return badges;
}
83 changes: 83 additions & 0 deletions apps/api/src/trust-portal/trust-access.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ jest.mock('@db', () => ({
member: {
findFirst: jest.fn(),
},
vendor: {
findMany: jest.fn(),
},
globalVendors: {
findMany: jest.fn(),
},
$transaction: jest.fn(),
},
Prisma: {
Expand Down Expand Up @@ -74,13 +80,90 @@ const mockDb = db as unknown as {
member: {
findFirst: jest.Mock;
};
vendor: {
findMany: jest.Mock;
};
globalVendors: {
findMany: jest.Mock;
};
$transaction: jest.Mock;
};

const mockGetSignedUrl = getSignedUrl as jest.MockedFunction<
typeof getSignedUrl
>;

describe('TrustAccessService getPublicVendors compliance badges (CS-688)', () => {
const service = new TrustAccessService(
{
getSignedUrl: jest.fn(),
} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);

beforeEach(() => {
jest.clearAllMocks();
mockDb.trust.findUnique.mockResolvedValue({ organizationId: 'org_1' });
});

// Regression: the public Trust Centre served a stale stored badge set
// (GDPR only) for Scaleway while the vendor's verified certifications include
// ISO 27001. The public path must derive badges from the certification data,
// not trust the stale stored value.
it('derives ISO 27001 from cert data even when stored badges are stale (GDPR only)', async () => {
mockDb.vendor.findMany.mockResolvedValue([
{
id: 'vnd_scaleway',
name: 'Scaleway',
description: null,
website: 'scaleway.com',
logoUrl: null,
complianceBadges: [{ type: 'gdpr', verified: true }],
},
]);
mockDb.globalVendors.findMany.mockResolvedValue([
{
website: 'scaleway.com',
riskAssessmentData: {
certifications: [
{ type: 'ISO/IEC 27001:2022', status: 'verified' },
{ type: 'HDS', status: 'verified' },
{ type: 'GDPR Compliance', status: 'verified' },
],
},
},
]);

const result = await service.getPublicVendors('capawesome');
const types = result[0].complianceBadges.map((b) => b.type);

expect(types).toContain('iso27001');
expect(types).toContain('gdpr');
});

it('keeps the stored badges when there is no derivable cert data', async () => {
mockDb.vendor.findMany.mockResolvedValue([
{
id: 'vnd_x',
name: 'X',
description: null,
website: 'x.com',
logoUrl: null,
complianceBadges: [{ type: 'soc2', verified: true }],
},
]);
mockDb.globalVendors.findMany.mockResolvedValue([]);

const result = await service.getPublicVendors('capawesome');
const types = result[0].complianceBadges.map((b) => b.type);

expect(types).toEqual(['soc2']);
});
});

describe('TrustAccessService favicon branding', () => {
const service = new TrustAccessService(
{
Expand Down
69 changes: 9 additions & 60 deletions apps/api/src/trust-portal/trust-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { db } from '@db';
import { randomBytes } from 'crypto';
import { extractComplianceBadges } from './cert-badge-mapper';
import {
ApproveAccessRequestDto,
CreateAccessRequestDto,
Expand Down Expand Up @@ -2876,9 +2877,14 @@ export class TrustAccessService {
}
}

// Extract compliance badges from riskAssessmentData when vendor record has none
if (!badges || !Array.isArray(badges) || badges.length === 0) {
badges = this.extractBadgesFromRiskData(parsed);
// Prefer badges freshly derived from the vendor's verified
// certifications so the public Trust Centre always matches the admin
// Vendors tab, rather than trusting a possibly-stale stored
// complianceBadges value (CS-688). Fall back to the stored value only
// when there is nothing to derive.
const derivedBadges = extractComplianceBadges(parsed);
if (derivedBadges.length > 0) {
badges = derivedBadges;
}
}
}
Expand All @@ -2890,63 +2896,6 @@ export class TrustAccessService {
});
}

/**
* Extract compliance badges from GlobalVendors riskAssessmentData certifications.
* Used as fallback when the vendor record has no complianceBadges synced yet.
*/
private extractBadgesFromRiskData(
data: Record<string, unknown>,
): Array<{ type: string; verified: boolean }> | null {
const certs = data.certifications;
if (!Array.isArray(certs)) return null;

const CERT_MAP: Record<string, string> = {
soc2: 'soc2',
'soc 2': 'soc2',
soc3: 'soc3',
'soc 3': 'soc3',
iso27001: 'iso27001',
'iso 27001': 'iso27001',
iso42001: 'iso42001',
'iso 42001': 'iso42001',
gdpr: 'gdpr',
hipaa: 'hipaa',
pcidss: 'pci_dss',
'pci dss': 'pci_dss',
pci_dss: 'pci_dss',
nen7510: 'nen7510',
'nen 7510': 'nen7510',
iso9001: 'iso9001',
'iso 9001': 'iso9001',
pipeda: 'pipeda',
ccpa: 'ccpa',
};

const badges: Array<{ type: string; verified: boolean }> = [];
const seen = new Set<string>();

for (const cert of certs) {
if (
!cert ||
typeof cert !== 'object' ||
cert.status !== 'verified' ||
typeof cert.type !== 'string'
)
continue;

const normalized = cert.type.toLowerCase().replace(/[^a-z0-9 _]/g, '');
// Use canonical slug for known certs, keep original type for unknown ones
const badgeType = CERT_MAP[normalized] ?? cert.type.trim();
const key = badgeType.toLowerCase();
if (badgeType && !seen.has(key)) {
seen.add(key);
badges.push({ type: badgeType, verified: true });
}
}

return badges.length > 0 ? badges : null;
}

/**
* Format compliance badges as simple type + label pairs for external rendering.
* Does NOT include branded icons to avoid implying vendors were certified through us.
Expand Down
Loading
Loading