diff --git a/apps/api/src/trust-portal/cert-badge-mapper.spec.ts b/apps/api/src/trust-portal/cert-badge-mapper.spec.ts new file mode 100644 index 0000000000..32aee3eb1e --- /dev/null +++ b/apps/api/src/trust-portal/cert-badge-mapper.spec.ts @@ -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([]); + }); +}); diff --git a/apps/api/src/trust-portal/cert-badge-mapper.ts b/apps/api/src/trust-portal/cert-badge-mapper.ts new file mode 100644 index 0000000000..cbe6253826 --- /dev/null +++ b/apps/api/src/trust-portal/cert-badge-mapper.ts @@ -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'; + 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; +} + +/** + * 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(); + + 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; +} diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts index e0bb9604ca..0a812b821d 100644 --- a/apps/api/src/trust-portal/trust-access.service.spec.ts +++ b/apps/api/src/trust-portal/trust-access.service.spec.ts @@ -24,6 +24,12 @@ jest.mock('@db', () => ({ member: { findFirst: jest.fn(), }, + vendor: { + findMany: jest.fn(), + }, + globalVendors: { + findMany: jest.fn(), + }, $transaction: jest.fn(), }, Prisma: { @@ -74,6 +80,12 @@ const mockDb = db as unknown as { member: { findFirst: jest.Mock; }; + vendor: { + findMany: jest.Mock; + }; + globalVendors: { + findMany: jest.Mock; + }; $transaction: jest.Mock; }; @@ -81,6 +93,77 @@ 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( { diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 0ada81b0eb..939c43189d 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -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, @@ -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; } } } @@ -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, - ): Array<{ type: string; verified: boolean }> | null { - const certs = data.certifications; - if (!Array.isArray(certs)) return null; - - const CERT_MAP: Record = { - 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(); - - 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. diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index 4ef2b358c5..eee766319a 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -12,6 +12,7 @@ import { PutObjectCommand, } from '@aws-sdk/client-s3'; import { db } from '@db'; +import { extractComplianceBadges } from './cert-badge-mapper'; import { DomainStatusResponseDto, DomainVerificationDto, @@ -1875,10 +1876,10 @@ export class TrustPortalService { }); if (globalVendor?.riskAssessmentData) { - const extractedBadges = this.extractComplianceBadges( + const extractedBadges = extractComplianceBadges( globalVendor.riskAssessmentData, ); - if (extractedBadges && extractedBadges.length > 0) { + if (extractedBadges.length > 0) { const currentBadges = vendor.complianceBadges as Array<{ type: string; }> | null; @@ -1934,83 +1935,6 @@ export class TrustPortalService { })); } - private extractComplianceBadges( - data: Prisma.JsonValue, - ): Array<{ type: string; verified: boolean }> | null { - try { - const parsed = data as { - certifications?: Array<{ type: string; status: string }>; - }; - - if (!parsed?.certifications || !Array.isArray(parsed.certifications)) { - return null; - } - - const badges: Array<{ type: string; verified: boolean }> = []; - const seenTypes = new Set(); - - for (const cert of parsed.certifications) { - if (cert.status !== 'verified') continue; - - const badgeType = this.mapCertificationToBadgeType(cert.type); - if (badgeType && !seenTypes.has(badgeType)) { - seenTypes.add(badgeType); - badges.push({ type: badgeType, verified: true }); - } - } - - return badges.length > 0 ? badges : null; - } catch { - return null; - } - } - - private mapCertificationToBadgeType(certType: string): string | null { - const normalized = certType.toLowerCase().replace(/[^a-z0-9]/g, ''); - - if (normalized.includes('soc2') || normalized.includes('soc 2')) - return 'soc2'; - if (this.matchesIsoStandard(normalized, '27001')) return 'iso27001'; - if (this.matchesIsoStandard(normalized, '42001')) return 'iso42001'; - if (normalized.includes('gdpr')) return 'gdpr'; - if (normalized.includes('hipaa')) return 'hipaa'; - if ( - normalized.includes('pcidss') || - normalized.includes('pci dss') || - normalized.includes('pci_dss') - ) - return 'pci_dss'; - if (normalized.includes('nen7510') || normalized.includes('nen 7510')) - return 'nen7510'; - if (this.matchesIsoStandard(normalized, '9001')) return 'iso9001'; - - return null; - } - - /** - * 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. - */ - private matchesIsoStandard( - normalized: string, - standardNumber: string, - ): boolean { - return new RegExp(`iso(?:iec)?${standardNumber}(?:\\d{4})?(?!\\d)`).test( - normalized, - ); - } - private generateLogoUrl(website: string | null): string | null { if (!website) return null; try {