diff --git a/.changeset/fair-flies-hide.md b/.changeset/fair-flies-hide.md new file mode 100644 index 00000000..d16b2aef --- /dev/null +++ b/.changeset/fair-flies-hide.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/scanner": minor +--- + +feat(scanner): implement dependency confusion detection diff --git a/package-lock.json b/package-lock.json index f157eada..d840b7a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1830,16 +1830,16 @@ "link": true }, "node_modules/@nodesecure/npm-registry-sdk": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@nodesecure/npm-registry-sdk/-/npm-registry-sdk-4.2.0.tgz", - "integrity": "sha512-TOyYEeMWvIlI+sc16vR9wtuBUXN6hQffOdaiPDHULKqlZlcTA/5Uqk/mITHIBgUXCfY81M68skkZSGYH/7LM3Q==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@nodesecure/npm-registry-sdk/-/npm-registry-sdk-4.4.0.tgz", + "integrity": "sha512-8d8qLJ6tBx6H2ezKikg0LZdrJoEhYcZcKchdGkB+TdkTxEJIXicB8ftS0mnrD+8+k+brknmAVVCEs+EaaLaHFA==", "license": "MIT", "dependencies": { "@nodesecure/npm-types": "^1.3.0", "@openally/httpie": "^1.0.0" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/@nodesecure/npm-types": { @@ -12800,7 +12800,7 @@ "@nodesecure/i18n": "^4.0.2", "@nodesecure/js-x-ray": "^10.0.0", "@nodesecure/mama": "^2.0.2", - "@nodesecure/npm-registry-sdk": "^4.0.0", + "@nodesecure/npm-registry-sdk": "^4.4.0", "@nodesecure/npm-types": "^1.3.0", "@nodesecure/rc": "^5.0.1", "@nodesecure/tarball": "^2.1.0", diff --git a/workspaces/scanner/package.json b/workspaces/scanner/package.json index 81028149..ddf36040 100644 --- a/workspaces/scanner/package.json +++ b/workspaces/scanner/package.json @@ -55,7 +55,7 @@ "@nodesecure/i18n": "^4.0.2", "@nodesecure/js-x-ray": "^10.0.0", "@nodesecure/mama": "^2.0.2", - "@nodesecure/npm-registry-sdk": "^4.0.0", + "@nodesecure/npm-registry-sdk": "^4.4.0", "@nodesecure/npm-types": "^1.3.0", "@nodesecure/rc": "^5.0.1", "@nodesecure/tarball": "^2.1.0", diff --git a/workspaces/scanner/src/depWalker.ts b/workspaces/scanner/src/depWalker.ts index 029f9fcc..e37a50d4 100644 --- a/workspaces/scanner/src/depWalker.ts +++ b/workspaces/scanner/src/depWalker.ts @@ -11,8 +11,9 @@ import { import * as Vulnera from "@nodesecure/vulnera"; import { npm } from "@nodesecure/tree-walker"; import { parseAuthor } from "@nodesecure/utils"; -import { ManifestManager } from "@nodesecure/mama"; +import { ManifestManager, parseNpmSpec } from "@nodesecure/mama"; import type { ManifestVersion, PackageJSON, WorkspacesPackageJSON } from "@nodesecure/npm-types"; +import { getNpmRegistryURL } from "@nodesecure/npm-registry-sdk"; // Import Internal Dependencies import { @@ -28,6 +29,7 @@ import type { Dependency, DependencyVersion, GlobalWarning, + DependencyConfusionWarning, Options, Payload } from "./types.js"; @@ -96,6 +98,8 @@ export async function depWalker( await using tempDir = await TempDirectory.create(); + const dependencyConfusionWarnings: DependencyConfusionWarning[] = []; + const payload: Partial = { id: tempDir.id, rootDependencyName: manifest.name ?? "workspace", @@ -141,10 +145,13 @@ export async function depWalker( }; let proceedDependencyScan = true; + const org = parseNpmSpec(name)?.org; if (dependencies.has(name)) { const dep = dependencies.get(name)!; operationsQueue.push( - new NpmRegistryProvider(name, version).enrichDependencyVersion(dep) + new NpmRegistryProvider(name, version, { + registry + }).enrichDependencyVersion(dep, dependencyConfusionWarnings, org) ); if (version in dep.versions) { @@ -176,6 +183,13 @@ export async function depWalker( const provider = new NpmRegistryProvider(name, version); operationsQueue.push(provider.enrichDependency(logger, dependency)); + if (registry !== getNpmRegistryURL() && org) { + operationsQueue.push( + new NpmRegistryProvider(name, version, { + registry + }).enrichScopedDependencyConfusionWarnings(dependencyConfusionWarnings, org) + ); + } } const scanDirOptions = { @@ -277,7 +291,7 @@ export async function depWalker( dependencies, options.highlight?.contacts ); - payload.warnings = globalWarnings.concat(warnings); + payload.warnings = globalWarnings.concat(dependencyConfusionWarnings as GlobalWarning[]).concat(warnings); payload.highlighted = { contacts: illuminated }; diff --git a/workspaces/scanner/src/i18n/english.js b/workspaces/scanner/src/i18n/english.js index 7bd0e629..c9cf12fc 100644 --- a/workspaces/scanner/src/i18n/english.js +++ b/workspaces/scanner/src/i18n/english.js @@ -4,7 +4,10 @@ import { taggedString as tS } from "@nodesecure/i18n"; const scanner = { disable_scarf: "This dependency could collect data against your consent so think to disable it with the env var: SCARF_ANALYTICS", keylogging: "This dependency can retrieve your keyboard and mouse inputs. It can be used for 'keylogging' attacks/malwares.", - typo_squatting: tS`The package '${0}' is similar to the following popular packages: ${1}` + typo_squatting: tS`The package '${0}' is similar to the following popular packages: ${1}`, + dependency_confusion: "This dependency was found on both a public and private registry but its signature does not match", + dependency_confusion_missing: "This dependency was found on the private but not on the public registry, this dependency is vulnerable to dependency confusion attacks.", + dependency_confusion_missing_org: tS`The org '${0}' is not claimed on the public registry` }; export default { scanner }; diff --git a/workspaces/scanner/src/i18n/french.js b/workspaces/scanner/src/i18n/french.js index ab39b0c1..4de25f32 100644 --- a/workspaces/scanner/src/i18n/french.js +++ b/workspaces/scanner/src/i18n/french.js @@ -4,7 +4,10 @@ import { taggedString as tS } from "@nodesecure/i18n"; const scanner = { disable_scarf: "Cette dépendance peut récolter des données contre votre volonté, pensez donc à la désactiver en fournissant la variable d'environnement SCARF_ANALYTICS", keylogging: "Cette dépendance peut obtenir vos entrées clavier ou de souris. Cette dépendance peut être utilisée en tant que 'keylogging' attacks/malwares.", - typo_squatting: tS`Le package '${0}' est similaire aux packages populaires suivants : ${1}` + typo_squatting: tS`Le package '${0}' est similaire aux packages populaires suivants : ${1}`, + dependency_confusion: "Cette dépendance a été trouvée à la fois sur un registre public et privé, mais sa signature ne correspond pas.", + dependency_confusion_missing: "Cette dépendance a été trouvée seulement sur le registre privé, cette dépendance est vulnérable à une attaque par confusion de dépendance.", + dependency_confusion_missing_org: tS`L'organisation '${0}' n'est pas revendiquée sur le registre public` }; export default { scanner }; diff --git a/workspaces/scanner/src/registry/NpmRegistryProvider.ts b/workspaces/scanner/src/registry/NpmRegistryProvider.ts index ca0d81cc..ef9c61ee 100644 --- a/workspaces/scanner/src/registry/NpmRegistryProvider.ts +++ b/workspaces/scanner/src/registry/NpmRegistryProvider.ts @@ -1,31 +1,49 @@ +// Import Node.js Dependencies +import path from "node:path"; + // Import Third-party Dependencies import semver from "semver"; import * as npmRegistrySDK from "@nodesecure/npm-registry-sdk"; import { packageJSONIntegrityHash } from "@nodesecure/mama"; -import type { Packument, PackumentVersion } from "@nodesecure/npm-types"; +import type { Packument, PackumentVersion, Signature } from "@nodesecure/npm-types"; +import { getNpmRegistryURL } from "@nodesecure/npm-registry-sdk"; +import * as i18n from "@nodesecure/i18n"; // Import Internal Dependencies import { PackumentExtractor, type DateProvider } from "./PackumentExtractor.js"; import { fetchNpmAvatars } from "./fetchNpmAvatars.js"; import type { - Dependency + Dependency, + DependencyConfusionWarning } from "../types.js"; import { Logger } from "../class/logger.class.js"; import { getLinks } from "../utils/getLinks.js"; +import { getDirNameFromUrl } from "../utils/dirname.js"; + +await i18n.extendFromSystemPath( + path.join(getDirNameFromUrl(import.meta.url), "..", "i18n") +); + +type PackumentNpmApiOptions = { + registry: string; +}; export interface NpmApiClient { - packument(name: string): Promise; - packumentVersion(name: string, version: string): Promise; + packument(name: string, options?: PackumentNpmApiOptions): Promise; + packumentVersion(name: string, version: string, options?: PackumentNpmApiOptions): Promise; + org(namespace: string): Promise; } export interface NpmRegistryProviderOptions { dateProvider?: DateProvider; npmApiClient?: NpmApiClient; + registry?: string; } export class NpmRegistryProvider { #date: DateProvider | undefined; #npmApiClient: NpmApiClient; + #registry: string; name: string; version: string; @@ -37,7 +55,8 @@ export class NpmRegistryProvider { ) { const { dateProvider = undefined, - npmApiClient = npmRegistrySDK + npmApiClient = npmRegistrySDK, + registry = npmRegistrySDK.getLocalRegistryURL() } = options; this.name = name; @@ -45,12 +64,16 @@ export class NpmRegistryProvider { this.#date = dateProvider; this.#npmApiClient = npmApiClient; + this.#registry = registry; } async collectPackageVersionData() { const packumentVersion = await this.#npmApiClient.packumentVersion( this.name, - this.version + this.version, + { + registry: this.#registry + } ); const { integrity } = packageJSONIntegrityHash(packumentVersion, { @@ -60,12 +83,15 @@ export class NpmRegistryProvider { return { links: getLinks(packumentVersion), integrity, - deprecated: packumentVersion.deprecated + deprecated: packumentVersion.deprecated, + signatures: packumentVersion.dist.signatures }; } async collectPackageData() { - const packument = await this.#npmApiClient.packument(this.name); + const packument = await this.#npmApiClient.packument(this.name, { + registry: this.#registry + }); const packumentVersion = packument.versions[this.version]; const metadata = new PackumentExtractor( @@ -104,7 +130,7 @@ export class NpmRegistryProvider { Object.assign(dependencyVersion, version); } catch { - // ignore + // ignored } finally { logger.tick("registry"); @@ -112,10 +138,12 @@ export class NpmRegistryProvider { } async enrichDependencyVersion( - dependency: Dependency + dependency: Dependency, + warnings: DependencyConfusionWarning[], + org: string | null | undefined ) { try { - const { integrity, deprecated, links } = await this.collectPackageVersionData(); + const { integrity, deprecated, links, signatures } = await this.collectPackageVersionData(); Object.assign( dependency.versions[this.version], @@ -125,9 +153,58 @@ export class NpmRegistryProvider { } ); dependency.metadata.integrity[this.version] = integrity; + if (this.#registry === getNpmRegistryURL()) { + return; + } + try { + const packumentVersionFromPublicRegistry = await this.#npmApiClient.packumentVersion(this.name, this.version, { + registry: getNpmRegistryURL() + }); + if (!this.#hasSameSignatures(signatures, packumentVersionFromPublicRegistry.dist.signatures)) { + this.#addDependencyConfusionWarning(warnings, await i18n.getToken("scanner.dependency_confusion")); + } + } + catch { + const isScoped = Boolean(org); + if (!isScoped) { + this.#addDependencyConfusionWarning(warnings, await i18n.getToken("scanner.dependency_confusion_missing")); + } + } } catch { // ignore } } + + #hasSameSignatures(signatures: Signature[] | undefined, signaturesFromPublicRegistry: Signature[] | undefined) { + if (!signatures || !signaturesFromPublicRegistry) { + return false; + } + + const sortedSignaturesFromPublic = signaturesFromPublicRegistry.sort((a, b) => a.keyid.localeCompare(b.keyid)); + const sortedSignaturesFromPrivate = signatures.sort((a, b) => a.keyid.localeCompare(b.keyid)); + + return sortedSignaturesFromPrivate.length === signaturesFromPublicRegistry.length && + sortedSignaturesFromPrivate?.every((signature, index) => signature.keyid === sortedSignaturesFromPublic[index].keyid + && signature.sig === sortedSignaturesFromPublic[index].sig); + } + + async enrichScopedDependencyConfusionWarnings(warnings: DependencyConfusionWarning[], org: string) { + try { + await this.#npmApiClient.org(this.name); + } + catch { + await this.#addDependencyConfusionWarning(warnings, await i18n.getToken("scanner.dependency_confusion_missing_org", org)); + } + } + + async #addDependencyConfusionWarning(warnings: DependencyConfusionWarning[], message: string) { + warnings.push({ + type: "dependency-confusion", + message, + metadata: { + name: this.name + } + }); + } } diff --git a/workspaces/scanner/src/types.ts b/workspaces/scanner/src/types.ts index 22ffeb55..674b8626 100644 --- a/workspaces/scanner/src/types.ts +++ b/workspaces/scanner/src/types.ts @@ -157,6 +157,14 @@ export interface Dependency { export type Dependencies = Record; +export type DependencyConfusionWarning = { + type: "dependency-confusion"; + message: string; + metadata: { + name: string; + }; +}; + export type GlobalWarning = { message: string; } & ( { type: @@ -172,7 +180,8 @@ export type GlobalWarning = { message: string; } & ( similar: string[]; }; } -); + | + DependencyConfusionWarning); export interface Payload { /** Payload unique id */ diff --git a/workspaces/scanner/test/NpmRegistryProvider.spec.ts b/workspaces/scanner/test/NpmRegistryProvider.spec.ts index 6b67ed6e..7b19ab4b 100644 --- a/workspaces/scanner/test/NpmRegistryProvider.spec.ts +++ b/workspaces/scanner/test/NpmRegistryProvider.spec.ts @@ -5,17 +5,31 @@ import assert from "node:assert"; // Import Third-party Dependencies import semver from "semver"; import is from "@slimio/is"; +import * as i18n from "@nodesecure/i18n"; +import { PackumentVersion, Packument } from "@nodesecure/npm-types"; +import { getNpmRegistryURL } from "@nodesecure/npm-registry-sdk"; // Import Internal Dependencies import { Logger, type Dependency } from "../src/index.js"; import { NpmRegistryProvider } from "../src/registry/NpmRegistryProvider.js"; describe("NpmRegistryProvider", () => { - describe("enrichDependencyVersion", () => { + async function dummyThrow(): Promise { + throw new Error(); + } + const defaultNpmApiClient = { + packument: dummyThrow, + packumentVersion: dummyThrow, + org: dummyThrow + }; + + describe("enrichDependencyVersion", async() => { + const message = await i18n.getToken("scanner.dependency_confusion"); + const messageMissing = await i18n.getToken("scanner.dependency_confusion_missing"); test("should not throw error when package does not exist", async() => { const provider = new NpmRegistryProvider("foobarrxldkedeoxcjek", "1.5.0"); - await provider.enrichDependencyVersion({} as any); + await provider.enrichDependencyVersion({} as any, [], null); }); test("should enrich dependency with manifest metadata and links for valid package", async() => { @@ -29,7 +43,7 @@ describe("NpmRegistryProvider", () => { }; const provider = new NpmRegistryProvider("@slimio/is", "1.5.0"); - await provider.enrichDependencyVersion(dep as any); + await provider.enrichDependencyVersion(dep as any, [], "slimio"); assert.equal(Object.keys(dep.metadata).length, 1); assert.deepEqual(dep.metadata, { @@ -46,6 +60,480 @@ describe("NpmRegistryProvider", () => { } }); }); + + test("should configure the npmApiClient on the given registry", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + const provider = new NpmRegistryProvider("foobarrxldkedeoxcjek", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + + await provider.enrichDependencyVersion({} as any, [], null); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foobarrxldkedeoxcjek", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + }); + + test(`should add a warning when a dependency is found on public and private registry + but the signatures does not match`, async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, [{ + type: "dependency-confusion", + message, + metadata: { + name: "foo" + } + }]); + }); + + test("should not add a warning when the signatures are the same but out of order", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzB", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfYB" + }, + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + }, + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzB", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfYB" + } + ] + } + } as unknown as PackumentVersion)); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, []); + }); + + test("should add a warning when only the sig differ", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(warnings, [{ + type: "dependency-confusion", + message, + metadata: { + name: "foo" + } + }]); + }); + + test("should not call the public registry when the provider registry is also the public registry", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNeSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: getNpmRegistryURL(), + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + + assert.strictEqual(packumentVersionMock.mock.callCount(), 1); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, []); + }); + + test("should add a warning when private packument version has no version", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: undefined + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, [{ + type: "dependency-confusion", + message, + metadata: { + name: "foo" + } + }]); + }); + + test("should add a warning when public packument signatures has no version", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => ({ + dist: { + signatures: undefined + } + } as unknown as PackumentVersion)); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, [{ + type: "dependency-confusion", + message, + metadata: { + name: "foo" + } + }]); + }); + + test("should not add the warning when the two signatures are the same", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementation(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, []); + }); + + test("should add a warning when the dependency is not scoped and not on the public npm package", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => { + throw new Error(); + }); + + const provider = new NpmRegistryProvider("foo", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, null); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, [{ + type: "dependency-confusion", + message: messageMissing, + metadata: { + name: "foo" + } + }]); + }); + + test("should not add a warning when the dependency is a scoped and not on the public npm package", async(t) => { + const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise>(); + + packumentVersionMock.mock.mockImplementationOnce(async() => ({ + dist: { + signatures: [ + { + keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY=" + } + ] + } + } as unknown as PackumentVersion)); + + packumentVersionMock.mock.mockImplementation(async() => { + throw new Error(); + }); + + const provider = new NpmRegistryProvider("@foo/utils", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packumentVersion: packumentVersionMock + } + }); + const warnings = []; + const dep = { + metadata: { + integrity: {} + }, + versions: { + "1.5.0": {} + } + } as unknown as Dependency; + await provider.enrichDependencyVersion(dep, warnings, "foo"); + assert.strictEqual(packumentVersionMock.mock.callCount(), 2); + assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["@foo/utils", "1.5.0", { + registry: "https://registry.npmjs.org/private" + }]); + assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["@foo/utils", "1.5.0", { + registry: getNpmRegistryURL() + }]); + assert.deepEqual(warnings, []); + }); }); describe("enrichDependency", () => { @@ -53,7 +541,32 @@ describe("NpmRegistryProvider", () => { const logger = new Logger().start("registry"); const provider = new NpmRegistryProvider("foobarrxldkedeoxcjek", "1.5.0"); + const warnings = []; + await provider.enrichDependency(logger, {} as any); + assert.deepEqual(warnings, []); + }); + + test("should configure the npmApiClient on the given registry", async(t) => { + const logger = new Logger().start("registry"); + + const packumentMock = t.mock.fn<(name: string) => Promise>(); + const provider = new NpmRegistryProvider("foobarrxldkedeoxcjek", "1.5.0", { + registry: "https://registry.npmjs.org/private", + npmApiClient: { + ...defaultNpmApiClient, + packument: packumentMock + } + }); + + await provider.enrichDependency(logger, {} as any); + + assert.deepEqual(packumentMock.mock.calls[0].arguments, [ + "foobarrxldkedeoxcjek", + { + registry: "https://registry.npmjs.org/private" + } + ]); }); test("should enrich dependency with complete package metadata for valid package", async() => { @@ -136,4 +649,48 @@ describe("NpmRegistryProvider", () => { assert.strictEqual(dependency.versions["2.5.9"].deprecated, "express 2.x series is deprecated"); }); }); + + describe("enrichDependencyConfusionWarnings", async() => { + const message = "The org 'foo' is not claimed on the public registry"; + + const privateRegistry = "https://registry.npmjs.org/private"; + + test("should add a warning when the org is not found on the public npm registry", async(t) => { + const mockOrg = t.mock.fn(dummyThrow); + const provider = new NpmRegistryProvider("@foo/utils", "2.5.9", { + npmApiClient: { + ...defaultNpmApiClient, + org: mockOrg + }, + registry: privateRegistry + }); + const warnings = []; + await provider.enrichScopedDependencyConfusionWarnings(warnings, "foo"); + assert.deepEqual(warnings, [{ + type: "dependency-confusion", + message, + metadata: { + name: "@foo/utils" + } + }]); + assert.strictEqual(mockOrg.mock.callCount(), 1); + }); + test("should not not add a dependency confusion warning when the org exist on the public registry", async(t) => { + const mockOrg = t.mock.fn(async(_) => { + return {}; + }); + const provider = new NpmRegistryProvider("@foo/utils", "2.5.9", { + npmApiClient: { + ...defaultNpmApiClient, + org: mockOrg + }, + registry: privateRegistry + }); + const warnings = []; + await provider.enrichScopedDependencyConfusionWarnings(warnings, "foo"); + assert.deepEqual(warnings, []); + assert.strictEqual(mockOrg.mock.callCount(), 1); + assert.deepEqual(mockOrg.mock.calls[0].arguments, ["@foo/utils"]); + }); + }); });