@doneisbetter/gds-compliance 2.6.6 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +276 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -15,6 +15,9 @@ const STRICT_COMPLIANCE_FIELDS = [
|
|
|
15
15
|
'approvedDetailPrimitives',
|
|
16
16
|
'approvedListingPrimitives',
|
|
17
17
|
'approvedActionPrimitives',
|
|
18
|
+
'approvedMediaPrimitives',
|
|
19
|
+
'approvedReportingPrimitives',
|
|
20
|
+
'approvedAccessPrimitives',
|
|
18
21
|
'approvedTemporaryExceptions',
|
|
19
22
|
'approvedThemeLanes',
|
|
20
23
|
'themeOwnershipPaths',
|
|
@@ -47,7 +50,14 @@ const PRODUCT_AUTHORED_REQUIRED_FIELDS = [
|
|
|
47
50
|
'testingRequirements',
|
|
48
51
|
'observabilityRequirements',
|
|
49
52
|
];
|
|
50
|
-
|
|
53
|
+
const IDENTITY_PROVIDER_BRANDING_FIELDS = [
|
|
54
|
+
'approvedProviders',
|
|
55
|
+
'forbiddenCustomizations',
|
|
56
|
+
'allowedVariants',
|
|
57
|
+
'colorAuthority',
|
|
58
|
+
'minTouchTargetPx',
|
|
59
|
+
'policyDocument',
|
|
60
|
+
];
|
|
51
61
|
export function validateManifest(manifest) {
|
|
52
62
|
const findings = [];
|
|
53
63
|
|
|
@@ -100,6 +110,87 @@ export function validateManifest(manifest) {
|
|
|
100
110
|
}
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
const identityProviderBranding = manifest.compliance?.identityProviderBranding;
|
|
114
|
+
if (identityProviderBranding != null) {
|
|
115
|
+
if (typeof identityProviderBranding !== 'object') {
|
|
116
|
+
findings.push({
|
|
117
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
118
|
+
severity: 'error',
|
|
119
|
+
message: 'compliance.identityProviderBranding must be an object when provided.',
|
|
120
|
+
});
|
|
121
|
+
} else {
|
|
122
|
+
if (!Array.isArray(identityProviderBranding.approvedProviders) || identityProviderBranding.approvedProviders.length === 0) {
|
|
123
|
+
findings.push({
|
|
124
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
125
|
+
severity: 'error',
|
|
126
|
+
message: 'compliance.identityProviderBranding.approvedProviders must be a non-empty array.',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const field of IDENTITY_PROVIDER_BRANDING_FIELDS) {
|
|
131
|
+
const value = identityProviderBranding[field];
|
|
132
|
+
if (value == null) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if ((field === 'forbiddenCustomizations' || field === 'allowedVariants') && !Array.isArray(value)) {
|
|
137
|
+
findings.push({
|
|
138
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
139
|
+
severity: 'error',
|
|
140
|
+
message: `compliance.identityProviderBranding.${field} must be an array when provided.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (Array.isArray(identityProviderBranding.allowedVariants)) {
|
|
146
|
+
const invalidVariants = identityProviderBranding.allowedVariants.filter((value) => !['solid', 'outline', 'neutral'].includes(String(value).trim().toLowerCase()));
|
|
147
|
+
if (invalidVariants.length > 0) {
|
|
148
|
+
findings.push({
|
|
149
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
150
|
+
severity: 'error',
|
|
151
|
+
message: `compliance.identityProviderBranding.allowedVariants contains invalid values: ${invalidVariants.join(', ')}.`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (Array.isArray(identityProviderBranding.forbiddenCustomizations)) {
|
|
157
|
+
const hasNonStringCustomization = identityProviderBranding.forbiddenCustomizations.some((value) => typeof value !== 'string');
|
|
158
|
+
if (hasNonStringCustomization) {
|
|
159
|
+
findings.push({
|
|
160
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
161
|
+
severity: 'error',
|
|
162
|
+
message: 'compliance.identityProviderBranding.forbiddenCustomizations may only contain strings.',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (identityProviderBranding.minTouchTargetPx != null &&
|
|
168
|
+
(typeof identityProviderBranding.minTouchTargetPx !== 'number' || identityProviderBranding.minTouchTargetPx < 24)) {
|
|
169
|
+
findings.push({
|
|
170
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
171
|
+
severity: 'error',
|
|
172
|
+
message: 'compliance.identityProviderBranding.minTouchTargetPx must be >= 24 when provided.',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (identityProviderBranding.colorAuthority != null && !['provider', 'gds-outline', 'gds-neutral'].includes(identityProviderBranding.colorAuthority)) {
|
|
177
|
+
findings.push({
|
|
178
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
179
|
+
severity: 'error',
|
|
180
|
+
message: 'compliance.identityProviderBranding.colorAuthority must be one of: provider, gds-outline, gds-neutral.',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (identityProviderBranding.policyDocument != null && typeof identityProviderBranding.policyDocument !== 'string') {
|
|
185
|
+
findings.push({
|
|
186
|
+
rule: 'manifest.invalidComplianceConfig',
|
|
187
|
+
severity: 'error',
|
|
188
|
+
message: 'compliance.identityProviderBranding.policyDocument must be a string when provided.',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
103
194
|
if (manifest.compliance?.strictMode != null && typeof manifest.compliance.strictMode !== 'boolean') {
|
|
104
195
|
findings.push({
|
|
105
196
|
rule: 'manifest.invalidComplianceConfig',
|
|
@@ -303,6 +394,9 @@ function inferStrictSurface(contract) {
|
|
|
303
394
|
if (normalized.includes('detail') || normalized.includes('profile')) return 'detail';
|
|
304
395
|
if (normalized.includes('card') || normalized.includes('listing')) return 'listing';
|
|
305
396
|
if (normalized.includes('action') || normalized.includes('button')) return 'action';
|
|
397
|
+
if (normalized.includes('media') || normalized.includes('upload') || normalized.includes('asset')) return 'media';
|
|
398
|
+
if (normalized.includes('report') || normalized.includes('chart') || normalized.includes('evidence') || normalized.includes('metric')) return 'reporting';
|
|
399
|
+
if (normalized.includes('auth') || normalized.includes('access') || normalized.includes('identity') || normalized.includes('login')) return 'access';
|
|
306
400
|
return null;
|
|
307
401
|
}
|
|
308
402
|
|
|
@@ -314,6 +408,9 @@ function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
|
|
|
314
408
|
detail: new Set(strict.approvedDetailPrimitives ?? []),
|
|
315
409
|
listing: new Set(strict.approvedListingPrimitives ?? []),
|
|
316
410
|
action: new Set(strict.approvedActionPrimitives ?? []),
|
|
411
|
+
media: new Set(strict.approvedMediaPrimitives ?? []),
|
|
412
|
+
reporting: new Set(strict.approvedReportingPrimitives ?? []),
|
|
413
|
+
access: new Set(strict.approvedAccessPrimitives ?? []),
|
|
317
414
|
};
|
|
318
415
|
const approvedTemporaryExceptions = new Set(strict.approvedTemporaryExceptions ?? []);
|
|
319
416
|
|
|
@@ -361,6 +458,50 @@ function runStrictCompliance({ manifest, manifestRoot, sourceFiles }) {
|
|
|
361
458
|
message: 'Strict mode forbids local button/action wrapper implementations. Use the canonical GDS ActionBar and semantic actions.',
|
|
362
459
|
});
|
|
363
460
|
}
|
|
461
|
+
|
|
462
|
+
if (/export function \w*(Listing|Venue|Event|Community|Product|Card)\w*\s*\(/.test(content)
|
|
463
|
+
&& /from\s+['"]@mantine\/core['"][\s\S]{0,240}\bCard\b/.test(content)
|
|
464
|
+
&& !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,260}\b(ListingCard|PublicProductCard|PublicFoodCard|MediaCard)\b/.test(content)) {
|
|
465
|
+
findings.push({
|
|
466
|
+
rule: 'strict.listing.local-card-wrapper',
|
|
467
|
+
severity: 'error',
|
|
468
|
+
file: filePath,
|
|
469
|
+
message: 'Strict mode forbids local listing/card wrappers backed by Mantine Card. Use ListingCard, PublicProductCard, PublicFoodCard, or MediaCard.',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (/export function \w*(Upload|Media|Asset|ImagePicker|Dropzone)\w*\s*\(/.test(content)
|
|
474
|
+
&& (/<input[^>]+type=["']file["']/.test(content) || /\bDropzone\b/.test(content))
|
|
475
|
+
&& !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,260}\b(MediaField|UploadDropzone)\b/.test(content)) {
|
|
476
|
+
findings.push({
|
|
477
|
+
rule: 'strict.media.local-upload-wrapper',
|
|
478
|
+
severity: 'error',
|
|
479
|
+
file: filePath,
|
|
480
|
+
message: 'Strict mode forbids local media/upload wrappers. Use MediaField and UploadDropzone while keeping storage logic product-owned.',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (/export function \w*(Report|Chart|Evidence|Analytics|Metric)\w*\s*\(/.test(content)
|
|
485
|
+
&& (/\bChart\b|recharts|chart\.js|<canvas\b|svg\s+role=["']img["']/.test(content))
|
|
486
|
+
&& !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,320}\b(ReportingSection|PeriodSelector|EvidencePanel|ChartTokenPanel|StatsSection|MetricCard)\b/.test(content)) {
|
|
487
|
+
findings.push({
|
|
488
|
+
rule: 'strict.reporting.local-chart-wrapper',
|
|
489
|
+
severity: 'error',
|
|
490
|
+
file: filePath,
|
|
491
|
+
message: 'Strict mode forbids local reporting/chart wrappers without the GDS reporting contract. Use ReportingSection, EvidencePanel, PeriodSelector, and ChartTokenPanel.',
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (/export function \w*(Auth|Login|Social|AccessDenied|Protected|Recovery)\w*\s*\(/.test(content)
|
|
496
|
+
&& (/\b(google|apple|github|microsoft|facebook)\b/i.test(content) || /Access denied|Sign in required|Session expired/i.test(content))
|
|
497
|
+
&& !/from\s+['"]@doneisbetter\/gds-core['"][\s\S]{0,360}\b(AuthShell|ProviderIdentityButton|ProviderIdentityButtonGroup|SocialAuthButtons|AccessSummary|AccessRecoveryPanel)\b/.test(content)) {
|
|
498
|
+
findings.push({
|
|
499
|
+
rule: 'strict.access.local-auth-wrapper',
|
|
500
|
+
severity: 'error',
|
|
501
|
+
file: filePath,
|
|
502
|
+
message: 'Strict mode forbids local auth/access wrappers. Use AuthShell, provider identity controls, AccessSummary, and AccessRecoveryPanel.',
|
|
503
|
+
});
|
|
504
|
+
}
|
|
364
505
|
}
|
|
365
506
|
|
|
366
507
|
return findings;
|
|
@@ -417,6 +558,139 @@ function isCoveredByApprovedException(relativePath, approvedExceptions = []) {
|
|
|
417
558
|
return approvedExceptions.some((exception) => matchesScope(relativePath, exception.scope ?? []));
|
|
418
559
|
}
|
|
419
560
|
|
|
561
|
+
function normalizeProviderId(value) {
|
|
562
|
+
return String(value).trim().toLowerCase();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function parseProviderIdsFromSocialAuthUsage(usageChunk) {
|
|
566
|
+
const providers = new Set();
|
|
567
|
+
const providerObjectRegex = /\b(?:id|provider)\s*:\s*['"]([^'"]+)['"]/g;
|
|
568
|
+
const providerAttributeRegex = /provider\s*=\s*['"]([^'"]+)['"]/g;
|
|
569
|
+
const providerArrayRegex = /providers\s*=\s*\[(.*?)\]/s;
|
|
570
|
+
|
|
571
|
+
for (const match of usageChunk.matchAll(providerObjectRegex)) {
|
|
572
|
+
providers.add(normalizeProviderId(match[1]));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const match of usageChunk.matchAll(providerAttributeRegex)) {
|
|
576
|
+
providers.add(normalizeProviderId(match[1]));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (providers.size > 0) {
|
|
580
|
+
return providers;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const arrayMatch = usageChunk.match(providerArrayRegex);
|
|
584
|
+
if (!arrayMatch) {
|
|
585
|
+
return providers;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const idRegex = /['"]([^'"]+)['"]/g;
|
|
589
|
+
for (const match of arrayMatch[1].matchAll(idRegex)) {
|
|
590
|
+
providers.add(normalizeProviderId(match[1]));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return providers;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function hasForbiddenCustomization(usageChunk, forbiddenCustomizations = []) {
|
|
597
|
+
if (!forbiddenCustomizations.length) {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return forbiddenCustomizations
|
|
602
|
+
.map((customization) => normalizeProviderId(customization))
|
|
603
|
+
.filter((customization) => new RegExp(`\\b${escapeRegex(customization)}\\s*[:=]`, 'i').test(usageChunk));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function scanIdentityProviderBranding({ manifest, manifestRoot, sourceFiles }) {
|
|
607
|
+
const findings = [];
|
|
608
|
+
const policy = manifest.compliance?.identityProviderBranding;
|
|
609
|
+
if (!policy || !Array.isArray(policy.approvedProviders) || !policy.approvedProviders.length) {
|
|
610
|
+
return findings;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const approvedProviders = new Set(policy.approvedProviders.map((provider) => normalizeProviderId(provider)));
|
|
614
|
+
const forbiddenCustomizations = Array.isArray(policy.forbiddenCustomizations)
|
|
615
|
+
? policy.forbiddenCustomizations
|
|
616
|
+
: [];
|
|
617
|
+
const allowedVariants = Array.isArray(policy.allowedVariants)
|
|
618
|
+
? new Set(policy.allowedVariants.map((variant) => normalizeProviderId(variant)))
|
|
619
|
+
: null;
|
|
620
|
+
const socialAuthUsages = /<(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)[\s\S]*?(?:\/\s*>|>[\s\S]*?<\/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)>)/g;
|
|
621
|
+
const providerTextRegex = /\b(google|apple|facebook|github|microsoft|linkedin|discord|\bx\b|email)\b/i;
|
|
622
|
+
const mantineButtonImportRegex = /from\s+['"]@mantine\/core['"][\s\S]{0,240}\bButton\b/;
|
|
623
|
+
const sourceRoot = normalizePath(manifestRoot).replace(/\/$/, '');
|
|
624
|
+
|
|
625
|
+
for (const filePath of sourceFiles) {
|
|
626
|
+
const content = readFileSync(filePath, 'utf8');
|
|
627
|
+
const relativePath = normalizePath(filePath).replace(`${sourceRoot}/`, '');
|
|
628
|
+
|
|
629
|
+
if (!/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)/.test(content) && (/\bSocialAuth\b/i.test(content) || providerTextRegex.test(content))) {
|
|
630
|
+
if (mantineButtonImportRegex.test(content) && providerTextRegex.test(content)) {
|
|
631
|
+
findings.push({
|
|
632
|
+
rule: 'identity.provider.custom-controls.warn',
|
|
633
|
+
severity: 'warn',
|
|
634
|
+
file: relativePath,
|
|
635
|
+
message: 'Social identity controls appear to use Mantine primitives directly. Consider using SocialAuthButtons or ProviderIdentityButton/ProviderIdentityButtonGroup and policy-conformant provider rendering.',
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const usages = [...content.matchAll(socialAuthUsages)];
|
|
642
|
+
for (const usage of usages) {
|
|
643
|
+
const providerIds = parseProviderIdsFromSocialAuthUsage(usage[0]);
|
|
644
|
+
const forbiddenInUsage = hasForbiddenCustomization(usage[0], forbiddenCustomizations);
|
|
645
|
+
|
|
646
|
+
for (const forbidden of forbiddenInUsage) {
|
|
647
|
+
findings.push({
|
|
648
|
+
rule: 'identity.provider.forbidden-customization',
|
|
649
|
+
severity: 'error',
|
|
650
|
+
file: relativePath,
|
|
651
|
+
message: `Social identity usage in ${relativePath} sets forbidden customization "${forbidden}". Use ProviderIdentityButton/ProviderIdentityButtonGroup or SocialAuthButtons instead.`,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (allowedVariants) {
|
|
656
|
+
for (const match of usage[0].matchAll(/\bvariant\s*[:=]\s*['"]([^'"]+)['"]/g)) {
|
|
657
|
+
const variant = normalizeProviderId(match[1]);
|
|
658
|
+
if (!allowedVariants.has(variant)) {
|
|
659
|
+
findings.push({
|
|
660
|
+
rule: 'identity.provider.disallowed-variant',
|
|
661
|
+
severity: 'error',
|
|
662
|
+
file: relativePath,
|
|
663
|
+
message: `Social identity usage sets variant "${variant}" outside compliance.identityProviderBranding.allowedVariants.`,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const providerId of providerIds) {
|
|
670
|
+
if (!approvedProviders.has(providerId)) {
|
|
671
|
+
findings.push({
|
|
672
|
+
rule: 'identity.provider.unapproved-id',
|
|
673
|
+
severity: 'error',
|
|
674
|
+
file: relativePath,
|
|
675
|
+
message: `Social identity usage uses provider "${providerId}" not listed in compliance.identityProviderBranding.approvedProviders.`,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)/.test(content) && !usages.length && providerTextRegex.test(content)) {
|
|
682
|
+
findings.push({
|
|
683
|
+
rule: 'identity.provider.missing-provider-list',
|
|
684
|
+
severity: 'warn',
|
|
685
|
+
file: relativePath,
|
|
686
|
+
message: 'Social identity controls are present but provider ids could not be parsed for policy validation. Keep provider ids explicit and canonical.',
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return findings;
|
|
692
|
+
}
|
|
693
|
+
|
|
420
694
|
function findThemeOwnershipFiles({ manifestRoot, sourceFiles, themeOwnershipPaths = [] }) {
|
|
421
695
|
if (!themeOwnershipPaths.length) {
|
|
422
696
|
return [];
|
|
@@ -542,6 +816,7 @@ export function runComplianceCheck({ manifestPath }) {
|
|
|
542
816
|
findings.push(...validateApprovedExceptions(manifest));
|
|
543
817
|
findings.push(...validateApprovedExceptionsAgainstRepo({ manifestRoot, manifest, sourceFiles }));
|
|
544
818
|
findings.push(...scanThemeGovernance({ manifestRoot, manifest, sourceFiles }));
|
|
819
|
+
findings.push(...scanIdentityProviderBranding({ manifest, manifestRoot, sourceFiles }));
|
|
545
820
|
|
|
546
821
|
if (protectedSurfacePaths.length) {
|
|
547
822
|
const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
|