mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 05:41:24 +00:00
fix(security): harden npm plugin and hook install integrity flow
This commit is contained in:
@@ -85,7 +85,52 @@ describe("resolveArchiveSourcePath", () => {
|
||||
});
|
||||
|
||||
describe("packNpmSpecToArchive", () => {
|
||||
it("packs spec and returns archive path using the final non-empty stdout line", async () => {
|
||||
it("packs spec and returns archive path using JSON output metadata", async () => {
|
||||
const cwd = await createTempDir("openclaw-install-source-utils-");
|
||||
runCommandWithTimeoutMock.mockResolvedValue({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
id: "openclaw-plugin@1.2.3",
|
||||
name: "openclaw-plugin",
|
||||
version: "1.2.3",
|
||||
filename: "openclaw-plugin-1.2.3.tgz",
|
||||
integrity: "sha512-test-integrity",
|
||||
shasum: "abc123",
|
||||
},
|
||||
]),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
const result = await packNpmSpecToArchive({
|
||||
spec: "openclaw-plugin@1.2.3",
|
||||
timeoutMs: 1000,
|
||||
cwd,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
|
||||
metadata: {
|
||||
name: "openclaw-plugin",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "openclaw-plugin@1.2.3",
|
||||
integrity: "sha512-test-integrity",
|
||||
shasum: "abc123",
|
||||
},
|
||||
});
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
|
||||
["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts", "--json"],
|
||||
expect.objectContaining({
|
||||
cwd,
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to parsing final stdout line when npm json output is unavailable", async () => {
|
||||
const cwd = await createTempDir("openclaw-install-source-utils-");
|
||||
runCommandWithTimeoutMock.mockResolvedValue({
|
||||
stdout: "npm notice created package\nopenclaw-plugin-1.2.3.tgz\n",
|
||||
@@ -104,14 +149,8 @@ describe("packNpmSpecToArchive", () => {
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
archivePath: path.join(cwd, "openclaw-plugin-1.2.3.tgz"),
|
||||
metadata: {},
|
||||
});
|
||||
expect(runCommandWithTimeoutMock).toHaveBeenCalledWith(
|
||||
["npm", "pack", "openclaw-plugin@1.2.3", "--ignore-scripts"],
|
||||
expect.objectContaining({
|
||||
cwd,
|
||||
timeoutMs: 300_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns npm pack error details when command fails", async () => {
|
||||
|
||||
@@ -5,6 +5,20 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { fileExists, resolveArchiveKind } from "./archive.js";
|
||||
|
||||
export type NpmSpecResolution = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
resolvedSpec?: string;
|
||||
integrity?: string;
|
||||
shasum?: string;
|
||||
resolvedAt?: string;
|
||||
};
|
||||
|
||||
export type NpmIntegrityDrift = {
|
||||
expectedIntegrity: string;
|
||||
actualIntegrity: string;
|
||||
};
|
||||
|
||||
export async function withTempDir<T>(
|
||||
prefix: string,
|
||||
fn: (tmpDir: string) => Promise<T>,
|
||||
@@ -39,6 +53,97 @@ export async function resolveArchiveSourcePath(archivePath: string): Promise<
|
||||
return { ok: true, path: resolved };
|
||||
}
|
||||
|
||||
function toOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parseResolvedSpecFromId(id: string): string | undefined {
|
||||
const at = id.lastIndexOf("@");
|
||||
if (at <= 0 || at >= id.length - 1) {
|
||||
return undefined;
|
||||
}
|
||||
const name = id.slice(0, at).trim();
|
||||
const version = id.slice(at + 1).trim();
|
||||
if (!name || !version) {
|
||||
return undefined;
|
||||
}
|
||||
return `${name}@${version}`;
|
||||
}
|
||||
|
||||
function normalizeNpmPackEntry(
|
||||
entry: unknown,
|
||||
): { filename?: string; metadata: NpmSpecResolution } | null {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return null;
|
||||
}
|
||||
const rec = entry as Record<string, unknown>;
|
||||
const name = toOptionalString(rec.name);
|
||||
const version = toOptionalString(rec.version);
|
||||
const id = toOptionalString(rec.id);
|
||||
const resolvedSpec =
|
||||
(name && version ? `${name}@${version}` : undefined) ??
|
||||
(id ? parseResolvedSpecFromId(id) : undefined);
|
||||
|
||||
return {
|
||||
filename: toOptionalString(rec.filename),
|
||||
metadata: {
|
||||
name,
|
||||
version,
|
||||
resolvedSpec,
|
||||
integrity: toOptionalString(rec.integrity),
|
||||
shasum: toOptionalString(rec.shasum),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseNpmPackJsonOutput(
|
||||
raw: string,
|
||||
): { filename?: string; metadata: NpmSpecResolution } | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [trimmed];
|
||||
const arrayStart = trimmed.indexOf("[");
|
||||
if (arrayStart > 0) {
|
||||
candidates.push(trimmed.slice(arrayStart));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
||||
let fallback: { filename?: string; metadata: NpmSpecResolution } | null = null;
|
||||
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
||||
const normalized = normalizeNpmPackEntry(entries[i]);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (!fallback) {
|
||||
fallback = normalized;
|
||||
}
|
||||
if (normalized.filename) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function packNpmSpecToArchive(params: {
|
||||
spec: string;
|
||||
timeoutMs: number;
|
||||
@@ -47,32 +152,44 @@ export async function packNpmSpecToArchive(params: {
|
||||
| {
|
||||
ok: true;
|
||||
archivePath: string;
|
||||
metadata: NpmSpecResolution;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const res = await runCommandWithTimeout(["npm", "pack", params.spec, "--ignore-scripts"], {
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
cwd: params.cwd,
|
||||
env: {
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
||||
const res = await runCommandWithTimeout(
|
||||
["npm", "pack", params.spec, "--ignore-scripts", "--json"],
|
||||
{
|
||||
timeoutMs: Math.max(params.timeoutMs, 300_000),
|
||||
cwd: params.cwd,
|
||||
env: {
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
||||
NPM_CONFIG_IGNORE_SCRIPTS: "true",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
if (res.code !== 0) {
|
||||
return { ok: false, error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}` };
|
||||
}
|
||||
|
||||
const packed = (res.stdout || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
const parsedJson = parseNpmPackJsonOutput(res.stdout || "");
|
||||
|
||||
const packed =
|
||||
parsedJson?.filename ??
|
||||
(res.stdout || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (!packed) {
|
||||
return { ok: false, error: "npm pack produced no archive" };
|
||||
}
|
||||
|
||||
return { ok: true, archivePath: path.join(params.cwd, packed) };
|
||||
return {
|
||||
ok: true,
|
||||
archivePath: path.join(params.cwd, packed),
|
||||
metadata: parsedJson?.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user