diff --git a/.changeset/cyan-days-read.md b/.changeset/cyan-days-read.md new file mode 100644 index 00000000..21bf6c60 --- /dev/null +++ b/.changeset/cyan-days-read.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/scanner": minor +--- + +feat(scanner): read .npmrc scoped registries for private package resolution diff --git a/workspaces/scanner/src/depWalker.ts b/workspaces/scanner/src/depWalker.ts index 5ad92c9e..e4a479a2 100644 --- a/workspaces/scanner/src/depWalker.ts +++ b/workspaces/scanner/src/depWalker.ts @@ -30,6 +30,7 @@ import { getManifestLinks, NPM_TOKEN } from "./utils/index.ts"; +import { getRegistryForPackage } from "./utils/npmrc.ts"; import { NpmRegistryProvider, type NpmApiClient } from "./registry/NpmRegistryProvider.ts"; import { StatsCollector } from "./class/StatsCollector.class.ts"; import { RegistryTokenStore } from "./registry/RegistryTokenStore.ts"; @@ -95,6 +96,7 @@ type WalkerOptions = Omit & { registry: string; location?: string; npmRcConfig?: Config; + npmRcEntries?: Record; }; type InitialPayload = @@ -122,6 +124,7 @@ export async function depWalker( vulnerabilityStrategy = Vulnera.strategies.NONE, registry, npmRcConfig, + npmRcEntries = {}, maxConcurrency = 8 } = options; @@ -129,9 +132,10 @@ export async function depWalker( const collectables = kCollectableTypes.map((type) => new DefaultCollectableSet(type)); - const tokenStore = new RegistryTokenStore(npmRcConfig, NPM_TOKEN.token); + const tokenStore = new RegistryTokenStore(npmRcConfig, NPM_TOKEN.token, npmRcEntries); const npmProjectConfig = tokenStore.getConfig(registry); + const pacoteScopedConfig = { ...npmProjectConfig, ...npmRcEntries }; const pacoteProvider: PacoteProvider = { async extract(spec, dest, opts): Promise { @@ -140,7 +144,7 @@ export async function depWalker( "tarball-scan", () => pacote.extract(spec, dest, { ...opts, - ...npmProjectConfig + ...pacoteScopedConfig }) ); } @@ -172,10 +176,10 @@ export async function depWalker( providers: { pacote: { manifest: (spec, opts) => statsCollector.track(`pacote.manifest ${spec}`, "tree-walk", () => pacote.manifest(spec, - { ...opts, ...npmProjectConfig })), + { ...opts, ...pacoteScopedConfig })), packument: (spec, opts) => statsCollector.track(`pacote.packument ${spec}`, "tree-walk", - () => pacote.packument(spec, { ...opts, ...npmProjectConfig })) + () => pacote.packument(spec, { ...opts, ...pacoteScopedConfig })) } } }); @@ -234,9 +238,10 @@ export async function depWalker( const org = parseNpmSpec(name)?.org; if (dependencies.has(name)) { const dep = dependencies.get(name)!; + const packageRegistry = getRegistryForPackage(name, npmRcEntries, registry); operationsQueue.push( new NpmRegistryProvider(name, version, { - registry, + registry: packageRegistry, tokenStore, npmApiClient }).enrichDependencyVersion(dep, dependencyConfusionWarnings, org) @@ -280,16 +285,17 @@ export async function depWalker( } else { fetchedMetadataPackages.add(name); + const packageRegistry = getRegistryForPackage(name, npmRcEntries, registry); const provider = new NpmRegistryProvider(name, version, { - registry, + registry: packageRegistry, tokenStore }); operationsQueue.push(provider.enrichDependency(logger, dependency)); - if (registry !== getNpmRegistryURL() && org) { + if (packageRegistry !== getNpmRegistryURL() && org) { operationsQueue.push( new NpmRegistryProvider(name, version, { - registry, + registry: packageRegistry, tokenStore }).enrichScopedDependencyConfusionWarnings(dependencyConfusionWarnings, org) ); diff --git a/workspaces/scanner/src/index.ts b/workspaces/scanner/src/index.ts index 446d3ef3..65af41ff 100644 --- a/workspaces/scanner/src/index.ts +++ b/workspaces/scanner/src/index.ts @@ -12,7 +12,7 @@ import type Config from "@npmcli/config"; // Import Internal Dependencies import { depWalker } from "./depWalker.ts"; -import { NPM_TOKEN, urlToString } from "./utils/index.ts"; +import { NPM_TOKEN, urlToString, readNpmRc } from "./utils/index.ts"; import { Logger, ScannerLoggerEvents } from "./class/logger.class.ts"; import { TempDirectory } from "./class/TempDirectory.class.ts"; import { comparePayloads } from "./comparePayloads.ts"; @@ -48,13 +48,16 @@ export async function workingDir( location }; + const npmRcEntries = await readNpmRc(location); + const finalizedOptions = Object.assign( { location }, kDefaultWorkingDirOptions, { ...options, packageLock, - registry + registry, + npmRcEntries } ); diff --git a/workspaces/scanner/src/registry/RegistryTokenStore.ts b/workspaces/scanner/src/registry/RegistryTokenStore.ts index 1f3723c9..512e1565 100644 --- a/workspaces/scanner/src/registry/RegistryTokenStore.ts +++ b/workspaces/scanner/src/registry/RegistryTokenStore.ts @@ -8,13 +8,25 @@ export class RegistryTokenStore implements TokenStore { #memo: Map = new Map(); #config: Config | undefined; #tokenFromEnv: string | undefined; - constructor(config: Config | undefined, tokenFromEnv: string | undefined) { + #npmRcEntries: Record; + + constructor( + config: Config | undefined, + tokenFromEnv: string | undefined, + npmRcEntries: Record = {} + ) { this.#config = config; this.#tokenFromEnv = tokenFromEnv; + this.#npmRcEntries = npmRcEntries; } get(registry: string): string | undefined { if (!this.#config) { + const tokenKey = this.getTokenKey(registry); + if (tokenKey in this.#npmRcEntries) { + return this.#npmRcEntries[tokenKey]; + } + return this.#tokenFromEnv; } if (this.#memo.has(registry)) { diff --git a/workspaces/scanner/src/utils/index.ts b/workspaces/scanner/src/utils/index.ts index 28e474fd..98623ece 100644 --- a/workspaces/scanner/src/utils/index.ts +++ b/workspaces/scanner/src/utils/index.ts @@ -5,6 +5,7 @@ export * from "./getLinks.ts"; export * from "./urlToString.ts"; export * from "./getUsedDeps.ts"; export * from "./isNodesecurePayload.ts"; +export * from "./npmrc.ts"; export const NPM_TOKEN = typeof process.env.NODE_SECURE_TOKEN === "string" ? { token: process.env.NODE_SECURE_TOKEN } : diff --git a/workspaces/scanner/src/utils/npmrc.ts b/workspaces/scanner/src/utils/npmrc.ts new file mode 100644 index 00000000..a920fed7 --- /dev/null +++ b/workspaces/scanner/src/utils/npmrc.ts @@ -0,0 +1,70 @@ +// Import Node.js Dependencies +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +function resolveEnvVars(value: string): string { + return value.replace( + /\$\{([^}]+)\}/g, + (_, envVar) => process.env[envVar] ?? "" + ); +} + +export function parseNpmRc(content: string): Record { + const entries: Record = {}; + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) { + continue; + } + + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) { + continue; + } + + const key = trimmed.slice(0, eqIndex).trim(); + const value = resolveEnvVars(trimmed.slice(eqIndex + 1).trim()); + + entries[key] = value; + } + + return entries; +} + +async function readNpmRcFile(filePath: string): Promise> { + try { + const content = await readFile(filePath, "utf-8"); + + return parseNpmRc(content); + } + catch { + return {}; + } +} + +export async function readNpmRc(location: string): Promise> { + const [userEntries, projectEntries] = await Promise.all([ + readNpmRcFile(path.join(os.homedir(), ".npmrc")), + readNpmRcFile(path.join(location, ".npmrc")) + ]); + + return { ...userEntries, ...projectEntries }; +} + +export function getRegistryForPackage( + packageName: string, + npmRcEntries: Record, + defaultRegistry: string +): string { + const scopeMatch = packageName.match(/^(@[^/]+)\//); + if (scopeMatch) { + const scopeKey = `${scopeMatch[1]}:registry`; + if (scopeKey in npmRcEntries) { + return npmRcEntries[scopeKey]; + } + } + + return defaultRegistry; +} diff --git a/workspaces/scanner/test/RegistryTokenStore.spec.ts b/workspaces/scanner/test/RegistryTokenStore.spec.ts index d361bfc5..be0c4d1e 100644 --- a/workspaces/scanner/test/RegistryTokenStore.spec.ts +++ b/workspaces/scanner/test/RegistryTokenStore.spec.ts @@ -85,4 +85,44 @@ always-auth=true }); }); }); + + describe("npmRcEntries", () => { + const npmRcEntries = { + "//npm.private-registry.test/:_authToken": "private-token", + "//other.registry.test/:_authToken": "other-token" + }; + + test("should resolve token from npmRcEntries when no config", () => { + const store = new RegistryTokenStore(undefined, undefined, npmRcEntries); + assert.strictEqual( + store.get("https://npm.private-registry.test/"), + "private-token" + ); + assert.strictEqual( + store.get("https://other.registry.test/"), + "other-token" + ); + }); + + test("should fallback to tokenFromEnv when registry not in npmRcEntries", () => { + const store = new RegistryTokenStore(undefined, "env-token", npmRcEntries); + assert.strictEqual(store.get("https://unknown.registry.test/"), "env-token"); + }); + + test("should prefer npmRcEntries over tokenFromEnv when no config", () => { + const store = new RegistryTokenStore(undefined, "env-token", npmRcEntries); + assert.strictEqual( + store.get("https://npm.private-registry.test/"), + "private-token" + ); + }); + + test("should ignore npmRcEntries when @npmcli/config is provided", () => { + const store = new RegistryTokenStore(config, undefined, npmRcEntries); + assert.strictEqual( + store.get("https://registry.npmjs.org/"), + "public-token" + ); + }); + }); }); diff --git a/workspaces/scanner/test/utils/npmrc.spec.ts b/workspaces/scanner/test/utils/npmrc.spec.ts new file mode 100644 index 00000000..27cfeb9e --- /dev/null +++ b/workspaces/scanner/test/utils/npmrc.spec.ts @@ -0,0 +1,205 @@ +// Import Node.js Dependencies +import { test, describe } from "node:test"; +import assert from "node:assert"; +import * as fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// Import Internal Dependencies +import { parseNpmRc, readNpmRc, getRegistryForPackage } from "../../src/utils/npmrc.ts"; +import { TempDirectory } from "../../src/class/TempDirectory.class.ts"; + +describe("parseNpmRc", () => { + test("should parse scoped registry entries", () => { + const content = ` + @nodesecure-test:registry=https://npm.private-registry.test/ + @other:registry=https://other.registry.com/ + `; + + const result = parseNpmRc(content); + + assert.strictEqual(result["@nodesecure-test:registry"], "https://npm.private-registry.test/"); + assert.strictEqual(result["@other:registry"], "https://other.registry.com/"); + }); + + test("should parse auth token entries", () => { + const content = ` + //npm.private-registry.test/:_authToken=my-token + //registry.npmjs.org/:_authToken=public-token + `; + + const result = parseNpmRc(content); + + assert.strictEqual(result["//npm.private-registry.test/:_authToken"], "my-token"); + assert.strictEqual(result["//registry.npmjs.org/:_authToken"], "public-token"); + }); + + test("should resolve environment variables", () => { + process.env.__TEST_NPMRC_TOKEN__ = "resolved-token"; + + try { + // eslint-disable-next-line no-template-curly-in-string + const content = "//npm.private-registry.test/:_authToken=${__TEST_NPMRC_TOKEN__}"; + const result = parseNpmRc(content); + + assert.strictEqual(result["//npm.private-registry.test/:_authToken"], "resolved-token"); + } + finally { + delete process.env.__TEST_NPMRC_TOKEN__; + } + }); + + test("should resolve undefined env vars to empty string", () => { + // eslint-disable-next-line no-template-curly-in-string + const content = "//npm.private-registry.test/:_authToken=${UNDEFINED_VAR_NPMRC_TEST}"; + const result = parseNpmRc(content); + + assert.strictEqual(result["//npm.private-registry.test/:_authToken"], ""); + }); + + test("should skip comments and empty lines", () => { + const content = ` + # This is a comment + ; This is also a comment + @nodesecure-test:registry=https://npm.private-registry.test/ + `; + + const result = parseNpmRc(content); + + assert.deepStrictEqual(Object.keys(result), ["@nodesecure-test:registry"]); + }); + + test("should skip lines without equals sign", () => { + const content = ` + no-equals-here + @nodesecure-test:registry=https://npm.private-registry.test/ + `; + + const result = parseNpmRc(content); + + assert.strictEqual(Object.keys(result).length, 1); + assert.strictEqual(result["@nodesecure-test:registry"], "https://npm.private-registry.test/"); + }); + + test("should parse a typical .npmrc with mixed entries", () => { + const content = ` + registry=https://registry.npmjs.org/ + always-auth=true + //registry.npmjs.org/:_authToken=public-token + @nodesecure-test:registry=https://npm.private-registry.test/ + //npm.private-registry.test/:_authToken=private-token + `; + + const result = parseNpmRc(content); + + assert.strictEqual(result.registry, "https://registry.npmjs.org/"); + assert.strictEqual(result["always-auth"], "true"); + assert.strictEqual(result["//registry.npmjs.org/:_authToken"], "public-token"); + assert.strictEqual(result["@nodesecure-test:registry"], "https://npm.private-registry.test/"); + assert.strictEqual(result["//npm.private-registry.test/:_authToken"], "private-token"); + }); + + test("should return empty object for empty content", () => { + assert.deepStrictEqual(parseNpmRc(""), {}); + }); +}); + +describe("readNpmRc", () => { + test("should read .npmrc from given location", async() => { + await using tempDir = await TempDirectory.create(); + + const npmrcContent = ` + @nodesecure-test:registry=https://npm.private-registry.test/ + //npm.private-registry.test/:_authToken=test-token + `; + fs.writeFileSync(path.join(tempDir.location, ".npmrc"), npmrcContent); + + const result = await readNpmRc(tempDir.location); + + assert.strictEqual(result["@nodesecure-test:registry"], "https://npm.private-registry.test/"); + assert.strictEqual(result["//npm.private-registry.test/:_authToken"], "test-token"); + }); + + test("should return entries even if location has no .npmrc", async() => { + await using tempDir = await TempDirectory.create(); + + const result = await readNpmRc(tempDir.location); + + assert.ok(typeof result === "object"); + }); + + test("should merge user and project .npmrc (project wins)", async() => { + await using tempDir = await TempDirectory.create(); + + const userNpmrcPath = path.join(os.homedir(), ".npmrc"); + const userNpmrcExists = fs.existsSync(userNpmrcPath); + const userNpmrcBackup = userNpmrcExists ? fs.readFileSync(userNpmrcPath, "utf-8") : null; + + try { + fs.writeFileSync(userNpmrcPath, ` + //registry.npmjs.org/:_authToken=user-token + @shared:registry=https://user.registry.com/ + `); + fs.writeFileSync( + path.join(tempDir.location, ".npmrc"), + "@shared:registry=https://project.registry.com/" + ); + + const result = await readNpmRc(tempDir.location); + + assert.strictEqual(result["@shared:registry"], "https://project.registry.com/"); + assert.strictEqual(result["//registry.npmjs.org/:_authToken"], "user-token"); + } + finally { + if (userNpmrcBackup === null) { + fs.unlinkSync(userNpmrcPath); + } + else { + fs.writeFileSync(userNpmrcPath, userNpmrcBackup); + } + } + }); +}); + +describe("getRegistryForPackage", () => { + const npmRcEntries = { + "@nodesecure-test:registry": "https://npm.private-registry.test/", + "@private:registry": "https://private.registry.com/" + }; + const defaultRegistry = "https://registry.npmjs.org/"; + + test("should return scoped registry for matching scope", () => { + assert.strictEqual( + getRegistryForPackage("@nodesecure-test/utils", npmRcEntries, defaultRegistry), + "https://npm.private-registry.test/" + ); + }); + + test("should return scoped registry for another matching scope", () => { + assert.strictEqual( + getRegistryForPackage("@private/some-lib", npmRcEntries, defaultRegistry), + "https://private.registry.com/" + ); + }); + + test("should return default registry for non-scoped package", () => { + assert.strictEqual( + getRegistryForPackage("express", npmRcEntries, defaultRegistry), + defaultRegistry + ); + }); + + test("should return default registry for unknown scope", () => { + assert.strictEqual( + getRegistryForPackage("@unknown/lib", npmRcEntries, defaultRegistry), + defaultRegistry + ); + }); + + test("should return default registry when no npmRcEntries", () => { + assert.strictEqual( + getRegistryForPackage("@nodesecure-test/utils", {}, defaultRegistry), + defaultRegistry + ); + }); +});