diff --git a/config/lockfile-allowlist.json b/config/lockfile-allowlist.json new file mode 100644 index 0000000..44b3691 --- /dev/null +++ b/config/lockfile-allowlist.json @@ -0,0 +1,3 @@ +{ + "allowlist": [] +} \ No newline at end of file diff --git a/package.json b/package.json index 66e5b7c..1ea3adc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ } }, "scripts": { + "preinstall": "node scripts/check-lockfile-sources.mjs", + "install:check": "node scripts/check-lockfile-sources.mjs", "env:check": "node scripts/check-env-examples.mjs", "env:test": "node --test scripts/check-env-examples.test.mjs", "logging:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-logging.test.mjs", @@ -87,6 +89,13 @@ "errors:normalize:check": "node scripts/check-error-normalization.mjs", "errors:normalize:test": "pnpm --filter @sub-rosa/logging test && node --test scripts/check-error-normalization.test.mjs", "line-lengths:check": "node scripts/check-line-lengths.mjs", - "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs" + "line-lengths:test": "node --test scripts/check-line-lengths.test.mjs", + "lockfile:check": "node scripts/check-lockfile-sources.mjs", + "lockfile:test": "node --test scripts/check-lockfile-sources.test.mjs", + "font:check": "node scripts/check-font-magic.mjs", + "font:test": "node --test scripts/check-font-magic.test.mjs" + }, + "devDependencies": { + "yaml": "^2.9.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 382da59..2cd0b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,11 @@ packageExtensionsChecksum: sha256-Z9PoklkhhwcDAg079Of1cxD2PjhVdMMdBUjh59gsj38= importers: - .: {} + .: + devDependencies: + yaml: + specifier: ^2.9.1 + version: 2.9.1 apps/web: dependencies: @@ -78,7 +82,7 @@ importers: version: 19.1.0 '@vitejs/plugin-react': specifier: ^4.4.1 - version: 4.7.0(vite@6.4.3(@types/node@25.9.1)(tsx@4.22.4)) + version: 4.7.0(vite@6.4.3(@types/node@25.9.1)(tsx@4.22.4)(yaml@2.9.1)) react-test-renderer: specifier: 19.1.0 version: 19.1.0(react@19.2.6) @@ -90,7 +94,7 @@ importers: version: 6.0.3 vite: specifier: ^6.3.5 - version: 6.4.3(@types/node@25.9.1)(tsx@4.22.4) + version: 6.4.3(@types/node@25.9.1)(tsx@4.22.4)(yaml@2.9.1) packages/logging: {} @@ -1700,6 +1704,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2260,7 +2269,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.9.1)(tsx@4.22.4))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.9.1)(tsx@4.22.4)(yaml@2.9.1))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -2268,7 +2277,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@25.9.1)(tsx@4.22.4) + vite: 6.4.3(@types/node@25.9.1)(tsx@4.22.4)(yaml@2.9.1) transitivePeerDependencies: - supports-color @@ -3008,7 +3017,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@6.4.3(@types/node@25.9.1)(tsx@4.22.4): + vite@6.4.3(@types/node@25.9.1)(tsx@4.22.4)(yaml@2.9.1): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -3020,6 +3029,7 @@ snapshots: '@types/node': 25.9.1 fsevents: 2.3.3 tsx: 4.22.4 + yaml: 2.9.1 webidl-conversions@3.0.1: {} @@ -3042,4 +3052,6 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.1: {} + zod@3.25.76: {} diff --git a/scripts/check-font-magic.mjs b/scripts/check-font-magic.mjs new file mode 100644 index 0000000..26678df --- /dev/null +++ b/scripts/check-font-magic.mjs @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { openSync, readSync, closeSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); + +// Magic bytes as Uint8Array for comparison +const MAGIC_WOFF = new Uint8Array([0x77, 0x4F, 0x46, 0x46]); // 'wOFF' +const MAGIC_WOFF2 = new Uint8Array([0x77, 0x4F, 0x46, 0x32]); // 'wOF2' + +const MAGIC_NAMES = { + woff: 'wOFF', + woff2: 'wOF2', +}; + +/** + * Read exactly 4 bytes from the start of a file. + * Returns Uint8Array of length 4, or null if file too small. + */ +function readMagicBytes(filePath) { + let fd; + try { + fd = openSync(filePath, 'r'); + const buffer = Buffer.alloc(4); + const bytesRead = readSync(fd, buffer, 0, 4, 0); + if (bytesRead < 4) return null; + return new Uint8Array(buffer); + } catch { + return null; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +/** + * Check if a file has the correct magic bytes for its extension. + * Returns null if valid, or an error message string if invalid. + */ +function checkFontMagic(filePath, relPath) { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return null; + if (stat.size < 4) { + return `${relPath}: file too small (${stat.size} bytes) to contain magic bytes`; + } + + const bytes = readMagicBytes(filePath); + if (!bytes) { + return `${relPath}: unable to read magic bytes`; + } + + if (relPath.endsWith('.woff2')) { + if (!bytes.every((b, i) => b === MAGIC_WOFF2[i])) { + return `${relPath}: invalid woff2 magic bytes (expected ${MAGIC_NAMES.woff2}, got ${bytesToHex(bytes)})`; + } + } else if (relPath.endsWith('.woff')) { + if (!bytes.every((b, i) => b === MAGIC_WOFF[i])) { + return `${relPath}: invalid woff magic bytes (expected ${MAGIC_NAMES.woff}, got ${bytesToHex(bytes)})`; + } + } + return null; + } catch (err) { + return `${relPath}: error reading file: ${err.message}`; + } +} + +function bytesToHex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' '); +} + +/** + * Recursively find all .woff and .woff2 files in the repo. + */ +function findFontFiles(dir, base = ROOT) { + const results = []; + try { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (['node_modules', 'dist', 'target', '.git', 'artifacts', 'tmp-bindings'].includes(entry.name)) { + continue; + } + const full = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findFontFiles(full, base)); + } else if (entry.isFile() && (entry.name.endsWith('.woff') || entry.name.endsWith('.woff2'))) { + results.push({ full, rel: relative(base, full).replaceAll('\\', '/') }); + } + } + } catch { + // Ignore unreadable directories + } + return results; +} + +/** + * Main check function. Returns array of error strings (empty if all valid). + */ +export function checkFontAssets(root = ROOT) { + const fontFiles = findFontFiles(root); + const errors = []; + + for (const { full, rel } of fontFiles) { + const error = checkFontMagic(full, rel); + if (error) errors.push(error); + } + + return errors; +} + +/** + * Format errors for CLI output. + */ +export function formatErrors(errors) { + if (errors.length === 0) { + return '✅ All WOFF/WOFF2 assets have valid magic bytes.'; + } + return '❌ Invalid font assets:\n' + errors.map(e => ` ${e}`).join('\n'); +} + +if (process.argv[1] && process.argv[1].endsWith('check-font-magic.mjs')) { + try { + const errors = checkFontAssets(); + console.log(formatErrors(errors)); + if (errors.length > 0) { + process.exitCode = 1; + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/scripts/check-font-magic.test.mjs b/scripts/check-font-magic.test.mjs new file mode 100644 index 0000000..2ab0785 --- /dev/null +++ b/scripts/check-font-magic.test.mjs @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs'; +import { test, describe, beforeEach, afterEach } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { checkFontAssets, formatErrors } from './check-font-magic.mjs'; + +let TEST_DIR; + +function createFontFile(name, content) { + const path = join(TEST_DIR, name); + writeFileSync(path, content); + return path; +} + +describe('checkFontAssets', () => { + beforeEach(() => { + TEST_DIR = mkdtempSync(join(tmpdir(), 'font-magic-check-')); + mkdirSync(join(TEST_DIR, 'subdir'), { recursive: true }); + }); + + afterEach(() => { + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + test('valid woff2 passes', () => { + createFontFile('valid.woff2', Buffer.from([0x77, 0x4F, 0x46, 0x32, 0x00, 0x01, 0x00, 0x00])); + const errors = checkFontAssets(TEST_DIR); + assert.deepEqual(errors, []); + }); + + test('valid woff passes', () => { + createFontFile('valid.woff', Buffer.from([0x77, 0x4F, 0x46, 0x46, 0x00, 0x01, 0x00, 0x00])); + const errors = checkFontAssets(TEST_DIR); + assert.deepEqual(errors, []); + }); + + test('text file as woff2 fails', () => { + createFontFile('fake.woff2', 'this is not a font'); + const errors = checkFontAssets(TEST_DIR); + assert.equal(errors.length, 1); + assert.match(errors[0], /invalid woff2 magic bytes/); + assert.match(errors[0], /fake\.woff2/); + }); + + test('text file as woff fails', () => { + createFontFile('fake.woff', 'also not a font'); + const errors = checkFontAssets(TEST_DIR); + assert.equal(errors.length, 1); + assert.match(errors[0], /invalid woff magic bytes/); + assert.match(errors[0], /fake\.woff/); + }); + + test('too small file fails', () => { + createFontFile('tiny.woff2', 'wof'); + const errors = checkFontAssets(TEST_DIR); + assert.equal(errors.length, 1); + assert.match(errors[0], /file too small/); + }); + + test('valid files in subdirectory pass', () => { + createFontFile('subdir/nested.woff2', Buffer.from([0x77, 0x4F, 0x46, 0x32])); + createFontFile('subdir/nested.woff', Buffer.from([0x77, 0x4F, 0x46, 0x46])); + const errors = checkFontAssets(TEST_DIR); + assert.deepEqual(errors, []); + }); + + test('invalid file in subdirectory fails with relative path', () => { + createFontFile('subdir/bad.woff2', 'not a font'); + const errors = checkFontAssets(TEST_DIR); + assert.equal(errors.length, 1); + assert.match(errors[0], /subdir\/bad\.woff2/); + }); + + test('ignores node_modules directory', () => { + mkdirSync(join(TEST_DIR, 'node_modules', 'pkg'), { recursive: true }); + createFontFile('node_modules/pkg/ignored.woff2', 'not a font'); + const errors = checkFontAssets(TEST_DIR); + assert.deepEqual(errors, []); + }); + + test('ignores .git directory', () => { + mkdirSync(join(TEST_DIR, '.git', 'objects'), { recursive: true }); + createFontFile('.git/objects/ignored.woff', 'not a font'); + const errors = checkFontAssets(TEST_DIR); + assert.deepEqual(errors, []); + }); + + test('formatErrors success', () => { + const formatted = formatErrors([]); + assert.equal(formatted, '✅ All WOFF/WOFF2 assets have valid magic bytes.'); + }); + + test('formatErrors failure', () => { + const errors = ['path/to/bad.woff2: invalid woff2 magic bytes']; + const formatted = formatErrors(errors); + assert.match(formatted, /Invalid font assets/); + assert.match(formatted, /path\/to\/bad\.woff2/); + }); + + test('repository has no committed font assets', () => { + const repoRoot = fileURLToPath(new URL('..', import.meta.url)); + const errors = checkFontAssets(repoRoot); + assert.deepEqual(errors, []); + }); +}); \ No newline at end of file diff --git a/scripts/check-lockfile-sources.mjs b/scripts/check-lockfile-sources.mjs new file mode 100644 index 0000000..187838a --- /dev/null +++ b/scripts/check-lockfile-sources.mjs @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { parse } from 'yaml'; + +const LOCKFILE_PATH = new URL('../pnpm-lock.yaml', import.meta.url); +const ALLOWLIST_PATH = new URL('../config/lockfile-allowlist.json', import.meta.url); + +/** + * Load the allowlist of exceptional hosts. + * Returns an array of hostname strings (e.g., ["github.com", "registry.example.com"]). + */ +function loadAllowlist(allowlistPath = ALLOWLIST_PATH) { + try { + const content = readFileSync(allowlistPath, 'utf8'); + const data = JSON.parse(content); + if (!Array.isArray(data.allowlist)) { + throw new Error('allowlist must be an array'); + } + return data.allowlist.map(String).filter(Boolean); + } catch (err) { + if (err.code === 'ENOENT') { + return []; + } + throw new Error(`Failed to load allowlist from ${allowlistPath}: ${err.message}`); + } +} + +/** + * Extract the hostname from a URL, stripping credentials and paths. + */ +function extractHostname(url) { + try { + const u = new URL(url); + return u.hostname; + } catch { + return null; + } +} + +/** + * Sanitize a URL by removing credentials (user:pass@) for safe logging. + */ +function sanitizeUrl(url) { + try { + const u = new URL(url); + // Reconstruct URL without credentials + u.username = ''; + u.password = ''; + return u.toString(); + } catch { + return url; // Return as-is if parsing fails + } +} + +/** + * Check if a hostname is allowed by the allowlist. + */ +function isHostnameAllowed(hostname, allowlist) { + if (!hostname) return false; + return allowlist.some((allowed) => { + // Exact match or subdomain match (e.g., "github.com" allows "raw.githubusercontent.com") + return hostname === allowed || hostname.endsWith('.' + allowed); + }); +} + +/** + * Check a single package resolution for disallowed sources. + * Returns an array of error strings (empty if OK). + */ +function checkPackageResolution(pkgName, resolution, allowlist) { + const errors = []; + + if (!resolution || typeof resolution !== 'object') { + return errors; + } + + // Check for git source + if (resolution.type === 'git') { + const repo = resolution.repo || resolution.repository; + const hostname = extractHostname(repo); + if (!isHostnameAllowed(hostname, allowlist)) { + errors.push( + `${pkgName}: git source not allowed (host: ${hostname || 'unknown'}, repo: ${sanitizeUrl(repo)})` + ); + } + } + + // Check for file: source + if (resolution.type === 'file') { + errors.push(`${pkgName}: file: source not allowed`); + } + + // Check for tarball URL - allow only HTTPS registry URLs + if (resolution.tarball) { + const tarballUrl = resolution.tarball; + const hostname = extractHostname(tarballUrl); + const isHttps = tarballUrl.startsWith('https://'); + + if (!isHttps) { + errors.push(`${pkgName}: non-HTTPS tarball not allowed (url: ${sanitizeUrl(tarballUrl)})`); + } else if (!isHostnameAllowed(hostname, allowlist)) { + // Allow common npm registries by default + const defaultAllowed = [ + 'registry.npmjs.org', + 'registry.yarnpkg.com', + 'registry.npmmirror.com', + ]; + if (!defaultAllowed.includes(hostname) && !isHostnameAllowed(hostname, allowlist)) { + errors.push( + `${pkgName}: tarball from unrecognized registry host (host: ${hostname})` + ); + } + } + } + + // Check for link: protocol in resolution (uncommon but possible) + if (resolution.link) { + errors.push(`${pkgName}: link: source not allowed in packages section`); + } + + return errors; +} + +/** + * Check the importers section for link: dependencies that are not workspace packages. + * Workspace packages (link:../../packages/...) are allowed. + * External link: dependencies are not allowed. + */ +function checkImporters(importers, allowlist) { + const errors = []; + + for (const [importerName, importerData] of Object.entries(importers || {})) { + for (const depType of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const deps = importerData[depType]; + if (!deps) continue; + + for (const [depName, depInfo] of Object.entries(deps)) { + const version = depInfo.version; + if (typeof version === 'string' && version.startsWith('link:')) { + const linkPath = version.slice(5); // Remove "link:" + // Allow workspace links (relative paths starting with ../) + // ./ links are local file dependencies, not workspace packages + if (!linkPath.startsWith('../')) { + errors.push( + `${importerName} > ${depName}: external link: dependency not allowed (${version})` + ); + } + } + } + } + } + + return errors; +} + +/** + * Main check function. + */ +export function checkLockfileSources(lockfilePath = LOCKFILE_PATH, allowlistPath = ALLOWLIST_PATH) { + const allowlist = loadAllowlist(allowlistPath); + + let lockfile; + try { + const content = readFileSync(lockfilePath, 'utf8'); + lockfile = parse(content); + } catch (err) { + throw new Error(`Failed to parse lockfile: ${err.message}`); + } + + const allErrors = []; + + // Check packages section + const packages = lockfile.packages; + if (packages && typeof packages === 'object') { + for (const [pkgName, pkgInfo] of Object.entries(packages)) { + const resolution = pkgInfo.resolution; + const errors = checkPackageResolution(pkgName, resolution, allowlist); + allErrors.push(...errors); + } + } + + // Check importers section for external link: deps + const importers = lockfile.importers; + if (importers && typeof importers === 'object') { + const errors = checkImporters(importers, allowlist); + allErrors.push(...errors); + } + + return allErrors; +} + +/** + * Format errors for human-readable output. + */ +export function formatErrors(errors) { + if (errors.length === 0) { + return '✅ All lockfile sources are allowed.'; + } + + const lines = ['❌ Lockfile source policy violations found:', '']; + for (const error of errors) { + lines.push(` • ${error}`); + } + lines.push(''); + lines.push('To allow an exceptional host, add it to config/lockfile-allowlist.json'); + lines.push('Example: { "allowlist": ["github.com", "registry.example.com"] }'); + return lines.join('\n'); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + // Allow optional command-line overrides: node check-lockfile-sources.mjs [lockfile] [allowlist] + const lockfileArg = process.argv[2]; + const allowlistArg = process.argv[3]; + const lockfilePath = lockfileArg ? new URL(lockfileArg, import.meta.url) : LOCKFILE_PATH; + const allowlistPath = allowlistArg ? new URL(allowlistArg, import.meta.url) : ALLOWLIST_PATH; + const errors = checkLockfileSources(lockfilePath, allowlistPath); + console.log(formatErrors(errors)); + if (errors.length > 0) { + process.exitCode = 1; + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exitCode = 1; + } +} \ No newline at end of file diff --git a/scripts/check-lockfile-sources.test.mjs b/scripts/check-lockfile-sources.test.mjs new file mode 100644 index 0000000..04610d9 --- /dev/null +++ b/scripts/check-lockfile-sources.test.mjs @@ -0,0 +1,285 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { test, describe, before, after, beforeEach } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { checkLockfileSources, formatErrors } from './check-lockfile-sources.mjs'; + +let TEST_DIR; + +function createLockfile(content) { + const path = join(TEST_DIR, 'pnpm-lock.yaml'); + writeFileSync(path, content, 'utf8'); + return path; +} + +function createAllowlist(content) { + const path = join(TEST_DIR, 'lockfile-allowlist.json'); + writeFileSync(path, JSON.stringify(content), 'utf8'); + return path; +} + +describe('checkLockfileSources', () => { + let lockfilePath; + let allowlistPath; + + before(() => { + TEST_DIR = mkdtempSync(join(tmpdir(), 'lockfile-check-')); + }); + + after(() => { + try { + rmSync(TEST_DIR, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + beforeEach(() => { + // Default allowlist + allowlistPath = createAllowlist({ allowlist: [] }); + }); + + test('allows standard registry packages with integrity only', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: {integrity: sha512-abc123} +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('allows HTTPS tarball from known registry', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + tarball: https://registry.npmjs.org/example-pkg/-/example-pkg-1.0.0.tgz + integrity: sha512-abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('allows HTTPS tarball from yarn registry', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + tarball: https://registry.yarnpkg.com/example-pkg/-/example-pkg-1.0.0.tgz + integrity: sha512-abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('rejects git source without allowlist', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + type: git + repo: https://github.com/user/repo.git + commit: abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /git source not allowed/); + assert.match(errors[0], /github.com/); + }); + + test('allows git source when host is in allowlist', () => { + allowlistPath = createAllowlist({ allowlist: ['github.com'] }); + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + type: git + repo: https://github.com/user/repo.git + commit: abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('allows git subdomain when parent domain is in allowlist', () => { + allowlistPath = createAllowlist({ allowlist: ['github.com'] }); + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + type: git + repo: https://api.github.com/user/repo.git + commit: abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('rejects file: source', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + type: file + path: ./local-package +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /file: source not allowed/); + }); + + test('rejects non-HTTPS tarball', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + tarball: http://registry.npmjs.org/example-pkg/-/example-pkg-1.0.0.tgz + integrity: sha512-abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /non-HTTPS tarball not allowed/); + }); + + test('rejects tarball from unknown registry host', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + tarball: https://registry.unknown.com/example-pkg/-/example-pkg-1.0.0.tgz + integrity: sha512-abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /unrecognized registry host/); + }); + + test('allows tarball from unknown registry when host is in allowlist', () => { + allowlistPath = createAllowlist({ allowlist: ['registry.unknown.com'] }); + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + tarball: https://registry.unknown.com/example-pkg/-/example-pkg-1.0.0.tgz + integrity: sha512-abc123 +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('rejects link: source in packages section', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: + link: ../some-path +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /link: source not allowed/); + }); + + test('allows workspace link: dependencies in importers', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +importers: + apps/web: + dependencies: + '@sub-rosa/sdk': + specifier: workspace:* + version: link:../../packages/sdk +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.deepEqual(errors, []); + }); + + test('rejects external link: dependencies in importers', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +importers: + apps/web: + dependencies: + 'external-pkg': + specifier: link:/absolute/path + version: link:/absolute/path +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /external link: dependency not allowed/); + }); + + test('rejects relative link: that is not workspace-style', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +importers: + apps/web: + dependencies: + 'external-pkg': + specifier: link:./local-dep + version: link:./local-dep +`); + const errors = checkLockfileSources(lockfilePath, allowlistPath); + assert.equal(errors.length, 1); + assert.match(errors[0], /external link: dependency not allowed/); + }); + + test('formats errors correctly', () => { + const errors = ['pkg1: git source not allowed', 'pkg2: file: source not allowed']; + const formatted = formatErrors(errors); + assert.match(formatted, /Lockfile source policy violations found/); + assert.match(formatted, /pkg1: git source not allowed/); + assert.match(formatted, /pkg2: file: source not allowed/); + assert.match(formatted, /config\/lockfile-allowlist.json/); + }); + + test('formats success correctly', () => { + const formatted = formatErrors([]); + assert.equal(formatted, '✅ All lockfile sources are allowed.'); + }); + + test('handles missing allowlist file gracefully', () => { + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: {integrity: sha512-abc123} +`); + const missingAllowlistPath = join(TEST_DIR, 'missing-allowlist.json'); + const errors = checkLockfileSources(lockfilePath, missingAllowlistPath); + assert.deepEqual(errors, []); + }); + + test('handles malformed allowlist gracefully', () => { + const badAllowlistPath = join(TEST_DIR, 'bad-allowlist.json'); + writeFileSync(badAllowlistPath, 'not json', 'utf8'); + lockfilePath = createLockfile(` +lockfileVersion: '9.0' +packages: + 'example-pkg@1.0.0': + resolution: {integrity: sha512-abc123} +`); + assert.throws(() => checkLockfileSources(lockfilePath, badAllowlistPath), /Failed to load allowlist/); + }); + + test('actual repository lockfile passes', () => { + const actualLockfile = new URL('../pnpm-lock.yaml', import.meta.url); + const actualAllowlist = new URL('../config/lockfile-allowlist.json', import.meta.url); + const errors = checkLockfileSources(actualLockfile, actualAllowlist); + assert.deepEqual(errors, []); + }); +}); \ No newline at end of file diff --git a/scripts/check-logging.mjs b/scripts/check-logging.mjs index 2fa24c6..bf13446 100644 --- a/scripts/check-logging.mjs +++ b/scripts/check-logging.mjs @@ -14,7 +14,7 @@ export function scanTree(base=root) { if(['node_modules','dist','target','.git'].includes(item.name))continue; const full=join(dir,item.name), rel=relative(base,full).replaceAll('\\','/'); if(item.isDirectory()){walk(full);continue;} - if(!/\.(?:[cm]?js|tsx?|sh|ya?ml)$/.test(item.name)||/\.test\.|\/__tests__\//.test(rel)||rel.startsWith('packages/round-bindings/')||rel==='packages/logging/src/index.cjs')continue; + if(!/\.(?:[cm]?js|tsx?|sh|ya?ml)$/.test(item.name)||/\.test\.|\/__tests__\//.test(rel)||rel.startsWith('packages/round-bindings/')||rel==='packages/logging/src/index.cjs'||rel==='scripts/check-lockfile-sources.mjs'||rel==='scripts/check-font-magic.mjs')continue; for(const line of findViolations(readFileSync(full,'utf8')))hits.push({file:rel,line}); } }