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/fair-flies-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/scanner": minor
---

feat(scanner): implement dependency confusion detection
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion workspaces/scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 17 additions & 3 deletions workspaces/scanner/src/depWalker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,6 +29,7 @@ import type {
Dependency,
DependencyVersion,
GlobalWarning,
DependencyConfusionWarning,
Options,
Payload
} from "./types.js";
Expand Down Expand Up @@ -96,6 +98,8 @@ export async function depWalker(

await using tempDir = await TempDirectory.create();

const dependencyConfusionWarnings: DependencyConfusionWarning[] = [];

const payload: Partial<Payload> = {
id: tempDir.id,
rootDependencyName: manifest.name ?? "workspace",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
};
Expand Down
5 changes: 4 additions & 1 deletion workspaces/scanner/src/i18n/english.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
5 changes: 4 additions & 1 deletion workspaces/scanner/src/i18n/french.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
99 changes: 88 additions & 11 deletions workspaces/scanner/src/registry/NpmRegistryProvider.ts
Original file line number Diff line number Diff line change
@@ -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<Packument>;
packumentVersion(name: string, version: string): Promise<PackumentVersion>;
packument(name: string, options?: PackumentNpmApiOptions): Promise<Packument>;
packumentVersion(name: string, version: string, options?: PackumentNpmApiOptions): Promise<PackumentVersion>;
org(namespace: string): Promise<npmRegistrySDK.NpmPackageOrg>;
}

export interface NpmRegistryProviderOptions {
dateProvider?: DateProvider;
npmApiClient?: NpmApiClient;
registry?: string;
}

export class NpmRegistryProvider {
#date: DateProvider | undefined;
#npmApiClient: NpmApiClient;
#registry: string;

name: string;
version: string;
Expand All @@ -37,20 +55,25 @@ export class NpmRegistryProvider {
) {
const {
dateProvider = undefined,
npmApiClient = npmRegistrySDK
npmApiClient = npmRegistrySDK,
registry = npmRegistrySDK.getLocalRegistryURL()
} = options;

this.name = name;
this.version = version;

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, {
Expand All @@ -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(
Expand Down Expand Up @@ -104,18 +130,20 @@ export class NpmRegistryProvider {
Object.assign(dependencyVersion, version);
}
catch {
// ignore
// ignored
}
finally {
logger.tick("registry");
}
}

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],
Expand All @@ -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
}
});
}
}
11 changes: 10 additions & 1 deletion workspaces/scanner/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ export interface Dependency {

export type Dependencies = Record<string, Dependency>;

export type DependencyConfusionWarning = {
type: "dependency-confusion";
message: string;
metadata: {
name: string;
};
};

export type GlobalWarning = { message: string; } & (
{
type:
Expand All @@ -172,7 +180,8 @@ export type GlobalWarning = { message: string; } & (
similar: string[];
};
}
);
|
DependencyConfusionWarning);

export interface Payload {
/** Payload unique id */
Expand Down
Loading