Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cyan-days-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/scanner": minor
---

feat(scanner): read .npmrc scoped registries for private package resolution
22 changes: 14 additions & 8 deletions workspaces/scanner/src/depWalker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,6 +96,7 @@ type WalkerOptions = Omit<Options, "registry"> & {
registry: string;
location?: string;
npmRcConfig?: Config;
npmRcEntries?: Record<string, string>;
};

type InitialPayload =
Expand Down Expand Up @@ -122,16 +124,18 @@ export async function depWalker(
vulnerabilityStrategy = Vulnera.strategies.NONE,
registry,
npmRcConfig,
npmRcEntries = {},
maxConcurrency = 8
} = options;

const statsCollector = new StatsCollector({ logger }, { isVerbose });

const collectables = kCollectableTypes.map((type) => new DefaultCollectableSet<Metadata>(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<void> {
Expand All @@ -140,7 +144,7 @@ export async function depWalker(
"tarball-scan",
() => pacote.extract(spec, dest, {
...opts,
...npmProjectConfig
...pacoteScopedConfig
})
);
}
Expand Down Expand Up @@ -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 }))
}
}
});
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
);
Expand Down
7 changes: 5 additions & 2 deletions workspaces/scanner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
}
);

Expand Down
14 changes: 13 additions & 1 deletion workspaces/scanner/src/registry/RegistryTokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ export class RegistryTokenStore implements TokenStore {
#memo: Map<string, string | undefined> = new Map();
#config: Config | undefined;
#tokenFromEnv: string | undefined;
constructor(config: Config | undefined, tokenFromEnv: string | undefined) {
#npmRcEntries: Record<string, string>;

constructor(
config: Config | undefined,
tokenFromEnv: string | undefined,
npmRcEntries: Record<string, string> = {}
) {
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)) {
Expand Down
1 change: 1 addition & 0 deletions workspaces/scanner/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } :
Expand Down
70 changes: 70 additions & 0 deletions workspaces/scanner/src/utils/npmrc.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
const entries: Record<string, string> = {};

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<Record<string, string>> {
try {
const content = await readFile(filePath, "utf-8");

return parseNpmRc(content);
}
catch {
return {};
}
}

export async function readNpmRc(location: string): Promise<Record<string, string>> {
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<string, string>,
defaultRegistry: string
): string {
const scopeMatch = packageName.match(/^(@[^/]+)\//);
if (scopeMatch) {
const scopeKey = `${scopeMatch[1]}:registry`;
if (scopeKey in npmRcEntries) {
return npmRcEntries[scopeKey];
}
}

return defaultRegistry;
}
40 changes: 40 additions & 0 deletions workspaces/scanner/test/RegistryTokenStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});
});
});
Loading
Loading