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
24 changes: 21 additions & 3 deletions src/core/npm-package-doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface BuildDoctorNpmPackageReportOptions {
environment?: CompatibilityEnvironment;
}

interface NpmPackEntry {
export interface NpmPackEntry {
filename?: string;
name?: string;
version?: string;
Expand All @@ -66,6 +66,24 @@ interface NpmPackEntry {
unpackedSize?: number;
}

export function parseNpmPackOutput(stdout: string): NpmPackEntry[] {
const parsed = JSON.parse(stdout) as unknown;

if (Array.isArray(parsed)) {
return parsed.filter(
(entry): entry is NpmPackEntry => typeof entry === "object" && entry !== null
);
}

if (typeof parsed === "object" && parsed !== null) {
return Object.values(parsed).filter(
(entry): entry is NpmPackEntry => typeof entry === "object" && entry !== null
);
}

return [];
}

interface CommandSpec {
command: string;
args: string[];
Expand Down Expand Up @@ -208,8 +226,8 @@ async function packNpmPackage(packageSpec: string, destinationPath: string): Pro
maxBuffer: 10 * 1024 * 1024
}
);
const packEntries = JSON.parse(stdout) as NpmPackEntry[];
const metadata = packEntries[0];
const packEntries = parseNpmPackOutput(stdout);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Tarball path unvalidated 🐞 Bug ☼ Reliability

packNpmPackage() now selects the first parsed record with any string filename and builds
tarballPath via path.join() without validating that it is a .tgz under destinationPath. That
path is then passed into extractTarGz() for gunzip+tar extraction, so unexpected output (e.g.,
../something or a non-tarball filename) can cause confusing failures or extraction attempts
outside the intended workspace.
Agent Prompt
### Issue description
`packNpmPackage()` accepts any string `metadata.filename` and turns it into `tarballPath` without verifying that it is a tarball path located within `destinationPath`. That path is then immediately gunzipped/extracted.

### Issue Context
`npm pack --json` output is treated as trusted, but this code is parsing external command output. Defensive validation should ensure we only attempt extraction on a real tarball created in the requested destination directory.

### Fix Focus Areas
- src/core/npm-package-doctor.ts[207-242]

### Recommended fix
1. Tighten tarball selection:
   - Prefer entries where `filename` is a string and ends with `.tgz`.
2. Resolve and validate the tarball path:
   - Compute `const candidatePath = path.isAbsolute(filename) ? filename : path.resolve(destinationPath, filename)`.
   - Reject if `!isPathWithinRoot(destinationPath, candidatePath)`.
   - Optionally `await stat(candidatePath)` and ensure it’s a file to fail early with a clearer error.
3. Use `candidatePath` as `tarballPath`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

const metadata = packEntries.find((entry) => typeof entry.filename === "string");

if (!metadata?.filename) {
throw new Error(`npm pack did not return a tarball for ${packageSpec}`);
Expand Down
20 changes: 20 additions & 0 deletions tests/npm-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";

import { runCli } from "../src/run-cli.js";
import { parseNpmPackOutput } from "../src/core/npm-package-doctor.js";

function createIo() {
const stdout: string[] = [];
Expand Down Expand Up @@ -87,6 +88,25 @@ async function createPackedPluginFixture(): Promise<string> {
}

describe("doctor npm command", () => {
it("normalizes npm pack JSON from npm 10 through npm 12", () => {
const entry = {
name: "doctor-npm-fixture",
version: "1.2.3",
filename: "doctor-npm-fixture-1.2.3.tgz"
};

expect(parseNpmPackOutput(JSON.stringify([entry]))).toEqual([entry]);
expect(
parseNpmPackOutput(JSON.stringify({ "doctor-npm-fixture": entry }))
).toEqual([entry]);
});

it("rejects non-record npm pack JSON shapes", () => {
expect(parseNpmPackOutput("null")).toEqual([]);
expect(parseNpmPackOutput('"unexpected"')).toEqual([]);
expect(parseNpmPackOutput("[null, 1]")).toEqual([]);
});

it("packs an npm package and reports preinstall plugin risk as JSON", async () => {
const packageRoot = await createPackedPluginFixture();
const { io, stdout, stderr } = createIo();
Expand Down