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..774f40e 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,11 @@ "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" + }, + "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-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..5da0d15 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')continue; for(const line of findViolations(readFileSync(full,'utf8')))hits.push({file:rel,line}); } }