Skip to content

Commit a2b006b

Browse files
committed
feat(scanner): add a dependency confusion warning when non scoped package not on the public npm registry
1 parent 3deec3f commit a2b006b

5 files changed

Lines changed: 126 additions & 18 deletions

File tree

‎workspaces/scanner/src/depWalker.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,13 @@ export async function depWalker(
145145
};
146146

147147
let proceedDependencyScan = true;
148+
const org = parseNpmSpec(name)?.org;
148149
if (dependencies.has(name)) {
149150
const dep = dependencies.get(name)!;
150151
operationsQueue.push(
151152
new NpmRegistryProvider(name, version, {
152153
registry
153-
}).enrichDependencyVersion(dep, dependencyConfusionWarnings)
154+
}).enrichDependencyVersion(dep, dependencyConfusionWarnings, org)
154155
);
155156

156157
if (version in dep.versions) {
@@ -182,7 +183,6 @@ export async function depWalker(
182183
const provider = new NpmRegistryProvider(name, version);
183184

184185
operationsQueue.push(provider.enrichDependency(logger, dependency));
185-
const org = parseNpmSpec(name)?.org;
186186
if (registry !== getNpmRegistryURL() && org) {
187187
operationsQueue.push(
188188
new NpmRegistryProvider(name, version, {

‎workspaces/scanner/src/i18n/english.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const scanner = {
66
keylogging: "This dependency can retrieve your keyboard and mouse inputs. It can be used for 'keylogging' attacks/malwares.",
77
typo_squatting: tS`The package '${0}' is similar to the following popular packages: ${1}`,
88
dependency_confusion: "This dependency was found on both a public and private registry but its signature does not match",
9+
dependency_confusion_missing: "This dependency was found on the private but not on the public registry, this dependency is vulnerable to dependency confusion attacks.",
910
dependency_confusion_missing_org: tS`The org '${0}' is not claimed on the public registry`
1011
};
1112

‎workspaces/scanner/src/i18n/french.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const scanner = {
66
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.",
77
typo_squatting: tS`Le package '${0}' est similaire aux packages populaires suivants : ${1}`,
88
dependency_confusion: "Cette dépendance a été trouvée à la fois sur un registre public et privé, mais sa signature ne correspond pas.",
9+
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.",
910
dependency_confusion_missing_org: tS`L'organisation '${0}' n'est pas revendiquée sur le registre public`
1011
};
1112

‎workspaces/scanner/src/registry/NpmRegistryProvider.ts‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ export class NpmRegistryProvider {
139139

140140
async enrichDependencyVersion(
141141
dependency: Dependency,
142-
warnings: DependencyConfusionWarning[]
142+
warnings: DependencyConfusionWarning[],
143+
org: string | null | undefined
143144
) {
144145
try {
145146
const { integrity, deprecated, links, signatures } = await this.collectPackageVersionData();
@@ -155,11 +156,19 @@ export class NpmRegistryProvider {
155156
if (this.#registry === getNpmRegistryURL()) {
156157
return;
157158
}
158-
const packumentVersionFromPublicRegistry = await this.#npmApiClient.packumentVersion(this.name, this.version, {
159-
registry: getNpmRegistryURL()
160-
});
161-
if (!this.#hasSameSignatures(signatures, packumentVersionFromPublicRegistry.dist.signatures)) {
162-
this.#addDependencyConfusionWarning(warnings, await i18n.getToken("scanner.dependency_confusion"));
159+
try {
160+
const packumentVersionFromPublicRegistry = await this.#npmApiClient.packumentVersion(this.name, this.version, {
161+
registry: getNpmRegistryURL()
162+
});
163+
if (!this.#hasSameSignatures(signatures, packumentVersionFromPublicRegistry.dist.signatures)) {
164+
this.#addDependencyConfusionWarning(warnings, await i18n.getToken("scanner.dependency_confusion"));
165+
}
166+
}
167+
catch {
168+
const isScoped = Boolean(org);
169+
if (!isScoped) {
170+
this.#addDependencyConfusionWarning(warnings, await i18n.getToken("scanner.dependency_confusion_missing"));
171+
}
163172
}
164173
}
165174
catch {

‎workspaces/scanner/test/NpmRegistryProvider.spec.ts‎

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ describe("NpmRegistryProvider", () => {
2525

2626
describe("enrichDependencyVersion", async() => {
2727
const message = await i18n.getToken("scanner.dependency_confusion");
28+
const messageMissing = await i18n.getToken("scanner.dependency_confusion_missing");
2829
test("should not throw error when package does not exist", async() => {
2930
const provider = new NpmRegistryProvider("foobarrxldkedeoxcjek", "1.5.0");
3031

31-
await provider.enrichDependencyVersion({} as any, []);
32+
await provider.enrichDependencyVersion({} as any, [], null);
3233
});
3334

3435
test("should enrich dependency with manifest metadata and links for valid package", async() => {
@@ -42,7 +43,7 @@ describe("NpmRegistryProvider", () => {
4243
};
4344
const provider = new NpmRegistryProvider("@slimio/is", "1.5.0");
4445

45-
await provider.enrichDependencyVersion(dep as any, []);
46+
await provider.enrichDependencyVersion(dep as any, [], "slimio");
4647

4748
assert.equal(Object.keys(dep.metadata).length, 1);
4849
assert.deepEqual(dep.metadata, {
@@ -70,7 +71,7 @@ describe("NpmRegistryProvider", () => {
7071
}
7172
});
7273

73-
await provider.enrichDependencyVersion({} as any, []);
74+
await provider.enrichDependencyVersion({} as any, [], null);
7475
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foobarrxldkedeoxcjek", "1.5.0", {
7576
registry: "https://registry.npmjs.org/private"
7677
}]);
@@ -118,7 +119,7 @@ describe("NpmRegistryProvider", () => {
118119
"1.5.0": {}
119120
}
120121
} as unknown as Dependency;
121-
await provider.enrichDependencyVersion(dep, warnings);
122+
await provider.enrichDependencyVersion(dep, warnings, null);
122123
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
123124
registry: "https://registry.npmjs.org/private"
124125
}]);
@@ -183,7 +184,7 @@ describe("NpmRegistryProvider", () => {
183184
"1.5.0": {}
184185
}
185186
} as unknown as Dependency;
186-
await provider.enrichDependencyVersion(dep, warnings);
187+
await provider.enrichDependencyVersion(dep, warnings, null);
187188
assert.strictEqual(packumentVersionMock.mock.callCount(), 2);
188189
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
189190
registry: "https://registry.npmjs.org/private"
@@ -235,7 +236,7 @@ describe("NpmRegistryProvider", () => {
235236
"1.5.0": {}
236237
}
237238
} as unknown as Dependency;
238-
await provider.enrichDependencyVersion(dep, warnings);
239+
await provider.enrichDependencyVersion(dep, warnings, null);
239240
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
240241
registry: "https://registry.npmjs.org/private"
241242
}]);
@@ -282,7 +283,7 @@ describe("NpmRegistryProvider", () => {
282283
"1.5.0": {}
283284
}
284285
} as unknown as Dependency;
285-
await provider.enrichDependencyVersion(dep, warnings);
286+
await provider.enrichDependencyVersion(dep, warnings, null);
286287

287288
assert.strictEqual(packumentVersionMock.mock.callCount(), 1);
288289
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
@@ -327,7 +328,7 @@ describe("NpmRegistryProvider", () => {
327328
"1.5.0": {}
328329
}
329330
} as unknown as Dependency;
330-
await provider.enrichDependencyVersion(dep, warnings);
331+
await provider.enrichDependencyVersion(dep, warnings, null);
331332
assert.strictEqual(packumentVersionMock.mock.callCount(), 2);
332333
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
333334
registry: "https://registry.npmjs.org/private"
@@ -380,7 +381,7 @@ describe("NpmRegistryProvider", () => {
380381
"1.5.0": {}
381382
}
382383
} as unknown as Dependency;
383-
await provider.enrichDependencyVersion(dep, warnings);
384+
await provider.enrichDependencyVersion(dep, warnings, null);
384385
assert.strictEqual(packumentVersionMock.mock.callCount(), 2);
385386
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
386387
registry: "https://registry.npmjs.org/private"
@@ -427,7 +428,7 @@ describe("NpmRegistryProvider", () => {
427428
"1.5.0": {}
428429
}
429430
} as unknown as Dependency;
430-
await provider.enrichDependencyVersion(dep, warnings);
431+
await provider.enrichDependencyVersion(dep, warnings, null);
431432
assert.strictEqual(packumentVersionMock.mock.callCount(), 2);
432433
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
433434
registry: "https://registry.npmjs.org/private"
@@ -437,6 +438,102 @@ describe("NpmRegistryProvider", () => {
437438
}]);
438439
assert.deepEqual(warnings, []);
439440
});
441+
442+
test("should add a warning when the dependency is not scoped and not on the public npm package", async(t) => {
443+
const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise<PackumentVersion>>();
444+
445+
packumentVersionMock.mock.mockImplementationOnce(async() => ({
446+
dist: {
447+
signatures: [
448+
{
449+
keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
450+
sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY="
451+
}
452+
]
453+
}
454+
} as unknown as PackumentVersion));
455+
456+
packumentVersionMock.mock.mockImplementation(async() => {
457+
throw new Error();
458+
});
459+
460+
const provider = new NpmRegistryProvider("foo", "1.5.0", {
461+
registry: "https://registry.npmjs.org/private",
462+
npmApiClient: {
463+
...defaultNpmApiClient,
464+
packumentVersion: packumentVersionMock
465+
}
466+
});
467+
const warnings = [];
468+
const dep = {
469+
metadata: {
470+
integrity: {}
471+
},
472+
versions: {
473+
"1.5.0": {}
474+
}
475+
} as unknown as Dependency;
476+
await provider.enrichDependencyVersion(dep, warnings, null);
477+
assert.strictEqual(packumentVersionMock.mock.callCount(), 2);
478+
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["foo", "1.5.0", {
479+
registry: "https://registry.npmjs.org/private"
480+
}]);
481+
assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["foo", "1.5.0", {
482+
registry: getNpmRegistryURL()
483+
}]);
484+
assert.deepEqual(warnings, [{
485+
type: "dependency-confusion",
486+
message: messageMissing,
487+
metadata: {
488+
name: "foo"
489+
}
490+
}]);
491+
});
492+
493+
test("should not add a warning when the dependency is a scoped and not on the public npm package", async(t) => {
494+
const packumentVersionMock = t.mock.fn<(name: string, version: string) => Promise<PackumentVersion>>();
495+
496+
packumentVersionMock.mock.mockImplementationOnce(async() => ({
497+
dist: {
498+
signatures: [
499+
{
500+
keyid: "SHA256:kl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
501+
sig: "MEUCIQCX/49atNLSDYZP8betYWEqB0G8zZnIyB7ibC7nRNyMiQIgHosOKHhVTVNBI/6iUNSpDokOc44zsZ7TfybMKj8YdfY="
502+
}
503+
]
504+
}
505+
} as unknown as PackumentVersion));
506+
507+
packumentVersionMock.mock.mockImplementation(async() => {
508+
throw new Error();
509+
});
510+
511+
const provider = new NpmRegistryProvider("@foo/utils", "1.5.0", {
512+
registry: "https://registry.npmjs.org/private",
513+
npmApiClient: {
514+
...defaultNpmApiClient,
515+
packumentVersion: packumentVersionMock
516+
}
517+
});
518+
const warnings = [];
519+
const dep = {
520+
metadata: {
521+
integrity: {}
522+
},
523+
versions: {
524+
"1.5.0": {}
525+
}
526+
} as unknown as Dependency;
527+
await provider.enrichDependencyVersion(dep, warnings, "foo");
528+
assert.strictEqual(packumentVersionMock.mock.callCount(), 2);
529+
assert.deepEqual(packumentVersionMock.mock.calls[0].arguments, ["@foo/utils", "1.5.0", {
530+
registry: "https://registry.npmjs.org/private"
531+
}]);
532+
assert.deepEqual(packumentVersionMock.mock.calls[1].arguments, ["@foo/utils", "1.5.0", {
533+
registry: getNpmRegistryURL()
534+
}]);
535+
assert.deepEqual(warnings, []);
536+
});
440537
});
441538

442539
describe("enrichDependency", () => {

0 commit comments

Comments
 (0)