diff --git a/.github/workflows/auth-integration-tests.yml b/.github/workflows/auth-integration-tests.yml index b5ec896a..1efb53e5 100644 --- a/.github/workflows/auth-integration-tests.yml +++ b/.github/workflows/auth-integration-tests.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index 140fa3bc..eb38e98a 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -7,12 +7,18 @@ on: paths: - 'packages/**' - 'scripts/build.ts' + - 'scripts/check-release-age.ts' + - 'bun.lock' + - 'bunfig.toml' - 'package.json' - '.github/workflows/build-packages.yml' pull_request: paths: - 'packages/**' - 'scripts/build.ts' + - 'scripts/check-release-age.ts' + - 'bun.lock' + - 'bunfig.toml' - 'package.json' - '.github/workflows/build-packages.yml' @@ -26,7 +32,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 + + - name: Check dependency release age + run: bun run security:release-age - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/core-integration-tests.yml b/.github/workflows/core-integration-tests.yml index 583c1023..7e0853ff 100644 --- a/.github/workflows/core-integration-tests.yml +++ b/.github/workflows/core-integration-tests.yml @@ -30,7 +30,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/core_tests.yml b/.github/workflows/core_tests.yml index f35b1df6..adcdba66 100644 --- a/.github/workflows/core_tests.yml +++ b/.github/workflows/core_tests.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/dependency-release-age.yml b/.github/workflows/dependency-release-age.yml new file mode 100644 index 00000000..2e454bb4 --- /dev/null +++ b/.github/workflows/dependency-release-age.yml @@ -0,0 +1,36 @@ +name: dependency-release-age + +on: + push: + branches: + - master + paths: + - 'bun.lock' + - 'bunfig.toml' + - 'package.json' + - 'packages/*/package.json' + - 'scripts/check-release-age.ts' + - '.github/workflows/dependency-release-age.yml' + pull_request: + paths: + - 'bun.lock' + - 'bunfig.toml' + - 'package.json' + - 'packages/*/package.json' + - 'scripts/check-release-age.ts' + - '.github/workflows/dependency-release-age.yml' + +jobs: + dependency-release-age: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Check dependency release age + run: bun run security:release-age diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3932be53..c85e802b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -31,7 +31,7 @@ jobs: fetch-depth: 0 - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Setup Pages uses: actions/configure-pages@v4 - name: Install dependencies diff --git a/.github/workflows/expo-sqlite-integration-tests.yml b/.github/workflows/expo-sqlite-integration-tests.yml index e141f2e1..348c0938 100644 --- a/.github/workflows/expo-sqlite-integration-tests.yml +++ b/.github/workflows/expo-sqlite-integration-tests.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 3e8209a0..e5c4250a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -42,7 +42,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/indexeddb-integration-tests.yml b/.github/workflows/indexeddb-integration-tests.yml index 8ce86679..0945a9a1 100644 --- a/.github/workflows/indexeddb-integration-tests.yml +++ b/.github/workflows/indexeddb-integration-tests.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc172889..ffb429e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,7 +23,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 + + - name: Check dependency release age + run: bun run security:release-age - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/sqlite-bun-integration-tests.yml b/.github/workflows/sqlite-bun-integration-tests.yml index 49f862e4..e47d9026 100644 --- a/.github/workflows/sqlite-bun-integration-tests.yml +++ b/.github/workflows/sqlite-bun-integration-tests.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/sqlite3-integration-tests.yml b/.github/workflows/sqlite3-integration-tests.yml index 0a234b77..da40c400 100644 --- a/.github/workflows/sqlite3-integration-tests.yml +++ b/.github/workflows/sqlite3-integration-tests.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.2.18 + bun-version: 1.3.11 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..66a75464 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[install] +# Only resolve npm package versions that have been available for at least 7 days. +minimumReleaseAge = 604800 diff --git a/package.json b/package.json index ce8dd7dd..2d44006f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "docs:dev": "vitepress dev packages/docs", "docs:build": "vitepress build packages/docs", "docs:preview": "vitepress preview packages/docs", + "security:release-age": "bun scripts/check-release-age.ts", "format": "prettier . --write", "format:check": "prettier . --check" } diff --git a/scripts/check-release-age.ts b/scripts/check-release-age.ts new file mode 100644 index 00000000..eb664ebf --- /dev/null +++ b/scripts/check-release-age.ts @@ -0,0 +1,337 @@ +#!/usr/bin/env bun + +const DEFAULT_BUNFIG_PATH = 'bunfig.toml'; +const DEFAULT_LOCKFILE_PATH = 'bun.lock'; +const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org'; +const DEFAULT_CONCURRENCY = 24; +const MAX_DISPLAYED_FAILURES = 25; + +type ReleaseAgeConfig = { + minimumReleaseAgeSeconds: number; + excludes: Set; +}; + +type LockedPackage = { + name: string; + version: string; + line: number; +}; + +type RegistryMetadata = { + time?: Record; +}; + +type CheckedPackage = LockedPackage & { + publishedAt?: Date; + ageSeconds?: number; + warning?: string; +}; + +type FailedPackage = CheckedPackage & { + publishedAt: Date; + ageSeconds: number; +}; + +function readArg(name: string): string | undefined { + const prefix = `${name}=`; + return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); +} + +function usage(): string { + return [ + 'Usage: bun scripts/check-release-age.ts [--config=path] [--lockfile=path]', + '', + 'Checks bun.lock against the minimumReleaseAge configured in bunfig.toml.', + 'Set NPM_REGISTRY_URL to use a registry other than https://registry.npmjs.org.', + ].join('\n'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function readReleaseAgeConfig(configPath: string): Promise { + const text = await Bun.file(configPath).text(); + const parsed = Bun.TOML.parse(text); + if (!isRecord(parsed)) { + throw new Error(`${configPath} does not contain a TOML object`); + } + + const install = parsed.install; + if (!isRecord(install)) { + throw new Error(`${configPath} must define [install].minimumReleaseAge`); + } + + const minimumReleaseAge = install.minimumReleaseAge; + if ( + typeof minimumReleaseAge !== 'number' || + !Number.isFinite(minimumReleaseAge) || + minimumReleaseAge <= 0 + ) { + throw new Error(`${configPath} must define a positive numeric [install].minimumReleaseAge`); + } + + const excludes = install.minimumReleaseAgeExcludes; + if ( + excludes !== undefined && + (!Array.isArray(excludes) || excludes.some((name) => typeof name !== 'string')) + ) { + throw new Error( + `${configPath} [install].minimumReleaseAgeExcludes must be an array of strings`, + ); + } + + return { + minimumReleaseAgeSeconds: minimumReleaseAge, + excludes: new Set(excludes as string[] | undefined), + }; +} + +function parseLockString(raw: string): string { + return JSON.parse(`"${raw}"`) as string; +} + +function parsePackageSpec(spec: string): { name: string; version: string } | null { + const aliasMarker = '@npm:'; + const aliasIndex = spec.lastIndexOf(aliasMarker); + const packageSpec = aliasIndex === -1 ? spec : spec.slice(aliasIndex + aliasMarker.length); + const versionSeparator = packageSpec.lastIndexOf('@'); + + if (versionSeparator <= 0 || versionSeparator === packageSpec.length - 1) { + return null; + } + + const name = packageSpec.slice(0, versionSeparator); + const version = packageSpec.slice(versionSeparator + 1); + if (name.includes(':') || version.includes(':')) { + return null; + } + + return { name, version }; +} + +async function readLockedPackages(lockfilePath: string): Promise { + const text = await Bun.file(lockfilePath).text(); + const packages = new Map(); + const entryPattern = /^\s{4}"(?:\\.|[^"\\])*": \["((?:\\.|[^"\\])*)"/; + + text.split('\n').forEach((line, index) => { + const match = line.match(entryPattern); + if (!match?.[1]) return; + + const spec = parseLockString(match[1]); + const parsed = parsePackageSpec(spec); + if (!parsed) return; + + const key = `${parsed.name}@${parsed.version}`; + if (!packages.has(key)) { + packages.set(key, { + ...parsed, + line: index + 1, + }); + } + }); + + if (packages.size === 0) { + throw new Error(`No npm package entries were found in ${lockfilePath}`); + } + + return [...packages.values()].sort((a, b) => { + const byName = a.name.localeCompare(b.name); + return byName === 0 ? a.version.localeCompare(b.version) : byName; + }); +} + +function packageMetadataUrl(registryUrl: string, name: string): string { + const registry = registryUrl.replace(/\/+$/, ''); + return `${registry}/${encodeURIComponent(name)}`; +} + +async function fetchRegistryMetadata(name: string, registryUrl: string): Promise { + const url = packageMetadataUrl(registryUrl, name); + let lastError: unknown; + + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + }, + }); + + if (response.ok) { + const metadata = await response.json(); + if (!isRecord(metadata)) { + throw new Error(`Registry response for ${name} was not an object`); + } + return metadata as RegistryMetadata; + } + + const retryable = response.status === 429 || response.status >= 500; + if (!retryable || attempt === 3) { + throw new Error(`Registry returned ${response.status} for ${name}`); + } + + lastError = new Error(`Registry returned ${response.status} for ${name}`); + } catch (error) { + lastError = error; + if (attempt === 3) break; + } + + await Bun.sleep(attempt * 1_000); + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +async function mapConcurrent( + values: T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < values.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(values[index]!); + } + } + + const workers = Array.from({ length: Math.min(concurrency, values.length) }, () => worker()); + await Promise.all(workers); + return results; +} + +function formatDuration(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (days === 0 && minutes > 0) parts.push(`${minutes}m`); + return parts.length === 0 ? `${seconds}s` : parts.join(' '); +} + +function getPublishedAt(pkg: LockedPackage, metadata: RegistryMetadata): Date | undefined { + const publishedAt = metadata.time?.[pkg.version]; + if (typeof publishedAt !== 'string') return undefined; + + const date = new Date(publishedAt); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +function groupByPackageName(packages: LockedPackage[]): Map { + const packagesByName = new Map(); + for (const pkg of packages) { + const versions = packagesByName.get(pkg.name); + if (versions) { + versions.push(pkg); + } else { + packagesByName.set(pkg.name, [pkg]); + } + } + return packagesByName; +} + +async function checkPackages( + packages: LockedPackage[], + registryUrl: string, + now: Date, +): Promise { + const packagesByName = groupByPackageName(packages); + const checkedGroups = await mapConcurrent( + [...packagesByName.entries()], + DEFAULT_CONCURRENCY, + async ([name, versions]) => { + const metadata = await fetchRegistryMetadata(name, registryUrl); + + return versions.map((pkg): CheckedPackage => { + const publishedAt = getPublishedAt(pkg, metadata); + if (!publishedAt) { + return { + ...pkg, + warning: `No publish time found for ${pkg.name}@${pkg.version}; treating as allowed`, + }; + } + + return { + ...pkg, + publishedAt, + ageSeconds: (now.getTime() - publishedAt.getTime()) / 1_000, + }; + }); + }, + ); + + return checkedGroups.flat(); +} + +async function main() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + console.log(usage()); + return; + } + + const configPath = readArg('--config') ?? DEFAULT_BUNFIG_PATH; + const lockfilePath = readArg('--lockfile') ?? DEFAULT_LOCKFILE_PATH; + const registryUrl = process.env.NPM_REGISTRY_URL ?? DEFAULT_REGISTRY_URL; + const config = await readReleaseAgeConfig(configPath); + const lockedPackages = await readLockedPackages(lockfilePath); + const packagesToCheck = lockedPackages.filter((pkg) => !config.excludes.has(pkg.name)); + const checkedPackages = await checkPackages(packagesToCheck, registryUrl, new Date()); + const failures = checkedPackages + .filter( + (pkg): pkg is FailedPackage => + pkg.publishedAt !== undefined && + pkg.ageSeconds !== undefined && + pkg.ageSeconds < config.minimumReleaseAgeSeconds, + ) + .sort((a, b) => a.ageSeconds - b.ageSeconds); + const warnings = checkedPackages.filter((pkg) => pkg.warning); + + for (const warning of warnings) { + console.warn(`warning: ${warning.warning}`); + } + + if (failures.length > 0) { + const threshold = formatDuration(config.minimumReleaseAgeSeconds); + console.error( + `Dependency release age check failed: ${failures.length} locked package version(s) ` + + `are newer than ${threshold}.`, + ); + + for (const pkg of failures.slice(0, MAX_DISPLAYED_FAILURES)) { + const age = formatDuration(pkg.ageSeconds); + console.error( + ` - ${pkg.name}@${pkg.version} published ${pkg.publishedAt.toISOString()} ` + + `(${age} old, ${lockfilePath}:${pkg.line})`, + ); + } + + if (failures.length > MAX_DISPLAYED_FAILURES) { + console.error(` ...and ${failures.length - MAX_DISPLAYED_FAILURES} more`); + } + + process.exit(1); + } + + const excludedCount = lockedPackages.length - packagesToCheck.length; + const threshold = formatDuration(config.minimumReleaseAgeSeconds); + const suffix = excludedCount === 0 ? '' : ` (${excludedCount} excluded by bunfig.toml)`; + console.log( + `Checked ${packagesToCheck.length} locked npm package version(s) against ` + + `${threshold} minimum release age${suffix}.`, + ); +} + +main().catch((error: unknown) => { + const normalized = error instanceof Error ? error : new Error(String(error)); + console.error(normalized.message); + process.exit(1); +});