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
7 changes: 7 additions & 0 deletions .changeset/some-dryers-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@nodesecure/scanner": minor
"@nodesecure/tarball": minor
"@nodesecure/mama": minor
---

feat: add npx and bin confusion warning
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Scanner builds on [JS-X-Ray](https://github.com/NodeSecure/js-x-ray) (SAST) and
- Detects:
- [Manifest confusion](https://blog.vlt.sh/blog/the-massive-hole-in-the-npm-ecosystem)
- [Dependency confusion](https://www.landh.tech/blog/20250610-netflix-vulnerability-dependency-confusion/)
- [Npx and Bin confusion](https://www.landh.tech/blog/20260521-npx-used-confusion-and-its-super-effective/)
- Typosquatting of popular package names
- Install scripts (e.g. `install`, `preinstall`, `postinstall`, `preuninstall`, `postuninstall`)
- Highlights packages by name, version(s), or maintainer
Expand Down
104 changes: 104 additions & 0 deletions workspaces/mama/docs/extractNpxFromScripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# extractNpxFromScripts

Parse the binary name, flags and script name from a npx command `npx --flags binaryName` in each script if present.

## Function Signature

```ts
export type NpxCommand = {
binaryName: string;
flags: string[];
scriptName: string;
};

export function* extractNpxFromScripts(
scripts: Record<string, string> | undefined
): IterableIterator<NpxCommand>
```


## Example Usage

```ts

extractNpxFromScripts({
test: "npx --yes=false jest",
exec: "npx my-internal-tool",
start: "npm run start"
});

/*
Will yield:

{ binaryName: "jest",
flags: ["--yes=false"],
scriptName: "test"
}

Then:

{
binaryName: "my-internal-tool",
flags: [], scriptName:
"exec"
}

Nothing is extracted from the start script since there is no npx command in it.
*/

extractNpxFromScripts({
release: "npx -y -p @changesets/cli@3.0.1 -c 'changeset version'"
});

/*
Will yield:

{
binaryName: "@changesets/cli",
flags: ["-y", "-p"],
scriptName: "release"
}

Note: that the binary name is given without the version
*/

extractNpxFromScripts({
release: "npx a && npx b"
});

/*
Will yield:

{
binaryName: "a",
flags: [],
scriptName: "release"
}

Then:

{
binaryName: "b",
flags: [],
scriptName: "release"
}

*/
```


## How It Works

The function uses a regular expression:

```ts
/\bnpx\s+((?:--?\w[\w-]*(?:[=\s]\S+)?\s+)*)(\S+)/g
```

to extract for each script:

* **binaryName** → the binary name (without the version when there is one) in the npx command (e.g. `"npx jest"` → `"jest"`)
* **flags** → the flags in the npx command (e.g. `"npx -y --no jest"` → `["-y", "--no"]`)
* **scriptName** → the name of the script where there is an npx command (e.g. `{"test":"npx -y --no jest"}` → `"test"`)

If there is no script containing a npx command the function does not yield anything.
8 changes: 7 additions & 1 deletion workspaces/mama/src/ManifestManager.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import { fromData } from "ssri";
// Import Internal Dependencies
import {
packageJSONIntegrityHash,
inspectModuleType
inspectModuleType,
extractNpxFromScripts,
type NpxCommand
} from "./utils/index.ts";

type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
Expand Down Expand Up @@ -250,6 +252,10 @@ export class ManifestManager<
}
}

public* extractNpxFromScripts(): IterableIterator<NpxCommand> {
yield* extractNpxFromScripts(this.document.scripts);
}

static async fromPackageJSON(
locationOrManifest: string | ManifestManager
): Promise<ManifestManager> {
Expand Down
4 changes: 3 additions & 1 deletion workspaces/mama/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ export * from "./ManifestManager.class.ts";
export {
packageJSONIntegrityHash,
parseNpmSpec,
extractNpxFromScripts,
inspectModuleType,
scanLockFiles,
LOCK_FILES,
type PackageModuleType
type PackageModuleType,
type NpxCommand
} from "./utils/index.ts";
38 changes: 38 additions & 0 deletions workspaces/mama/src/utils/extractNpxFromScripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Import Internal Dependencies
import { parseNpmSpec } from "./parseNpmSpec.ts";

export type NpxCommand = {
binaryName: string;
flags: string[];
scriptName: string;
};

export function* extractNpxFromScripts(
scripts: Record<string, string> | undefined
): IterableIterator<NpxCommand> {
if (!scripts) {
return;
}

for (const [scriptName, scriptValue] of Object.entries(scripts)) {
for (const npx of extractNpx(scriptValue, scriptName)) {
yield npx;
}
}
}

function* extractNpx(command: string, scriptName: string): IterableIterator<NpxCommand> {
const npxPattern = /\bnpx\s+((?:--?\w[\w-]*(?:[=\s]\S+)?\s+)*)(\S+)/g;
let match: RegExpExecArray | null;
while ((match = npxPattern.exec(command)) !== null) {
const flags = match[1].split(" ").filter(Boolean);

const npmSpec = parseNpmSpec(match[2]);

yield {
binaryName: npmSpec?.name!,
flags,
scriptName
};
}
}
1 change: 1 addition & 0 deletions workspaces/mama/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./integrity-hash.ts";
export * from "./extractNpxFromScripts.ts";
export * from "./inspectModuleType.ts";
export * from "./parseNpmSpec.ts";
export * from "./scanLockFiles.ts";
45 changes: 45 additions & 0 deletions workspaces/mama/test/ManifestManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,4 +951,49 @@ describe("ManifestManager", () => {
);
});
});

describe("npx command extraction from scripts", () => {
it("should extract nothing when there is no scripts in the manifest", () => {
const packageJSON: PackageJSON = {
...kMinimalPackageJSON,
dependencies: {
kleur: "1.0.0"
},
devDependencies: {
mocha: "1.0.0"
},
gypfile: false
};

const mama = new ManifestManager(packageJSON);

assert.deepEqual(Array.from(mama.extractNpxFromScripts()), []);
});

it("should extract the npx command from the scritps in the manifest", () => {
const packageJSON: PackageJSON = {
scripts: {
test: "npx --no jest",
exec: "npx my-internal-tool",
start: "npm run start"
},
...kMinimalPackageJSON,
dependencies: {
kleur: "1.0.0"
},
devDependencies: {
mocha: "1.0.0"
},
gypfile: false
};

const mama = new ManifestManager(packageJSON);

assert.deepEqual(Array.from(mama.extractNpxFromScripts()),
[
{ binaryName: "jest", flags: ["--no"], scriptName: "test" },
{ binaryName: "my-internal-tool", flags: [], scriptName: "exec" }
]);
});
});
});
125 changes: 125 additions & 0 deletions workspaces/mama/test/extractNpxFromScripts.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Import Node.js Dependencies
import assert from "node:assert";
import { describe, it } from "node:test";

// Import Internal Dependencies
import { extractNpxFromScripts } from "../src/utils/index.ts";

describe("extractNpxFromScripts", () => {
describe("npx binary name extraction", () => {
it("should not extract anynthing when this is not an npx command", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ start: "npm run start" })), []);
});

it("should extract binary name for the simplest npx command", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ exec: "npx my-internal-tool" })), [
{
binaryName: "my-internal-tool",
flags: [],
scriptName: "exec"
}
]);
});

it("should extract the binary name when the npx command is not trimmed", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ exec: " npx my-internal-tool " })), [
{
binaryName: "my-internal-tool",
flags: [],
scriptName: "exec"
}
]);
});

it("should extract the binary name when the npx command is not the only command", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ test: "tsc && npx jest --coverage" })), [
{
binaryName: "jest",
flags: [],
scriptName: "test"
}
]);
});

it("should extract the flags", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ test: "npx --no jest" })), [
{
binaryName: "jest",
flags: ["--no"],
scriptName: "test"
}
]);
});

it("should be able to extract multiple flags", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ test: "npx --no --quiet jest" })), [
{
binaryName: "jest",
flags: ["--no", "--quiet"],
scriptName: "test"
}
]);

assert.deepEqual(Array.from(extractNpxFromScripts({ test: "npx --no --quiet jest" })), [
{
binaryName: "jest",
flags: ["--no", "--quiet"],
scriptName: "test"
}
]);
});

it("should not match unrelated command", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({ solve: "rnpx solve" })), []);
});

it("should extract remove the version from the binary name when there is one", () => {
assert.deepEqual(
Array.from(extractNpxFromScripts({ release: "npx -y -p @changesets/cli@3.0.1 -c 'changeset version'" })),
[
{
binaryName: "@changesets/cli",
flags: ["-y", "-p"],
scriptName: "release"
}
]
);
});

it("should be able to match multiple npx commands in one script", () => {
assert.deepEqual(
Array.from(extractNpxFromScripts({ release: "npx a && npx b" })),
[
{
binaryName: "a",
flags: [],
scriptName: "release"
},
{
binaryName: "b",
flags: [],
scriptName: "release"
}
]
);
});
});

describe("npx command extraction from scripts", () => {
it("should extract nothing when there is no scripts", () => {
assert.deepEqual(Array.from(extractNpxFromScripts(undefined)), []);
assert.deepEqual(Array.from(extractNpxFromScripts({})), []);
});

it("should extract the npx command from the scripts when there is one", () => {
assert.deepEqual(Array.from(extractNpxFromScripts({
test: "npx --yes=false jest",
exec: "npx my-internal-tool",
start: "npm run start"
})), [
{ binaryName: "jest", flags: ["--yes=false"], scriptName: "test" },
{ binaryName: "my-internal-tool", flags: [], scriptName: "exec" }
]);
});
});
});
4 changes: 2 additions & 2 deletions workspaces/scanner/src/class/TarballScanner.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ export class TarballScanner {
ref: any,
result: ScanResultPayload
): void {
const { description, engines, repository, scripts, author, integrity } = result;
Object.assign(ref, { description, engines, repository, scripts, author, integrity });
const { description, engines, repository, scripts, author, integrity, bin } = result;
Object.assign(ref, { description, engines, repository, scripts, author, integrity, bin });

ref.warnings.push(...result.warnings);
ref.licenses = result.licenses;
Expand Down
Loading