@doneisbetter/gds-compliance 2.6.5 → 2.6.7

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.
Files changed (2) hide show
  1. package/index.js +206 -1
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -47,7 +47,14 @@ const PRODUCT_AUTHORED_REQUIRED_FIELDS = [
47
47
  'testingRequirements',
48
48
  'observabilityRequirements',
49
49
  ];
50
-
50
+ const IDENTITY_PROVIDER_BRANDING_FIELDS = [
51
+ 'approvedProviders',
52
+ 'forbiddenCustomizations',
53
+ 'allowedVariants',
54
+ 'colorAuthority',
55
+ 'minTouchTargetPx',
56
+ 'policyDocument',
57
+ ];
51
58
  export function validateManifest(manifest) {
52
59
  const findings = [];
53
60
 
@@ -100,6 +107,87 @@ export function validateManifest(manifest) {
100
107
  }
101
108
  }
102
109
 
110
+ const identityProviderBranding = manifest.compliance?.identityProviderBranding;
111
+ if (identityProviderBranding != null) {
112
+ if (typeof identityProviderBranding !== 'object') {
113
+ findings.push({
114
+ rule: 'manifest.invalidComplianceConfig',
115
+ severity: 'error',
116
+ message: 'compliance.identityProviderBranding must be an object when provided.',
117
+ });
118
+ } else {
119
+ if (!Array.isArray(identityProviderBranding.approvedProviders) || identityProviderBranding.approvedProviders.length === 0) {
120
+ findings.push({
121
+ rule: 'manifest.invalidComplianceConfig',
122
+ severity: 'error',
123
+ message: 'compliance.identityProviderBranding.approvedProviders must be a non-empty array.',
124
+ });
125
+ }
126
+
127
+ for (const field of IDENTITY_PROVIDER_BRANDING_FIELDS) {
128
+ const value = identityProviderBranding[field];
129
+ if (value == null) {
130
+ continue;
131
+ }
132
+
133
+ if ((field === 'forbiddenCustomizations' || field === 'allowedVariants') && !Array.isArray(value)) {
134
+ findings.push({
135
+ rule: 'manifest.invalidComplianceConfig',
136
+ severity: 'error',
137
+ message: `compliance.identityProviderBranding.${field} must be an array when provided.`,
138
+ });
139
+ }
140
+ }
141
+
142
+ if (Array.isArray(identityProviderBranding.allowedVariants)) {
143
+ const invalidVariants = identityProviderBranding.allowedVariants.filter((value) => !['solid', 'outline', 'neutral'].includes(String(value).trim().toLowerCase()));
144
+ if (invalidVariants.length > 0) {
145
+ findings.push({
146
+ rule: 'manifest.invalidComplianceConfig',
147
+ severity: 'error',
148
+ message: `compliance.identityProviderBranding.allowedVariants contains invalid values: ${invalidVariants.join(', ')}.`,
149
+ });
150
+ }
151
+ }
152
+
153
+ if (Array.isArray(identityProviderBranding.forbiddenCustomizations)) {
154
+ const hasNonStringCustomization = identityProviderBranding.forbiddenCustomizations.some((value) => typeof value !== 'string');
155
+ if (hasNonStringCustomization) {
156
+ findings.push({
157
+ rule: 'manifest.invalidComplianceConfig',
158
+ severity: 'error',
159
+ message: 'compliance.identityProviderBranding.forbiddenCustomizations may only contain strings.',
160
+ });
161
+ }
162
+ }
163
+
164
+ if (identityProviderBranding.minTouchTargetPx != null &&
165
+ (typeof identityProviderBranding.minTouchTargetPx !== 'number' || identityProviderBranding.minTouchTargetPx < 24)) {
166
+ findings.push({
167
+ rule: 'manifest.invalidComplianceConfig',
168
+ severity: 'error',
169
+ message: 'compliance.identityProviderBranding.minTouchTargetPx must be >= 24 when provided.',
170
+ });
171
+ }
172
+
173
+ if (identityProviderBranding.colorAuthority != null && !['provider', 'gds-outline', 'gds-neutral'].includes(identityProviderBranding.colorAuthority)) {
174
+ findings.push({
175
+ rule: 'manifest.invalidComplianceConfig',
176
+ severity: 'error',
177
+ message: 'compliance.identityProviderBranding.colorAuthority must be one of: provider, gds-outline, gds-neutral.',
178
+ });
179
+ }
180
+
181
+ if (identityProviderBranding.policyDocument != null && typeof identityProviderBranding.policyDocument !== 'string') {
182
+ findings.push({
183
+ rule: 'manifest.invalidComplianceConfig',
184
+ severity: 'error',
185
+ message: 'compliance.identityProviderBranding.policyDocument must be a string when provided.',
186
+ });
187
+ }
188
+ }
189
+ }
190
+
103
191
  if (manifest.compliance?.strictMode != null && typeof manifest.compliance.strictMode !== 'boolean') {
104
192
  findings.push({
105
193
  rule: 'manifest.invalidComplianceConfig',
@@ -417,6 +505,122 @@ function isCoveredByApprovedException(relativePath, approvedExceptions = []) {
417
505
  return approvedExceptions.some((exception) => matchesScope(relativePath, exception.scope ?? []));
418
506
  }
419
507
 
508
+ function normalizeProviderId(value) {
509
+ return String(value).trim().toLowerCase();
510
+ }
511
+
512
+ function parseProviderIdsFromSocialAuthUsage(usageChunk) {
513
+ const providers = new Set();
514
+ const providerObjectRegex = /\b(?:id|provider)\s*:\s*['"]([^'"]+)['"]/g;
515
+ const providerAttributeRegex = /provider\s*=\s*['"]([^'"]+)['"]/g;
516
+ const providerArrayRegex = /providers\s*=\s*\[(.*?)\]/s;
517
+
518
+ for (const match of usageChunk.matchAll(providerObjectRegex)) {
519
+ providers.add(normalizeProviderId(match[1]));
520
+ }
521
+
522
+ for (const match of usageChunk.matchAll(providerAttributeRegex)) {
523
+ providers.add(normalizeProviderId(match[1]));
524
+ }
525
+
526
+ if (providers.size > 0) {
527
+ return providers;
528
+ }
529
+
530
+ const arrayMatch = usageChunk.match(providerArrayRegex);
531
+ if (!arrayMatch) {
532
+ return providers;
533
+ }
534
+
535
+ const idRegex = /['"]([^'"]+)['"]/g;
536
+ for (const match of arrayMatch[1].matchAll(idRegex)) {
537
+ providers.add(normalizeProviderId(match[1]));
538
+ }
539
+
540
+ return providers;
541
+ }
542
+
543
+ function hasForbiddenCustomization(usageChunk, forbiddenCustomizations = []) {
544
+ if (!forbiddenCustomizations.length) {
545
+ return [];
546
+ }
547
+
548
+ return forbiddenCustomizations
549
+ .map((customization) => normalizeProviderId(customization))
550
+ .filter((customization) => new RegExp(`\\b${escapeRegex(customization)}\\s*[:=]`, 'i').test(usageChunk));
551
+ }
552
+
553
+ function scanIdentityProviderBranding({ manifest, manifestRoot, sourceFiles }) {
554
+ const findings = [];
555
+ const policy = manifest.compliance?.identityProviderBranding;
556
+ if (!policy || !Array.isArray(policy.approvedProviders) || !policy.approvedProviders.length) {
557
+ return findings;
558
+ }
559
+
560
+ const approvedProviders = new Set(policy.approvedProviders.map((provider) => normalizeProviderId(provider)));
561
+ const forbiddenCustomizations = Array.isArray(policy.forbiddenCustomizations)
562
+ ? policy.forbiddenCustomizations
563
+ : [];
564
+ const socialAuthUsages = /<(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)[\s\S]*?(?:\/\s*>|>[\s\S]*?<\/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)>)/g;
565
+ const providerTextRegex = /\b(google|apple|facebook|github|microsoft|linkedin|discord|\bx\b|email)\b/i;
566
+ const mantineButtonImportRegex = /from\s+['"]@mantine\/core['"][\s\S]{0,240}\bButton\b/;
567
+ const sourceRoot = normalizePath(manifestRoot).replace(/\/$/, '');
568
+
569
+ for (const filePath of sourceFiles) {
570
+ const content = readFileSync(filePath, 'utf8');
571
+ const relativePath = normalizePath(filePath).replace(`${sourceRoot}/`, '');
572
+
573
+ if (!/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)/.test(content) && (/\bSocialAuth\b/i.test(content) || providerTextRegex.test(content))) {
574
+ if (mantineButtonImportRegex.test(content) && providerTextRegex.test(content)) {
575
+ findings.push({
576
+ rule: 'identity.provider.custom-controls.warn',
577
+ severity: 'warn',
578
+ file: relativePath,
579
+ message: 'Social identity controls appear to use Mantine primitives directly. Consider using SocialAuthButtons or ProviderIdentityButton/ProviderIdentityButtonGroup and policy-conformant provider rendering.',
580
+ });
581
+ }
582
+ continue;
583
+ }
584
+
585
+ const usages = [...content.matchAll(socialAuthUsages)];
586
+ for (const usage of usages) {
587
+ const providerIds = parseProviderIdsFromSocialAuthUsage(usage[0]);
588
+ const forbiddenInUsage = hasForbiddenCustomization(usage[0], forbiddenCustomizations);
589
+
590
+ for (const forbidden of forbiddenInUsage) {
591
+ findings.push({
592
+ rule: 'identity.provider.forbidden-customization',
593
+ severity: 'error',
594
+ file: relativePath,
595
+ message: `Social identity usage in ${relativePath} sets forbidden customization "${forbidden}". Use ProviderIdentityButton/ProviderIdentityButtonGroup or SocialAuthButtons instead.`,
596
+ });
597
+ }
598
+
599
+ for (const providerId of providerIds) {
600
+ if (!approvedProviders.has(providerId)) {
601
+ findings.push({
602
+ rule: 'identity.provider.unapproved-id',
603
+ severity: 'error',
604
+ file: relativePath,
605
+ message: `Social identity usage uses provider "${providerId}" not listed in compliance.identityProviderBranding.approvedProviders.`,
606
+ });
607
+ }
608
+ }
609
+ }
610
+
611
+ if (/(?:SocialAuthButtons|ProviderIdentityButton|ProviderIdentityButtonGroup)/.test(content) && !usages.length && providerTextRegex.test(content)) {
612
+ findings.push({
613
+ rule: 'identity.provider.missing-provider-list',
614
+ severity: 'warn',
615
+ file: relativePath,
616
+ message: 'Social identity controls are present but provider ids could not be parsed for policy validation. Keep provider ids explicit and canonical.',
617
+ });
618
+ }
619
+ }
620
+
621
+ return findings;
622
+ }
623
+
420
624
  function findThemeOwnershipFiles({ manifestRoot, sourceFiles, themeOwnershipPaths = [] }) {
421
625
  if (!themeOwnershipPaths.length) {
422
626
  return [];
@@ -542,6 +746,7 @@ export function runComplianceCheck({ manifestPath }) {
542
746
  findings.push(...validateApprovedExceptions(manifest));
543
747
  findings.push(...validateApprovedExceptionsAgainstRepo({ manifestRoot, manifest, sourceFiles }));
544
748
  findings.push(...scanThemeGovernance({ manifestRoot, manifest, sourceFiles }));
749
+ findings.push(...scanIdentityProviderBranding({ manifest, manifestRoot, sourceFiles }));
545
750
 
546
751
  if (protectedSurfacePaths.length) {
547
752
  const normalizedProtectedSurfacePaths = protectedSurfacePaths.map((value) => normalizePath(resolve(manifestRoot, value)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doneisbetter/gds-compliance",
3
- "version": "2.6.5",
3
+ "version": "2.6.7",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "bin": {