From 1dcee0794f39425bfc4c5d581f86bd460be2fdc7 Mon Sep 17 00:00:00 2001 From: cgombauld Date: Sat, 3 Jan 2026 22:49:23 +0100 Subject: [PATCH] feat(tarball): add warning when hostname resolve to a private ip --- .changeset/sunny-knives-create.md | 8 + workspaces/rc/package.json | 2 +- workspaces/scanner/package.json | 2 +- workspaces/tarball/package.json | 3 +- .../tarball/src/class/DnsResolver.class.ts | 34 +++++ .../tarball/src/class/NpmTarball.class.ts | 47 +++++- workspaces/tarball/test/NpmTarball.spec.ts | 141 ++++++++++++++++++ .../npmTarball/shady-link/invalid-ip.js | 1 + .../npmTarball/shady-link/package.json | 6 + .../shady-link/private-ip-1/index.js | 1 + .../shady-link/private-ip-2/index.js | 3 + .../npmTarball/shady-link/public-ip.js | 1 + workspaces/tree-walker/package.json | 2 +- 13 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 .changeset/sunny-knives-create.md create mode 100644 workspaces/tarball/src/class/DnsResolver.class.ts create mode 100644 workspaces/tarball/test/NpmTarball.spec.ts create mode 100644 workspaces/tarball/test/fixtures/npmTarball/shady-link/invalid-ip.js create mode 100644 workspaces/tarball/test/fixtures/npmTarball/shady-link/package.json create mode 100644 workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-1/index.js create mode 100644 workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-2/index.js create mode 100644 workspaces/tarball/test/fixtures/npmTarball/shady-link/public-ip.js diff --git a/.changeset/sunny-knives-create.md b/.changeset/sunny-knives-create.md new file mode 100644 index 00000000..99b30d5a --- /dev/null +++ b/.changeset/sunny-knives-create.md @@ -0,0 +1,8 @@ +--- +"@nodesecure/tree-walker": minor +"@nodesecure/scanner": minor +"@nodesecure/tarball": minor +"@nodesecure/rc": minor +--- + +feat(tarball): add warning when hostname resolve to a private ip diff --git a/workspaces/rc/package.json b/workspaces/rc/package.json index a5d71bb5..03572351 100644 --- a/workspaces/rc/package.json +++ b/workspaces/rc/package.json @@ -45,7 +45,7 @@ "ajv": "6.12.6" }, "dependencies": { - "@nodesecure/js-x-ray": "11.0.1", + "@nodesecure/js-x-ray": "11.2.0", "@nodesecure/npm-types": "^1.2.0", "@nodesecure/vulnera": "^2.0.1", "@openally/config": "^1.0.1", diff --git a/workspaces/scanner/package.json b/workspaces/scanner/package.json index a3b2e4f2..d92d0490 100644 --- a/workspaces/scanner/package.json +++ b/workspaces/scanner/package.json @@ -68,7 +68,7 @@ "@nodesecure/contact": "^3.0.0", "@nodesecure/flags": "^3.0.3", "@nodesecure/i18n": "^4.0.2", - "@nodesecure/js-x-ray": "11.1.0", + "@nodesecure/js-x-ray": "11.2.0", "@nodesecure/mama": "^2.1.0", "@nodesecure/npm-registry-sdk": "^4.4.0", "@nodesecure/npm-types": "^1.3.0", diff --git a/workspaces/tarball/package.json b/workspaces/tarball/package.json index 42e56d83..6708c941 100644 --- a/workspaces/tarball/package.json +++ b/workspaces/tarball/package.json @@ -47,10 +47,11 @@ "dependencies": { "@nodesecure/conformance": "^1.2.1", "@nodesecure/fs-walk": "^2.0.0", - "@nodesecure/js-x-ray": "11.1.0", + "@nodesecure/js-x-ray": "11.2.0", "@nodesecure/mama": "^2.1.0", "@nodesecure/npm-types": "^1.2.0", "@nodesecure/utils": "^2.3.0", + "ipaddr.js": "2.3.0", "pacote": "^21.0.0" }, "devDependencies": { diff --git a/workspaces/tarball/src/class/DnsResolver.class.ts b/workspaces/tarball/src/class/DnsResolver.class.ts new file mode 100644 index 00000000..f97bd1b8 --- /dev/null +++ b/workspaces/tarball/src/class/DnsResolver.class.ts @@ -0,0 +1,34 @@ +// Import Node.js Dependencies +import { lookup } from "node:dns/promises"; +import { type LookupAddress } from "node:dns"; + +// Import Third-party Dependencies +import ipaddress from "ipaddr.js"; + +export interface Resolver { + isPrivateHost(hostname: string): Promise; +} + +export class DnsResolver implements Resolver { + async isPrivateHost(hostname: string) { + const ipAddressListDetails: LookupAddress[] = await lookup(hostname, { all: true }); + const ipAddressList = ipAddressListDetails.map((ipAddressDetails) => ipAddressDetails.address); + + return ipAddressList.some(this.#isPrivateIPAddress); + } + + #isPrivateIPAddress(ipAddress: string): boolean { + let ip = ipaddress.parse(ipAddress); + + if (ip instanceof ipaddress.IPv6 && ip.isIPv4MappedAddress()) { + ip = ip.toIPv4Address(); + } + + const range = ip.range(); + if (range !== "unicast") { + return true; + } + + return false; + } +} diff --git a/workspaces/tarball/src/class/NpmTarball.class.ts b/workspaces/tarball/src/class/NpmTarball.class.ts index 40d70de9..3e00fd8b 100644 --- a/workspaces/tarball/src/class/NpmTarball.class.ts +++ b/workspaces/tarball/src/class/NpmTarball.class.ts @@ -9,6 +9,8 @@ import { } from "@nodesecure/mama"; import { AstAnalyser, + CollectableSet, + warnings, type AstAnalyserOptions } from "@nodesecure/js-x-ray"; @@ -21,6 +23,7 @@ import { getTarballComposition, type TarballComposition } from "../utils/index.ts"; +import { type Resolver, DnsResolver } from "./DnsResolver.class.ts"; export interface ScannedFilesResult { composition: TarballComposition; @@ -28,6 +31,10 @@ export interface ScannedFilesResult { code: SourceCodeReport; } +export type NpmTarballOptions = { + resolver?: Resolver; +}; + export class NpmTarball { static JS_EXTENSIONS = new Set([ ".js", ".mjs", ".cjs", @@ -36,15 +43,18 @@ export class NpmTarball { ]); manifest: LocatedManifestManager; + #resolver: Resolver; constructor( - mama: ManifestManager + mama: ManifestManager, + options: NpmTarballOptions = {} ) { if (!ManifestManager.isLocated(mama)) { throw new Error("ManifestManager must have a location"); } this.manifest = mama; + this.#resolver = options?.resolver ?? new DnsResolver(); } async scanFiles( @@ -64,7 +74,11 @@ export class NpmTarball { code = new SourceCodeReport(); } else { - const astAnalyser = new AstAnalyser(astAnalyserOptions); + const options = this.#optionsWithHostnameSet(astAnalyserOptions ?? {}); + + const hostNameSet = options?.collectables?.find((collectable) => collectable.type === "hostname")!; + + const astAnalyser = new AstAnalyser(options); code = await new SourceCodeScanner(this.manifest, { astAnalyser }).iterate({ manifest: [...this.manifest.getEntryFiles()] @@ -72,6 +86,26 @@ export class NpmTarball { javascript: composition.files .flatMap(filterJavaScriptFiles()) }); + + const operationQueue = + Array.from(hostNameSet) + .map(({ value, locations }) => this.#resolver.isPrivateHost(value) + .then((isPrivate) => { + if (isPrivate) { + locations.forEach(({ file, location }) => { + code.warnings.push({ + kind: "shady-link", + ...warnings["shady-link"], + file: file ?? undefined, + location, + value, + source: "Scanner" + }); + }); + } + }) + ); + await Promise.allSettled(operationQueue); } return { @@ -80,6 +114,15 @@ export class NpmTarball { code }; } + + #optionsWithHostnameSet(options: AstAnalyserOptions): AstAnalyserOptions { + const hasHostnameSet = options?.collectables?.some((collectable) => collectable.type === "hostname"); + if (hasHostnameSet) { + return options; + } + + return { ...options, collectables: [...options.collectables ?? [], new CollectableSet("hostname")] }; + } } function filterJavaScriptFiles() { diff --git a/workspaces/tarball/test/NpmTarball.spec.ts b/workspaces/tarball/test/NpmTarball.spec.ts new file mode 100644 index 00000000..57bcd6aa --- /dev/null +++ b/workspaces/tarball/test/NpmTarball.spec.ts @@ -0,0 +1,141 @@ +// Import Node.js Dependencies +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, test } from "node:test"; +import assert from "node:assert"; + +// Import Third-party Dependencies +import { CollectableSet, warnings, type Warning } from "@nodesecure/js-x-ray"; +import { ManifestManager } from "@nodesecure/mama"; + +type SourceArrayLocation = [[number, number], [number, number]]; + +// Import Internal Dependencies +import { NpmTarball } from "../src/class/NpmTarball.class.ts"; + +// CONSTANTS +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const kFixturePath = path.join(__dirname, "fixtures", "npmTarball"); +const kShadyLinkPath = path.join(kFixturePath, "shady-link"); + +describe("NpmTarball", () => { + test("it should have a shady-link warning when a hostname resolve a private ip address with collectables", async() => { + const mama = await ManifestManager.fromPackageJSON(path.join(kFixturePath, "shady-link", "package.json")); + const npmTarball = new NpmTarball(mama); + const hostnameSet = new CollectableSet("hostname"); + + const result = await npmTarball.scanFiles({ + collectables: [hostnameSet] + }); + + assert.deepEqual( + result.code.warnings.sort(compareWarning), + [{ + ...warnings["shady-link"], + kind: "shady-link", + location: [[[1, 18], [1, 50]]] as SourceArrayLocation[], + source: "Scanner", + value: "10.0.0.1.sslip.io", + file: `${kShadyLinkPath}/private-ip-1` + }, + { + ...warnings["shady-link"], + kind: "shady-link", + location: [[[3, 19], [3, 51]]] as SourceArrayLocation[], + source: "Scanner", + value: "10.0.0.1.sslip.io", + file: `${kShadyLinkPath}/private-ip-2` + }, + { + ...warnings["shady-link"], + kind: "shady-link", + location: [[[1, 18], [1, 50]]] as SourceArrayLocation[], + source: "Scanner", + file: `${kShadyLinkPath}/private-ip-2`, + value: "192-168-1-250.sslip.io" + }].sort(compareWarning) + ); + }); + + test("it should have a shady-link warning when a hostname resolve a private ip address without options", async() => { + const mama = await ManifestManager.fromPackageJSON(path.join(kFixturePath, "shady-link", "package.json")); + const npmTarball = new NpmTarball(mama); + + const result = await npmTarball.scanFiles(); + + assert.deepEqual( + result.code.warnings.sort(compareWarning), + [{ + ...warnings["shady-link"], + kind: "shady-link", + location: [[[1, 18], [1, 50]]] as SourceArrayLocation[], + source: "Scanner", + value: "10.0.0.1.sslip.io", + file: `${kShadyLinkPath}/private-ip-1` + }, + { + ...warnings["shady-link"], + kind: "shady-link", + location: [[[3, 19], [3, 51]]] as SourceArrayLocation[], + source: "Scanner", + value: "10.0.0.1.sslip.io", + file: `${kShadyLinkPath}/private-ip-2` + }, + { + ...warnings["shady-link"], + kind: "shady-link", + location: [[[1, 18], [1, 50]]] as SourceArrayLocation[], + source: "Scanner", + file: `${kShadyLinkPath}/private-ip-2`, + value: "192-168-1-250.sslip.io" + }].sort(compareWarning) + ); + }); + + test("it should have a shady-link warning when a hostname resolve a private ip address without hostname set", async() => { + const mama = await ManifestManager.fromPackageJSON(path.join(kFixturePath, "shady-link", "package.json")); + const npmTarball = new NpmTarball(mama); + + const result = await npmTarball.scanFiles({ + collectables: [new CollectableSet("url"), new CollectableSet("ip")] + }); + + assert.deepEqual( + result.code.warnings.sort(compareWarning), + [{ + ...warnings["shady-link"], + kind: "shady-link", + location: [[[1, 18], [1, 50]]] as SourceArrayLocation[], + source: "Scanner", + value: "10.0.0.1.sslip.io", + file: `${kShadyLinkPath}/private-ip-1` + }, + { + ...warnings["shady-link"], + kind: "shady-link", + location: [[[3, 19], [3, 51]]] as SourceArrayLocation[], + source: "Scanner", + value: "10.0.0.1.sslip.io", + file: `${kShadyLinkPath}/private-ip-2` + }, + { + ...warnings["shady-link"], + kind: "shady-link", + location: [[[1, 18], [1, 50]]] as SourceArrayLocation[], + source: "Scanner", + file: `${kShadyLinkPath}/private-ip-2`, + value: "192-168-1-250.sslip.io" + }].sort(compareWarning) + ); + }); +}); + +function compareWarning(a: Warning, b: Warning): number { + const fileComparison = a.file?.localeCompare(b.file ?? ""); + + if (fileComparison) { + return fileComparison; + } + + return (a.value ?? "")?.localeCompare(b.value ?? ""); +} diff --git a/workspaces/tarball/test/fixtures/npmTarball/shady-link/invalid-ip.js b/workspaces/tarball/test/fixtures/npmTarball/shady-link/invalid-ip.js new file mode 100644 index 00000000..2c6e6016 --- /dev/null +++ b/workspaces/tarball/test/fixtures/npmTarball/shady-link/invalid-ip.js @@ -0,0 +1 @@ +const invalidIp = "https://example.invalid"; diff --git a/workspaces/tarball/test/fixtures/npmTarball/shady-link/package.json b/workspaces/tarball/test/fixtures/npmTarball/shady-link/package.json new file mode 100644 index 00000000..d2774d46 --- /dev/null +++ b/workspaces/tarball/test/fixtures/npmTarball/shady-link/package.json @@ -0,0 +1,6 @@ +{ + "version": "0.1.0", + "name": "shady-link", + "type": "module", + "license": "MIT" +} diff --git a/workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-1/index.js b/workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-1/index.js new file mode 100644 index 00000000..9fbb867b --- /dev/null +++ b/workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-1/index.js @@ -0,0 +1 @@ +const privateIp = "https://10.0.0.1.sslip.io/path"; diff --git a/workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-2/index.js b/workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-2/index.js new file mode 100644 index 00000000..93f3a2f8 --- /dev/null +++ b/workspaces/tarball/test/fixtures/npmTarball/shady-link/private-ip-2/index.js @@ -0,0 +1,3 @@ +const privateIp = "https://192-168-1-250.sslip.io"; + +const privateIp2 = "https://10.0.0.1.sslip.io/path"; diff --git a/workspaces/tarball/test/fixtures/npmTarball/shady-link/public-ip.js b/workspaces/tarball/test/fixtures/npmTarball/shady-link/public-ip.js new file mode 100644 index 00000000..5d344603 --- /dev/null +++ b/workspaces/tarball/test/fixtures/npmTarball/shady-link/public-ip.js @@ -0,0 +1 @@ +const publicIp = "https://example.com"; diff --git a/workspaces/tree-walker/package.json b/workspaces/tree-walker/package.json index 0f4c7434..cc98632e 100644 --- a/workspaces/tree-walker/package.json +++ b/workspaces/tree-walker/package.json @@ -37,7 +37,7 @@ }, "homepage": "https://github.com/NodeSecure/tree/master/workspaces/tree-walker#readme", "dependencies": { - "@nodesecure/js-x-ray": "11.0.1", + "@nodesecure/js-x-ray": "11.2.0", "@nodesecure/npm-registry-sdk": "^4.0.0", "@nodesecure/npm-types": "^1.1.0", "@npmcli/arborist": "9.1.9",