240 lines
8.4 KiB
TypeScript
240 lines
8.4 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Smoke: TOTP-2FA never recommends Google Authenticator (or other
|
|
* closed-source authenticators) anywhere in the codebase.
|
|
*
|
|
* ADR-0043 commits to an open-source-only recommendation policy.
|
|
* This smoke enforces it structurally:
|
|
*
|
|
* 1. RECOMMENDED_AUTHENTICATOR_APPS in
|
|
* apps/web/src/lib/auth/recommendedAuthenticatorApps.ts
|
|
* contains ONLY apps with verifiable open-source licenses
|
|
* (GPL, AGPL, MPL, Apache, BSD, MIT). No proprietary apps.
|
|
*
|
|
* 2. NOT_RECOMMENDED_AUTHENTICATOR_APPS explicitly lists
|
|
* Google Authenticator, Microsoft Authenticator, and Authy
|
|
* with their reasons surfaced.
|
|
*
|
|
* 3. NO locale JSON string mentions Google/Microsoft/Authy in
|
|
* a positive recommendation context (only in the explicit
|
|
* "we don't recommend these" section, where it's expected).
|
|
*
|
|
* 4. The 2FA settings route renders both RECOMMENDED and
|
|
* NOT_RECOMMENDED app sections, and the "not recommended"
|
|
* section is collapsible (not hidden).
|
|
*
|
|
* Tamper test: removing Google Authenticator from
|
|
* NOT_RECOMMENDED_AUTHENTICATOR_APPS without removing all UI
|
|
* references to it → smoke fails. Recommending a closed-source
|
|
* app in RECOMMENDED_AUTHENTICATOR_APPS → smoke fails.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const REPO_ROOT = join(__dirname, '..', '..', '..');
|
|
|
|
const RECOMMENDED_APPS_TS = readFileSync(
|
|
join(REPO_ROOT, 'apps/web/src/lib/auth/recommendedAuthenticatorApps.ts'),
|
|
'utf8'
|
|
);
|
|
const TFA_ROUTE = readFileSync(
|
|
join(REPO_ROOT, 'apps/web/src/routes/[lang]/settings/security/2fa/+page.svelte'),
|
|
'utf8'
|
|
);
|
|
const LOCALES_DIR = join(REPO_ROOT, 'apps/web/src/lib/i18n/locales');
|
|
const LOCALE_FILES = readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.json'));
|
|
|
|
const OPEN_SOURCE_LICENSE_PATTERNS = [
|
|
/GPL-[23]\.0/,
|
|
/AGPL-3\.0/,
|
|
/MPL-[12]\.0/,
|
|
/Apache-2\.0/,
|
|
/BSD-[23]-Clause/,
|
|
/MIT\b/,
|
|
/ISC\b/,
|
|
/CC-BY-SA/
|
|
];
|
|
|
|
interface AppRow {
|
|
readonly name: string;
|
|
readonly license: string;
|
|
}
|
|
|
|
function parseRecommendedApps(): AppRow[] {
|
|
// Find the array start marker, then walk to its matching `];`
|
|
// at the top level (the inline `platforms: ['Android']` arrays
|
|
// inside entries don't have a trailing semicolon so they
|
|
// can't false-match).
|
|
const startMarker = 'RECOMMENDED_AUTHENTICATOR_APPS: ReadonlyArray<AuthenticatorApp> = [';
|
|
const startIdx = RECOMMENDED_APPS_TS.indexOf(startMarker);
|
|
if (startIdx < 0) {
|
|
throw new Error('Could not locate RECOMMENDED_AUTHENTICATOR_APPS array');
|
|
}
|
|
// The array body ends at the next `];\n` that's followed by
|
|
// a blank line or another `export`/`/**` line — i.e., at the
|
|
// top level, not at any nested array literal.
|
|
const bodyStart = startIdx + startMarker.length;
|
|
const endIdx = RECOMMENDED_APPS_TS.indexOf('];\n', bodyStart);
|
|
if (endIdx < 0) {
|
|
throw new Error('Could not locate end of RECOMMENDED_AUTHENTICATOR_APPS array');
|
|
}
|
|
const body = RECOMMENDED_APPS_TS.slice(bodyStart, endIdx);
|
|
const rows: AppRow[] = [];
|
|
// Each entry is a brace-delimited object literal at the
|
|
// top level of the array. We find them by scanning for
|
|
// `{`...matching `}` with simple brace depth counting.
|
|
let depth = 0;
|
|
let entryStart = -1;
|
|
for (let i = 0; i < body.length; i++) {
|
|
const ch = body[i];
|
|
if (ch === '{') {
|
|
if (depth === 0) entryStart = i;
|
|
depth += 1;
|
|
} else if (ch === '}') {
|
|
depth -= 1;
|
|
if (depth === 0 && entryStart >= 0) {
|
|
const entry = body.slice(entryStart, i + 1);
|
|
const nameM = entry.match(/name:\s*'([^']+)'/);
|
|
const licM = entry.match(/license:\s*'([^']+)'/);
|
|
if (nameM && licM) {
|
|
rows.push({ name: nameM[1], license: licM[1] });
|
|
}
|
|
entryStart = -1;
|
|
}
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function parseNotRecommendedApps(): string[] {
|
|
const startMarker = 'NOT_RECOMMENDED_AUTHENTICATOR_APPS';
|
|
const startIdx = RECOMMENDED_APPS_TS.indexOf(startMarker);
|
|
if (startIdx < 0) {
|
|
throw new Error('Could not locate NOT_RECOMMENDED_AUTHENTICATOR_APPS array');
|
|
}
|
|
const arrOpen = RECOMMENDED_APPS_TS.indexOf('[', startIdx);
|
|
const endIdx = RECOMMENDED_APPS_TS.indexOf('];', arrOpen);
|
|
if (arrOpen < 0 || endIdx < 0) {
|
|
throw new Error('Malformed NOT_RECOMMENDED_AUTHENTICATOR_APPS array');
|
|
}
|
|
const body = RECOMMENDED_APPS_TS.slice(arrOpen + 1, endIdx);
|
|
const names: string[] = [];
|
|
for (const match of body.matchAll(/name:\s*'([^']+)'/g)) {
|
|
names.push(match[1]);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
let failures = 0;
|
|
let passes = 0;
|
|
|
|
function expect(label: string, cond: boolean, detail = ''): void {
|
|
if (cond) {
|
|
passes += 1;
|
|
console.log(` ✓ ${label}`);
|
|
} else {
|
|
failures += 1;
|
|
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
}
|
|
|
|
console.log('2fa-no-google-recommendation-smoke\n');
|
|
|
|
// 1. Every recommended app has a license that matches an OSI pattern.
|
|
console.log('1. recommended apps are open-source-licensed:');
|
|
const recommended = parseRecommendedApps();
|
|
expect(
|
|
'at least three recommended apps',
|
|
recommended.length >= 3,
|
|
`found ${recommended.length}`
|
|
);
|
|
for (const app of recommended) {
|
|
const isOpenSource = OPEN_SOURCE_LICENSE_PATTERNS.some((p) => p.test(app.license));
|
|
expect(`${app.name} (${app.license}) is open-source-licensed`, isOpenSource);
|
|
}
|
|
|
|
// 2. NOT_RECOMMENDED explicitly lists the closed-source big three.
|
|
console.log('\n2. not-recommended list calls out the closed-source big three:');
|
|
const notRecommended = parseNotRecommendedApps();
|
|
expect(
|
|
'Google Authenticator is in NOT_RECOMMENDED list',
|
|
notRecommended.some((n) => /google\s*authenticator/i.test(n)),
|
|
notRecommended.join(', ')
|
|
);
|
|
expect(
|
|
'Microsoft Authenticator is in NOT_RECOMMENDED list',
|
|
notRecommended.some((n) => /microsoft\s*authenticator/i.test(n)),
|
|
notRecommended.join(', ')
|
|
);
|
|
expect(
|
|
'Authy is in NOT_RECOMMENDED list',
|
|
notRecommended.some((n) => /authy/i.test(n)),
|
|
notRecommended.join(', ')
|
|
);
|
|
|
|
// 3. No locale string positively recommends Google/Microsoft/Authy.
|
|
// "Positively" means: appears under settings.totp.recommended_apps.*
|
|
// or in any non-explicitly-negative context. We check by ensuring
|
|
// that for every locale, "Google Authenticator" / "Microsoft
|
|
// Authenticator" / "Authy" appears ONLY under
|
|
// settings.totp.not_recommended_apps.* (or not at all).
|
|
console.log('\n3. locale strings never positively recommend the big three:');
|
|
for (const localeFile of LOCALE_FILES) {
|
|
const path = join(LOCALES_DIR, localeFile);
|
|
const d = JSON.parse(readFileSync(path, 'utf8'));
|
|
const totp = d?.settings?.totp;
|
|
if (!totp) {
|
|
failures += 1;
|
|
console.error(` ✗ ${localeFile} is missing settings.totp tree`);
|
|
continue;
|
|
}
|
|
const recommendedTree = totp.recommended_apps ?? {};
|
|
const recommendedStrings = JSON.stringify(recommendedTree);
|
|
expect(
|
|
`${localeFile}: recommended_apps tree has no 'Google Authenticator'`,
|
|
!/google\s*authenticator/i.test(recommendedStrings)
|
|
);
|
|
expect(
|
|
`${localeFile}: recommended_apps tree has no 'Microsoft Authenticator'`,
|
|
!/microsoft\s*authenticator/i.test(recommendedStrings)
|
|
);
|
|
expect(
|
|
`${localeFile}: recommended_apps tree has no 'Authy'`,
|
|
!/\bauthy\b/i.test(recommendedStrings)
|
|
);
|
|
}
|
|
|
|
// 4. The 2FA route renders BOTH lists.
|
|
console.log('\n4. settings/security/2fa route renders both lists:');
|
|
expect(
|
|
'route imports RECOMMENDED_AUTHENTICATOR_APPS',
|
|
/RECOMMENDED_AUTHENTICATOR_APPS/.test(TFA_ROUTE)
|
|
);
|
|
expect(
|
|
'route imports NOT_RECOMMENDED_AUTHENTICATOR_APPS',
|
|
/NOT_RECOMMENDED_AUTHENTICATOR_APPS/.test(TFA_ROUTE)
|
|
);
|
|
expect(
|
|
'route iterates recommended apps via #each (directly or as a non-mutating sorted copy)',
|
|
/each\s+RECOMMENDED_AUTHENTICATOR_APPS/.test(TFA_ROUTE) ||
|
|
/\[\s*\.\.\.\s*RECOMMENDED_AUTHENTICATOR_APPS\s*\]\.sort\b/.test(TFA_ROUTE)
|
|
);
|
|
expect(
|
|
'route iterates not-recommended apps via #each (directly or as a non-mutating sorted copy)',
|
|
/each\s+NOT_RECOMMENDED_AUTHENTICATOR_APPS/.test(TFA_ROUTE) ||
|
|
/\[\s*\.\.\.\s*NOT_RECOMMENDED_AUTHENTICATOR_APPS\s*\]\.sort\b/.test(TFA_ROUTE)
|
|
);
|
|
expect(
|
|
'not-recommended section is in a <details> (collapsible, not hidden)',
|
|
/<details[^>]*class="apps not-recommended"/.test(TFA_ROUTE) ||
|
|
/class="apps not-recommended"[^>]*>[\s\S]{0,200}?<\/details>/.test(TFA_ROUTE) ||
|
|
/<details[\s\S]{0,500}?NOT_RECOMMENDED/.test(TFA_ROUTE)
|
|
);
|
|
|
|
console.log(`\n${passes} passed, ${failures} failed`);
|
|
if (failures > 0) process.exit(1);
|
|
console.log(`✓ all ${passes} 2fa-no-google-recommendation-smoke scenarios passed`);
|