Skip to content

Commit dd2ea84

Browse files
tofikwestclaude
andauthored
fix(trust-portal): derive public vendor badges from cert data (CS-688) (#3355)
* fix(trust-portal): derive public vendor badges from cert data (CS-688) The public Trust Centre subprocessor badges are served by TrustAccessService.getPublicVendors, which returned the stored Vendor.complianceBadges verbatim and only re-derived from certification data when that stored value was empty. Its fallback mapper also used a brittle exact-match that never recognized "ISO/IEC 27001:2022". So a vendor with a stale stored badge set (Scaleway: GDPR only) kept showing the wrong badges publicly even after the admin-side mapper was fixed in #3315/#3318 — the two surfaces ran different code. - Add cert-badge-mapper.ts as the single source of truth for turning certification names into badge types (moved verbatim from the tested admin mapper, incl. the bounded matchesIsoStandard logic). - getPublicVendors now derives badges from riskAssessmentData with that shared mapper and treats them as authoritative when present, mirroring the admin sync, so admin and public can no longer disagree. Falls back to stored badges only when there is nothing to derive. No data backfill needed — the public path corrects stale stored badges on the fly. - trust-portal.service.ts (admin sync) now imports the shared mapper instead of its own private copies. Tests: shared-mapper unit tests (ISO/IEC 27001:2022, 2701x boundaries, unrelated-digit guards) + a getPublicVendors regression proving a stale GDPR-only stored set still yields ISO 27001 from cert data. Fixes CS-688 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgkStnwws7zZL39mJ79PeN * fix(trust-portal): recognize spelled-out PCI DSS + drop dead mapper branches Address cubic review on the shared cert-badge-mapper: - Map "Payment Card Industry Data Security Standard" to pci_dss via a `paymentcard` substring check, matching the scan-time mappers. Since the public path now derives badges authoritatively, without this it would drop a PCI badge the scan writer recognizes. - Remove separator-specific branches ('soc 2', 'pci dss', 'pci_dss', 'nen 7510') that were unreachable after normalization strips spaces/underscores; document the normalization so the intent is clear. Behavior-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgkStnwws7zZL39mJ79PeN --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2a66b0 commit dd2ea84

5 files changed

Lines changed: 282 additions & 139 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import {
2+
extractComplianceBadges,
3+
mapCertificationToBadgeType,
4+
} from './cert-badge-mapper';
5+
6+
describe('mapCertificationToBadgeType', () => {
7+
// CS-688: the "IEC" infix and ":2022" suffix must not stop ISO 27001 from
8+
// being recognized.
9+
it('maps "ISO/IEC 27001:2022" to iso27001', () => {
10+
expect(mapCertificationToBadgeType('ISO/IEC 27001:2022')).toBe('iso27001');
11+
});
12+
13+
it('maps common certifications to their canonical badge types', () => {
14+
expect(mapCertificationToBadgeType('SOC 2 Type II')).toBe('soc2');
15+
expect(mapCertificationToBadgeType('ISO 9001:2015')).toBe('iso9001');
16+
expect(mapCertificationToBadgeType('ISO/IEC 42001:2023')).toBe('iso42001');
17+
expect(mapCertificationToBadgeType('GDPR Compliance')).toBe('gdpr');
18+
expect(mapCertificationToBadgeType('HIPAA')).toBe('hipaa');
19+
expect(mapCertificationToBadgeType('PCI DSS')).toBe('pci_dss');
20+
expect(mapCertificationToBadgeType('NEN 7510')).toBe('nen7510');
21+
});
22+
23+
// The fully spelled-out PCI name must map too — the scan-time mappers already
24+
// recognize it, so the shared display mapper must not drop it.
25+
it('maps the spelled-out "Payment Card Industry Data Security Standard"', () => {
26+
expect(
27+
mapCertificationToBadgeType('Payment Card Industry Data Security Standard'),
28+
).toBe('pci_dss');
29+
});
30+
31+
// The digits alone must not classify an unrelated identifier.
32+
it('does not misclassify ids that merely contain the standard digits', () => {
33+
expect(mapCertificationToBadgeType('Catalog 19001')).toBeNull();
34+
expect(mapCertificationToBadgeType('ISO 90010')).toBeNull();
35+
});
36+
37+
// Distinct 2701x standards must not earn the 27001 badge.
38+
it('does not read ISO/IEC 27017 or 27018 as iso27001', () => {
39+
expect(mapCertificationToBadgeType('ISO/IEC 27017:2015')).toBeNull();
40+
expect(mapCertificationToBadgeType('ISO/IEC 27018:2019')).toBeNull();
41+
});
42+
43+
it('returns null for certifications with no badge', () => {
44+
expect(mapCertificationToBadgeType('HDS')).toBeNull();
45+
expect(mapCertificationToBadgeType('')).toBeNull();
46+
});
47+
});
48+
49+
describe('extractComplianceBadges', () => {
50+
// CS-688 regression: Scaleway's verified certs must yield iso27001 + gdpr.
51+
it('extracts verified badges and skips unrecognized certs', () => {
52+
const badges = extractComplianceBadges({
53+
certifications: [
54+
{ type: 'ISO/IEC 27001:2022', status: 'verified' },
55+
{ type: 'HDS', status: 'verified' },
56+
{ type: 'GDPR Compliance', status: 'verified' },
57+
],
58+
});
59+
60+
const types = badges.map((b) => b.type);
61+
expect(types).toContain('iso27001');
62+
expect(types).toContain('gdpr');
63+
expect(types).not.toContain('HDS');
64+
});
65+
66+
it('ignores certifications that are not verified', () => {
67+
const badges = extractComplianceBadges({
68+
certifications: [
69+
{ type: 'ISO/IEC 27001:2022', status: 'expired' },
70+
{ type: 'GDPR Compliance', status: 'verified' },
71+
],
72+
});
73+
74+
expect(badges.map((b) => b.type)).toEqual(['gdpr']);
75+
});
76+
77+
it('de-duplicates repeated badge types', () => {
78+
const badges = extractComplianceBadges({
79+
certifications: [
80+
{ type: 'ISO/IEC 27001:2022', status: 'verified' },
81+
{ type: 'ISO 27001:2013', status: 'verified' },
82+
],
83+
});
84+
85+
expect(badges).toEqual([{ type: 'iso27001', verified: true }]);
86+
});
87+
88+
it('returns an empty array for missing or malformed data', () => {
89+
expect(extractComplianceBadges(null)).toEqual([]);
90+
expect(extractComplianceBadges({})).toEqual([]);
91+
expect(extractComplianceBadges({ certifications: 'nope' })).toEqual([]);
92+
});
93+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Single source of truth for turning AI-extracted certification names
3+
* (`GlobalVendors.riskAssessmentData.certifications[].type`) into Trust Portal
4+
* compliance badge types.
5+
*
6+
* Both the admin vendor sync (`trust-portal.service.ts`) and the public,
7+
* visitor-facing portal (`trust-access.service.ts`) derive badges through this
8+
* module so the two surfaces can never disagree — the mismatch that caused
9+
* CS-688 (Scaleway showed ISO 27001 in the admin Vendors tab but only GDPR on
10+
* the public Trust Centre).
11+
*/
12+
13+
export type ComplianceBadge = {
14+
type: string;
15+
verified: boolean;
16+
};
17+
18+
/**
19+
* Whether a normalized cert string (lowercased, alphanumerics only) names the
20+
* given ISO standard number.
21+
*
22+
* - Requires an "iso" / "iso iec" prefix, so unrelated ids that merely contain
23+
* the digits ("19001", "127001") are not misclassified.
24+
* - The optional "iec" handles joint ISO/IEC standards whose "IEC" infix would
25+
* otherwise break the match ("ISO/IEC 27001:2022" -> "isoiec270012022").
26+
* - Allows an optional trailing 4-digit year ("ISO 9001:2015" -> "iso90012015")
27+
* but forbids any other trailing digit, so a longer number is not read as a
28+
* shorter standard ("ISO 90010" is not "ISO 9001", "ISO 27017" is not 27001).
29+
*
30+
* `standardNumber` is always a hard-coded digit literal — never user input —
31+
* so building the RegExp from it carries no injection risk.
32+
*/
33+
function matchesIsoStandard(normalized: string, standardNumber: string): boolean {
34+
return new RegExp(`iso(?:iec)?${standardNumber}(?:\\d{4})?(?!\\d)`).test(
35+
normalized,
36+
);
37+
}
38+
39+
/**
40+
* Map a single certification name to its canonical badge type, or `null` when
41+
* the certification is not one we render a badge for.
42+
*/
43+
export function mapCertificationToBadgeType(certType: string): string | null {
44+
// Strip every non-alphanumeric char (spaces, slashes, colons, underscores)
45+
// so separator variants collapse to one form — "PCI DSS", "PCI-DSS" and
46+
// "pci_dss" all become "pcidss". Matches below therefore never need to spell
47+
// out separator variants.
48+
const normalized = certType.toLowerCase().replace(/[^a-z0-9]/g, '');
49+
50+
if (normalized.includes('soc2')) return 'soc2';
51+
if (matchesIsoStandard(normalized, '27001')) return 'iso27001';
52+
if (matchesIsoStandard(normalized, '42001')) return 'iso42001';
53+
if (normalized.includes('gdpr')) return 'gdpr';
54+
if (normalized.includes('hipaa')) return 'hipaa';
55+
// "pcidss" covers "PCI DSS"; "paymentcard" covers the fully spelled-out
56+
// "Payment Card Industry Data Security Standard" (kept in sync with the
57+
// scan-time mappers in trigger/vendor/vendor-risk-assessment*).
58+
if (normalized.includes('pcidss') || normalized.includes('paymentcard'))
59+
return 'pci_dss';
60+
if (normalized.includes('nen7510')) return 'nen7510';
61+
if (matchesIsoStandard(normalized, '9001')) return 'iso9001';
62+
63+
return null;
64+
}
65+
66+
/**
67+
* Extract the deduplicated set of verified compliance badges from a
68+
* `GlobalVendors.riskAssessmentData` object. Unrecognized certifications are
69+
* skipped. Returns an empty array when there is nothing to map.
70+
*/
71+
export function extractComplianceBadges(data: unknown): ComplianceBadge[] {
72+
if (!data || typeof data !== 'object') return [];
73+
74+
const certifications = (data as { certifications?: unknown }).certifications;
75+
if (!Array.isArray(certifications)) return [];
76+
77+
const badges: ComplianceBadge[] = [];
78+
const seenTypes = new Set<string>();
79+
80+
for (const cert of certifications) {
81+
if (!cert || typeof cert !== 'object') continue;
82+
83+
const { type, status } = cert as { type?: unknown; status?: unknown };
84+
if (status !== 'verified' || typeof type !== 'string') continue;
85+
86+
const badgeType = mapCertificationToBadgeType(type);
87+
if (badgeType && !seenTypes.has(badgeType)) {
88+
seenTypes.add(badgeType);
89+
badges.push({ type: badgeType, verified: true });
90+
}
91+
}
92+
93+
return badges;
94+
}

‎apps/api/src/trust-portal/trust-access.service.spec.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ jest.mock('@db', () => ({
2424
member: {
2525
findFirst: jest.fn(),
2626
},
27+
vendor: {
28+
findMany: jest.fn(),
29+
},
30+
globalVendors: {
31+
findMany: jest.fn(),
32+
},
2733
$transaction: jest.fn(),
2834
},
2935
Prisma: {
@@ -74,13 +80,90 @@ const mockDb = db as unknown as {
7480
member: {
7581
findFirst: jest.Mock;
7682
};
83+
vendor: {
84+
findMany: jest.Mock;
85+
};
86+
globalVendors: {
87+
findMany: jest.Mock;
88+
};
7789
$transaction: jest.Mock;
7890
};
7991

8092
const mockGetSignedUrl = getSignedUrl as jest.MockedFunction<
8193
typeof getSignedUrl
8294
>;
8395

96+
describe('TrustAccessService getPublicVendors compliance badges (CS-688)', () => {
97+
const service = new TrustAccessService(
98+
{
99+
getSignedUrl: jest.fn(),
100+
} as any,
101+
{} as any,
102+
{} as any,
103+
{} as any,
104+
{} as any,
105+
);
106+
107+
beforeEach(() => {
108+
jest.clearAllMocks();
109+
mockDb.trust.findUnique.mockResolvedValue({ organizationId: 'org_1' });
110+
});
111+
112+
// Regression: the public Trust Centre served a stale stored badge set
113+
// (GDPR only) for Scaleway while the vendor's verified certifications include
114+
// ISO 27001. The public path must derive badges from the certification data,
115+
// not trust the stale stored value.
116+
it('derives ISO 27001 from cert data even when stored badges are stale (GDPR only)', async () => {
117+
mockDb.vendor.findMany.mockResolvedValue([
118+
{
119+
id: 'vnd_scaleway',
120+
name: 'Scaleway',
121+
description: null,
122+
website: 'scaleway.com',
123+
logoUrl: null,
124+
complianceBadges: [{ type: 'gdpr', verified: true }],
125+
},
126+
]);
127+
mockDb.globalVendors.findMany.mockResolvedValue([
128+
{
129+
website: 'scaleway.com',
130+
riskAssessmentData: {
131+
certifications: [
132+
{ type: 'ISO/IEC 27001:2022', status: 'verified' },
133+
{ type: 'HDS', status: 'verified' },
134+
{ type: 'GDPR Compliance', status: 'verified' },
135+
],
136+
},
137+
},
138+
]);
139+
140+
const result = await service.getPublicVendors('capawesome');
141+
const types = result[0].complianceBadges.map((b) => b.type);
142+
143+
expect(types).toContain('iso27001');
144+
expect(types).toContain('gdpr');
145+
});
146+
147+
it('keeps the stored badges when there is no derivable cert data', async () => {
148+
mockDb.vendor.findMany.mockResolvedValue([
149+
{
150+
id: 'vnd_x',
151+
name: 'X',
152+
description: null,
153+
website: 'x.com',
154+
logoUrl: null,
155+
complianceBadges: [{ type: 'soc2', verified: true }],
156+
},
157+
]);
158+
mockDb.globalVendors.findMany.mockResolvedValue([]);
159+
160+
const result = await service.getPublicVendors('capawesome');
161+
const types = result[0].complianceBadges.map((b) => b.type);
162+
163+
expect(types).toEqual(['soc2']);
164+
});
165+
});
166+
84167
describe('TrustAccessService favicon branding', () => {
85168
const service = new TrustAccessService(
86169
{

‎apps/api/src/trust-portal/trust-access.service.ts‎

Lines changed: 9 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@nestjs/common';
77
import { db } from '@db';
88
import { randomBytes } from 'crypto';
9+
import { extractComplianceBadges } from './cert-badge-mapper';
910
import {
1011
ApproveAccessRequestDto,
1112
CreateAccessRequestDto,
@@ -2876,9 +2877,14 @@ export class TrustAccessService {
28762877
}
28772878
}
28782879

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

2893-
/**
2894-
* Extract compliance badges from GlobalVendors riskAssessmentData certifications.
2895-
* Used as fallback when the vendor record has no complianceBadges synced yet.
2896-
*/
2897-
private extractBadgesFromRiskData(
2898-
data: Record<string, unknown>,
2899-
): Array<{ type: string; verified: boolean }> | null {
2900-
const certs = data.certifications;
2901-
if (!Array.isArray(certs)) return null;
2902-
2903-
const CERT_MAP: Record<string, string> = {
2904-
soc2: 'soc2',
2905-
'soc 2': 'soc2',
2906-
soc3: 'soc3',
2907-
'soc 3': 'soc3',
2908-
iso27001: 'iso27001',
2909-
'iso 27001': 'iso27001',
2910-
iso42001: 'iso42001',
2911-
'iso 42001': 'iso42001',
2912-
gdpr: 'gdpr',
2913-
hipaa: 'hipaa',
2914-
pcidss: 'pci_dss',
2915-
'pci dss': 'pci_dss',
2916-
pci_dss: 'pci_dss',
2917-
nen7510: 'nen7510',
2918-
'nen 7510': 'nen7510',
2919-
iso9001: 'iso9001',
2920-
'iso 9001': 'iso9001',
2921-
pipeda: 'pipeda',
2922-
ccpa: 'ccpa',
2923-
};
2924-
2925-
const badges: Array<{ type: string; verified: boolean }> = [];
2926-
const seen = new Set<string>();
2927-
2928-
for (const cert of certs) {
2929-
if (
2930-
!cert ||
2931-
typeof cert !== 'object' ||
2932-
cert.status !== 'verified' ||
2933-
typeof cert.type !== 'string'
2934-
)
2935-
continue;
2936-
2937-
const normalized = cert.type.toLowerCase().replace(/[^a-z0-9 _]/g, '');
2938-
// Use canonical slug for known certs, keep original type for unknown ones
2939-
const badgeType = CERT_MAP[normalized] ?? cert.type.trim();
2940-
const key = badgeType.toLowerCase();
2941-
if (badgeType && !seen.has(key)) {
2942-
seen.add(key);
2943-
badges.push({ type: badgeType, verified: true });
2944-
}
2945-
}
2946-
2947-
return badges.length > 0 ? badges : null;
2948-
}
2949-
29502899
/**
29512900
* Format compliance badges as simple type + label pairs for external rendering.
29522901
* Does NOT include branded icons to avoid implying vendors were certified through us.

0 commit comments

Comments
 (0)