mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a918170f95 |
@@ -0,0 +1,642 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildOsvQueryBatchRequest,
|
||||
cleanDependencyScanResult,
|
||||
extractNpmDependencies,
|
||||
mergeOsvQueryBatchResponses,
|
||||
normalizeOsvQueryBatchResponse,
|
||||
splitOsvQueryBatchRequest,
|
||||
} from "./packageDependencyScan";
|
||||
|
||||
describe("packageDependencyScan", () => {
|
||||
it("extracts exact npm dependency versions from package-lock files", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
lodash: "^4.17.0",
|
||||
},
|
||||
devDependencies: {
|
||||
vitest: "4.1.9",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
lodash: "^4.17.0",
|
||||
},
|
||||
devDependencies: {
|
||||
vitest: "4.1.9",
|
||||
},
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
version: "4.17.21",
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
version: "4.1.9",
|
||||
dev: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(dependencies).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "lodash",
|
||||
dependencyKind: "dependencies",
|
||||
requestedRange: "^4.17.0",
|
||||
resolvedVersion: "4.17.21",
|
||||
}),
|
||||
]);
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "lodash", ecosystem: "npm" },
|
||||
version: "4.17.21",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts non-dev transitive npm packages from lockfiles", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
wrapper: "^1.0.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
wrapper: "^1.0.0",
|
||||
},
|
||||
},
|
||||
"node_modules/wrapper": {
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/wrapper/node_modules/plain-crypto-js": {
|
||||
version: "0.1.0",
|
||||
},
|
||||
"node_modules/wrapper/node_modules/dev-optional-malware": {
|
||||
version: "2.0.0",
|
||||
devOptional: true,
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
version: "4.1.9",
|
||||
dev: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(dependencies).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "wrapper",
|
||||
manifestPath: "package.json",
|
||||
dependencyKind: "dependencies",
|
||||
resolvedVersion: "1.0.0",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "plain-crypto-js",
|
||||
manifestPath: "package-lock.json",
|
||||
resolvedVersion: "0.1.0",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "dev-optional-malware",
|
||||
manifestPath: "package-lock.json",
|
||||
resolvedVersion: "2.0.0",
|
||||
}),
|
||||
]);
|
||||
expect(dependencies[1]).not.toHaveProperty("dependencyKind");
|
||||
expect(dependencies[2]).not.toHaveProperty("dependencyKind");
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "wrapper", ecosystem: "npm" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
package: { name: "plain-crypto-js", ecosystem: "npm" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
package: { name: "dev-optional-malware", ecosystem: "npm" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts exact bundled package versions from node_modules package manifests", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
bundledDependencies: ["plain-crypto-js"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "node_modules/plain-crypto-js/package.json",
|
||||
content: JSON.stringify({
|
||||
name: "plain-crypto-js",
|
||||
version: "0.1.0",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(dependencies).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "plain-crypto-js",
|
||||
manifestPath: "package.json",
|
||||
dependencyKind: "bundledDependencies",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "plain-crypto-js",
|
||||
resolvedPackageName: "plain-crypto-js",
|
||||
manifestPath: "node_modules/plain-crypto-js/package.json",
|
||||
resolvedVersion: "0.1.0",
|
||||
}),
|
||||
]);
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "plain-crypto-js", ecosystem: "npm" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses real package names from lockfiles for npm aliases", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
"safe-name": "npm:demo-malware@1.0.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"node_modules/safe-name": {
|
||||
name: "demo-malware",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(dependencies).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "safe-name",
|
||||
resolvedPackageName: "demo-malware",
|
||||
resolvedVersion: "1.0.0",
|
||||
}),
|
||||
]);
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "demo-malware", ecosystem: "npm" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses real package names from package-lock v1 npm alias entries", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
"safe-name": "npm:demo-malware@1.0.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 1,
|
||||
dependencies: {
|
||||
"safe-name": {
|
||||
version: "npm:demo-malware@1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(dependencies).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "safe-name",
|
||||
resolvedPackageName: "demo-malware",
|
||||
resolvedVersion: "1.0.0",
|
||||
}),
|
||||
]);
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "demo-malware", ecosystem: "npm" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts transitive packages from package-lock v1 dependencies", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 1,
|
||||
dependencies: {
|
||||
wrapper: {
|
||||
version: "1.0.0",
|
||||
dependencies: {
|
||||
"plain-crypto-js": {
|
||||
version: "0.1.0",
|
||||
},
|
||||
"dev-helper": {
|
||||
version: "2.0.0",
|
||||
dev: true,
|
||||
},
|
||||
"dev-optional-malware": {
|
||||
version: "3.0.0",
|
||||
devOptional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "wrapper", ecosystem: "npm" },
|
||||
version: "1.0.0",
|
||||
},
|
||||
{
|
||||
package: { name: "plain-crypto-js", ecosystem: "npm" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
package: { name: "dev-optional-malware", ecosystem: "npm" },
|
||||
version: "3.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("splits and merges OSV querybatch payloads without reordering results", () => {
|
||||
const request = {
|
||||
queries: [
|
||||
{ package: { name: "one", ecosystem: "npm" as const }, version: "1.0.0" },
|
||||
{ package: { name: "two", ecosystem: "npm" as const }, version: "2.0.0" },
|
||||
{ package: { name: "three", ecosystem: "npm" as const }, version: "3.0.0" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(splitOsvQueryBatchRequest(request, 2)).toEqual([
|
||||
{ queries: request.queries.slice(0, 2) },
|
||||
{ queries: request.queries.slice(2) },
|
||||
]);
|
||||
expect(
|
||||
mergeOsvQueryBatchResponses([
|
||||
{ results: [{ vulns: [{ id: "GHSA-1", aliases: [] }] }, {}] },
|
||||
{ results: [{ vulns: [{ id: "MAL-2026-1", aliases: [] }] }] },
|
||||
]),
|
||||
).toEqual({
|
||||
results: [
|
||||
{ vulns: [{ id: "GHSA-1", aliases: [] }] },
|
||||
{},
|
||||
{ vulns: [{ id: "MAL-2026-1", aliases: [] }] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes exact manifest versions before querying OSV", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
"equals-version": "=1.2.3",
|
||||
"tagged-version": "v2.3.4",
|
||||
"alias-version": "npm:real-package@3.4.5",
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(buildOsvQueryBatchRequest(dependencies)).toEqual({
|
||||
queries: [
|
||||
{
|
||||
package: { name: "equals-version", ecosystem: "npm" },
|
||||
version: "1.2.3",
|
||||
},
|
||||
{
|
||||
package: { name: "tagged-version", ecosystem: "npm" },
|
||||
version: "2.3.4",
|
||||
},
|
||||
{
|
||||
package: { name: "real-package", ecosystem: "npm" },
|
||||
version: "3.4.5",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes OSV malicious advisories as install-blocking malicious results", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
"demo-malware": "1.0.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
vulns: [
|
||||
{
|
||||
id: "MAL-2026-1234",
|
||||
summary: "Malicious package",
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "malicious",
|
||||
findings: [
|
||||
{
|
||||
advisoryId: "MAL-2026-1234",
|
||||
classification: "malware",
|
||||
confidence: "high",
|
||||
packageName: "demo-malware",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.findings[0]).not.toHaveProperty("severity");
|
||||
expect(result.findings[0]).not.toHaveProperty("url");
|
||||
});
|
||||
|
||||
it("preserves manifest aliases on normalized OSV findings", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
"safe-name": "npm:demo-malware@1.0.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"node_modules/safe-name": {
|
||||
name: "demo-malware",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
vulns: [
|
||||
{
|
||||
id: "MAL-2026-1234",
|
||||
summary: "Malicious package",
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
packageName: "demo-malware",
|
||||
manifestName: "safe-name",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes malicious transitive lockfile advisories as blocking malware", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package-lock.json",
|
||||
content: JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"node_modules/wrapper": {
|
||||
version: "1.0.0",
|
||||
},
|
||||
"node_modules/wrapper/node_modules/plain-crypto-js": {
|
||||
version: "0.1.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: {
|
||||
results: [
|
||||
{},
|
||||
{
|
||||
vulns: [
|
||||
{
|
||||
id: "MAL-2026-4321",
|
||||
summary: "Malicious transitive package",
|
||||
aliases: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "malicious",
|
||||
findings: [
|
||||
{
|
||||
advisoryId: "MAL-2026-4321",
|
||||
classification: "malware",
|
||||
confidence: "high",
|
||||
packageName: "plain-crypto-js",
|
||||
version: "0.1.0",
|
||||
manifestPath: "package-lock.json",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.findings[0]).not.toHaveProperty("dependencyKind");
|
||||
});
|
||||
|
||||
it("normalizes ordinary OSV vulnerabilities as suspicious advisory results", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
lodash: "4.17.20",
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
vulns: [
|
||||
{
|
||||
id: "GHSA-1234-5678-9012",
|
||||
summary: "Prototype pollution",
|
||||
aliases: ["CVE-2026-1234"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "suspicious",
|
||||
findings: [
|
||||
{
|
||||
advisoryId: "GHSA-1234-5678-9012",
|
||||
classification: "vulnerability",
|
||||
confidence: "medium",
|
||||
packageName: "lodash",
|
||||
version: "4.17.20",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark range-only dependencies as clean", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
lodash: "^4.17.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(cleanDependencyScanResult({ dependencies, checkedAt: 123 })).toMatchObject({
|
||||
status: "skipped",
|
||||
dependencyCount: 1,
|
||||
scannedDependencyCount: 0,
|
||||
skippedDependencyCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark partially scanned dependency sets as clean", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
exact: "1.0.0",
|
||||
ranged: "^2.0.0",
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: {
|
||||
results: [{}],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "skipped",
|
||||
dependencyCount: 2,
|
||||
scannedDependencyCount: 1,
|
||||
skippedDependencyCount: 1,
|
||||
findings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed OSV querybatch responses", () => {
|
||||
const dependencies = extractNpmDependencies([
|
||||
{
|
||||
path: "package.json",
|
||||
content: JSON.stringify({
|
||||
dependencies: {
|
||||
lodash: "4.17.20",
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: {},
|
||||
}),
|
||||
).toThrow(/results array/);
|
||||
|
||||
expect(() =>
|
||||
normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
checkedAt: 123,
|
||||
response: { results: [] },
|
||||
}),
|
||||
).toThrow(/result count/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import semver from "semver";
|
||||
|
||||
export type PackageDependencyScanStatus =
|
||||
| "clean"
|
||||
| "suspicious"
|
||||
| "malicious"
|
||||
| "skipped"
|
||||
| "error";
|
||||
export type PackageDependencyScanProvider = "osv";
|
||||
export type PackageDependencyEcosystem = "npm";
|
||||
export type PackageDependencyKind =
|
||||
| "dependencies"
|
||||
| "optionalDependencies"
|
||||
| "peerDependencies"
|
||||
| "bundledDependencies"
|
||||
| "bundleDependencies";
|
||||
|
||||
export type PackageDependency = {
|
||||
name: string;
|
||||
resolvedPackageName: string;
|
||||
ecosystem: PackageDependencyEcosystem;
|
||||
dependencyKind?: PackageDependencyKind;
|
||||
manifestPath: string;
|
||||
requestedRange?: string;
|
||||
resolvedVersion?: string;
|
||||
};
|
||||
|
||||
export type PackageDependencyScanFinding = {
|
||||
source: PackageDependencyScanProvider;
|
||||
advisoryId: string;
|
||||
packageName: string;
|
||||
manifestName?: string;
|
||||
ecosystem: PackageDependencyEcosystem;
|
||||
version?: string;
|
||||
summary: string;
|
||||
aliases: string[];
|
||||
classification: "malware" | "vulnerability";
|
||||
confidence: "high" | "medium";
|
||||
severity?: string;
|
||||
url?: string;
|
||||
manifestPath?: string;
|
||||
dependencyKind?: PackageDependencyKind;
|
||||
};
|
||||
|
||||
export type PackageDependencyScanResult = {
|
||||
status: PackageDependencyScanStatus;
|
||||
provider: PackageDependencyScanProvider;
|
||||
scannerVersion: string;
|
||||
dependencyCount: number;
|
||||
scannedDependencyCount: number;
|
||||
skippedDependencyCount: number;
|
||||
manifests: string[];
|
||||
findings: PackageDependencyScanFinding[];
|
||||
summary: string;
|
||||
checkedAt: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type DependencyManifestFile = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type OsvVulnerability = {
|
||||
id: string;
|
||||
summary?: string;
|
||||
aliases: string[];
|
||||
severity?: string;
|
||||
url?: string;
|
||||
classification: "malware" | "vulnerability";
|
||||
};
|
||||
|
||||
const SCANNER_VERSION = "osv-npm-lockfile-v1";
|
||||
const OSV_QUERY_BATCH_LIMIT = 1_000;
|
||||
const DEPENDENCY_KINDS: PackageDependencyKind[] = [
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"bundledDependencies",
|
||||
"bundleDependencies",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizePackageName(name: string) {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isPackageJsonPath(path: string) {
|
||||
return path.toLowerCase().endsWith("package.json");
|
||||
}
|
||||
|
||||
function isNodeModulesPackageJsonPath(path: string) {
|
||||
return /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/]+\/package\.json$/i.test(path);
|
||||
}
|
||||
|
||||
function packageRootFromPath(path: string) {
|
||||
return path.split("/").slice(0, -1).join("/");
|
||||
}
|
||||
|
||||
function npmLockfilePathsForRoot(root: string) {
|
||||
const prefix = root ? `${root}/` : "";
|
||||
return [`${prefix}package-lock.json`, `${prefix}npm-shrinkwrap.json`];
|
||||
}
|
||||
|
||||
function isNpmLockfilePath(path: string) {
|
||||
return /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json)$/i.test(path);
|
||||
}
|
||||
|
||||
function dependencyEntries(value: unknown): Array<{ name: string; requestedRange?: string }> {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string" && Boolean(entry.trim()))
|
||||
.map((name) => ({ name: name.trim() }));
|
||||
}
|
||||
if (!isRecord(value)) return [];
|
||||
return Object.entries(value)
|
||||
.filter(([name]) => Boolean(name.trim()))
|
||||
.map(([name, range]) => ({
|
||||
name: name.trim(),
|
||||
requestedRange: stringValue(range),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseJson(content: string): JsonRecord | null {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function lockPackages(lockfile: JsonRecord): JsonRecord | null {
|
||||
return isRecord(lockfile.packages) ? lockfile.packages : null;
|
||||
}
|
||||
|
||||
function nodeModulesKeyForDependency(name: string) {
|
||||
return `node_modules/${name}`;
|
||||
}
|
||||
|
||||
function dependencyNameFromLockfilePackagePath(path: string) {
|
||||
const marker = "node_modules/";
|
||||
const markerIndex = path.lastIndexOf(marker);
|
||||
if (markerIndex < 0) return undefined;
|
||||
const packagePath = path.slice(markerIndex + marker.length);
|
||||
const segments = packagePath.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return undefined;
|
||||
if (segments[0]?.startsWith("@")) {
|
||||
return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined;
|
||||
}
|
||||
return segments[0];
|
||||
}
|
||||
|
||||
function exactVersionFromRequestedRange(requestedRange: string | undefined) {
|
||||
if (!requestedRange) return undefined;
|
||||
return semver.valid(requestedRange) ?? semver.valid(requestedRange.replace(/^=\s*/, ""));
|
||||
}
|
||||
|
||||
function resolutionFromPackageSpec(params: { manifestName: string; packageSpec?: string }) {
|
||||
const exactVersion = exactVersionFromRequestedRange(params.packageSpec);
|
||||
if (exactVersion) {
|
||||
return {
|
||||
resolvedPackageName: params.manifestName,
|
||||
resolvedVersion: exactVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const npmAlias = params.packageSpec?.match(/^npm:(.+)$/)?.[1];
|
||||
if (!npmAlias) return undefined;
|
||||
const versionDelimiter = npmAlias.lastIndexOf("@");
|
||||
if (versionDelimiter <= 0) return undefined;
|
||||
const resolvedPackageName = npmAlias.slice(0, versionDelimiter);
|
||||
const resolvedVersion = exactVersionFromRequestedRange(npmAlias.slice(versionDelimiter + 1));
|
||||
if (!resolvedPackageName || !resolvedVersion) return undefined;
|
||||
return { resolvedPackageName, resolvedVersion };
|
||||
}
|
||||
|
||||
function lockfileResolutionForDependency(lockfile: JsonRecord | null, dependencyName: string) {
|
||||
if (!lockfile) return undefined;
|
||||
const packages = lockPackages(lockfile);
|
||||
const packageEntry = packages?.[nodeModulesKeyForDependency(dependencyName)];
|
||||
if (isRecord(packageEntry)) {
|
||||
const version = stringValue(packageEntry.version);
|
||||
const resolvedVersion = exactVersionFromRequestedRange(version);
|
||||
if (resolvedVersion) {
|
||||
return {
|
||||
resolvedPackageName: stringValue(packageEntry.name) ?? dependencyName,
|
||||
resolvedVersion,
|
||||
};
|
||||
}
|
||||
}
|
||||
const dependencies = isRecord(lockfile.dependencies) ? lockfile.dependencies : null;
|
||||
const dependencyEntry = dependencies?.[dependencyName];
|
||||
if (!isRecord(dependencyEntry)) return undefined;
|
||||
const version = stringValue(dependencyEntry.version);
|
||||
if (!version) return undefined;
|
||||
const exactVersion = exactVersionFromRequestedRange(version);
|
||||
if (exactVersion) {
|
||||
return {
|
||||
resolvedPackageName: stringValue(dependencyEntry.name) ?? dependencyName,
|
||||
resolvedVersion: exactVersion,
|
||||
};
|
||||
}
|
||||
return resolutionFromPackageSpec({
|
||||
manifestName: dependencyName,
|
||||
packageSpec: version,
|
||||
});
|
||||
}
|
||||
|
||||
function packageSpecResolutionForDependency(dependency: { name: string; requestedRange?: string }) {
|
||||
return resolutionFromPackageSpec({
|
||||
manifestName: dependency.name,
|
||||
packageSpec: dependency.requestedRange,
|
||||
});
|
||||
}
|
||||
|
||||
function packageManifestSelfDependency(
|
||||
file: DependencyManifestFile,
|
||||
manifest: JsonRecord,
|
||||
): PackageDependency | null {
|
||||
if (!isNodeModulesPackageJsonPath(file.path)) return null;
|
||||
const resolvedPackageName = stringValue(manifest.name);
|
||||
const resolvedVersion = exactVersionFromRequestedRange(stringValue(manifest.version));
|
||||
if (!resolvedPackageName || !resolvedVersion) return null;
|
||||
return {
|
||||
name:
|
||||
dependencyNameFromLockfilePackagePath(packageRootFromPath(file.path)) ?? resolvedPackageName,
|
||||
resolvedPackageName,
|
||||
ecosystem: "npm",
|
||||
manifestPath: file.path,
|
||||
resolvedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function lockfilePackageDependencies(file: DependencyManifestFile): PackageDependency[] {
|
||||
const lockfile = parseJson(file.content);
|
||||
if (!lockfile) return [];
|
||||
const packages = lockPackages(lockfile);
|
||||
|
||||
const dependencies: PackageDependency[] = [];
|
||||
if (packages) {
|
||||
for (const [path, entry] of Object.entries(packages)) {
|
||||
if (!isRecord(entry) || !path) continue;
|
||||
if (entry.dev === true) continue;
|
||||
const packageName = dependencyNameFromLockfilePackagePath(path);
|
||||
const resolvedPackageName = stringValue(entry.name) ?? packageName;
|
||||
const resolvedVersion = exactVersionFromRequestedRange(stringValue(entry.version));
|
||||
if (!packageName || !resolvedPackageName || !resolvedVersion) continue;
|
||||
dependencies.push({
|
||||
name: packageName,
|
||||
resolvedPackageName,
|
||||
ecosystem: "npm",
|
||||
manifestPath: file.path,
|
||||
resolvedVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
dependencies.push(...lockfileNestedDependencies(lockfile.dependencies, file.path));
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function lockfileNestedDependencies(value: unknown, manifestPath: string): PackageDependency[] {
|
||||
if (!isRecord(value)) return [];
|
||||
const dependencies: PackageDependency[] = [];
|
||||
for (const [name, entry] of Object.entries(value)) {
|
||||
if (!name.trim() || !isRecord(entry) || entry.dev === true) {
|
||||
continue;
|
||||
}
|
||||
const resolution = resolutionFromPackageSpec({
|
||||
manifestName: name.trim(),
|
||||
packageSpec: stringValue(entry.version),
|
||||
});
|
||||
if (resolution) {
|
||||
dependencies.push({
|
||||
name: name.trim(),
|
||||
resolvedPackageName: resolution.resolvedPackageName,
|
||||
ecosystem: "npm",
|
||||
manifestPath,
|
||||
resolvedVersion: resolution.resolvedVersion,
|
||||
});
|
||||
}
|
||||
dependencies.push(...lockfileNestedDependencies(entry.dependencies, manifestPath));
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
export function extractNpmDependencies(files: DependencyManifestFile[]): PackageDependency[] {
|
||||
const fileByPath = new Map(files.map((file) => [file.path, file]));
|
||||
const dependencies: PackageDependency[] = [];
|
||||
|
||||
for (const file of files.filter((entry) => isPackageJsonPath(entry.path))) {
|
||||
const manifest = parseJson(file.content);
|
||||
if (!manifest) continue;
|
||||
const selfDependency = packageManifestSelfDependency(file, manifest);
|
||||
if (selfDependency) dependencies.push(selfDependency);
|
||||
const root = packageRootFromPath(file.path);
|
||||
const lockfile =
|
||||
npmLockfilePathsForRoot(root)
|
||||
.map((path) => fileByPath.get(path)?.content)
|
||||
.map((content) => (content ? parseJson(content) : null))
|
||||
.find((parsed) => parsed !== null) ?? null;
|
||||
|
||||
for (const dependencyKind of DEPENDENCY_KINDS) {
|
||||
for (const dependency of dependencyEntries(manifest[dependencyKind])) {
|
||||
const lockfileResolution = lockfileResolutionForDependency(lockfile, dependency.name);
|
||||
const packageSpecResolution = packageSpecResolutionForDependency(dependency);
|
||||
const resolution = lockfileResolution ?? packageSpecResolution;
|
||||
dependencies.push({
|
||||
name: dependency.name,
|
||||
resolvedPackageName: resolution?.resolvedPackageName ?? dependency.name,
|
||||
ecosystem: "npm",
|
||||
dependencyKind,
|
||||
manifestPath: file.path,
|
||||
requestedRange: dependency.requestedRange,
|
||||
resolvedVersion: resolution?.resolvedVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of files.filter((entry) => isNpmLockfilePath(entry.path))) {
|
||||
dependencies.push(...lockfilePackageDependencies(file));
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return dependencies.filter((dependency) => {
|
||||
const key = [
|
||||
normalizePackageName(dependency.resolvedPackageName),
|
||||
dependency.resolvedVersion ?? "",
|
||||
].join("\0");
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export type OsvQueryBatchRequest = {
|
||||
queries: Array<{
|
||||
package: {
|
||||
name: string;
|
||||
ecosystem: "npm";
|
||||
};
|
||||
version: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function buildOsvQueryBatchRequest(dependencies: PackageDependency[]): OsvQueryBatchRequest {
|
||||
const scanned = dependencies.filter(
|
||||
(dependency): dependency is PackageDependency & { resolvedVersion: string } =>
|
||||
Boolean(dependency.resolvedVersion),
|
||||
);
|
||||
return {
|
||||
queries: scanned.map((dependency) => ({
|
||||
package: {
|
||||
name: dependency.resolvedPackageName,
|
||||
ecosystem: "npm",
|
||||
},
|
||||
version: dependency.resolvedVersion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function splitOsvQueryBatchRequest(
|
||||
request: OsvQueryBatchRequest,
|
||||
maxQueries = OSV_QUERY_BATCH_LIMIT,
|
||||
): OsvQueryBatchRequest[] {
|
||||
if (maxQueries < 1) throw new Error("OSV query batch size must be at least 1");
|
||||
const batches: OsvQueryBatchRequest[] = [];
|
||||
for (let index = 0; index < request.queries.length; index += maxQueries) {
|
||||
batches.push({ queries: request.queries.slice(index, index + maxQueries) });
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
export function mergeOsvQueryBatchResponses(responses: unknown[]): unknown {
|
||||
const results: unknown[] = [];
|
||||
for (const response of responses) {
|
||||
if (!isRecord(response) || !Array.isArray(response.results)) {
|
||||
throw new Error("OSV response did not include a results array");
|
||||
}
|
||||
results.push(...response.results);
|
||||
}
|
||||
return { results };
|
||||
}
|
||||
|
||||
function vulnerabilityAliases(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((entry): entry is string => typeof entry === "string" && Boolean(entry));
|
||||
}
|
||||
|
||||
function vulnerabilitySeverity(value: unknown): string | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
for (const entry of value) {
|
||||
if (!isRecord(entry)) continue;
|
||||
const score = stringValue(entry.score);
|
||||
if (score) return score;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isMaliciousOsvAdvisory(id: string, aliases: string[]) {
|
||||
return id.startsWith("MAL-") || aliases.some((alias) => alias.startsWith("MAL-"));
|
||||
}
|
||||
|
||||
function normalizeOsvVulnerability(value: unknown): OsvVulnerability | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = stringValue(value.id);
|
||||
if (!id) return null;
|
||||
const aliases = vulnerabilityAliases(value.aliases);
|
||||
const classification = isMaliciousOsvAdvisory(id, aliases) ? "malware" : "vulnerability";
|
||||
return {
|
||||
id,
|
||||
aliases,
|
||||
classification,
|
||||
summary: stringValue(value.summary),
|
||||
severity: vulnerabilitySeverity(value.severity),
|
||||
url: stringValue(value.details)
|
||||
? `https://osv.dev/vulnerability/${encodeURIComponent(id)}`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOsvQueryBatchResponse(params: {
|
||||
dependencies: PackageDependency[];
|
||||
response: unknown;
|
||||
checkedAt: number;
|
||||
}): PackageDependencyScanResult {
|
||||
const scannedDependencies = params.dependencies.filter(
|
||||
(dependency) => dependency.resolvedVersion,
|
||||
);
|
||||
if (!isRecord(params.response) || !Array.isArray(params.response.results)) {
|
||||
throw new Error("OSV response did not include a results array");
|
||||
}
|
||||
const results = params.response.results;
|
||||
if (results.length !== scannedDependencies.length) {
|
||||
throw new Error(
|
||||
`OSV response result count ${results.length} did not match query count ${scannedDependencies.length}`,
|
||||
);
|
||||
}
|
||||
const findings: PackageDependencyScanFinding[] = [];
|
||||
|
||||
for (let index = 0; index < scannedDependencies.length; index += 1) {
|
||||
const dependency = scannedDependencies[index];
|
||||
const result = results[index];
|
||||
const vulns = isRecord(result) && Array.isArray(result.vulns) ? result.vulns : [];
|
||||
for (const vuln of vulns) {
|
||||
const normalized = normalizeOsvVulnerability(vuln);
|
||||
if (!normalized) continue;
|
||||
findings.push({
|
||||
source: "osv",
|
||||
advisoryId: normalized.id,
|
||||
packageName: dependency.resolvedPackageName,
|
||||
...(dependency.name !== dependency.resolvedPackageName
|
||||
? { manifestName: dependency.name }
|
||||
: {}),
|
||||
ecosystem: dependency.ecosystem,
|
||||
version: dependency.resolvedVersion,
|
||||
summary: normalized.summary ?? normalized.id,
|
||||
aliases: normalized.aliases,
|
||||
classification: normalized.classification,
|
||||
confidence: normalized.classification === "malware" ? "high" : "medium",
|
||||
...(normalized.severity ? { severity: normalized.severity } : {}),
|
||||
...(normalized.url ? { url: normalized.url } : {}),
|
||||
manifestPath: dependency.manifestPath,
|
||||
...(dependency.dependencyKind ? { dependencyKind: dependency.dependencyKind } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasMalware = findings.some((finding) => finding.classification === "malware");
|
||||
const hasVulnerabilities = findings.length > 0;
|
||||
const skippedDependencyCount = params.dependencies.length - scannedDependencies.length;
|
||||
const status: PackageDependencyScanStatus = hasMalware
|
||||
? "malicious"
|
||||
: hasVulnerabilities
|
||||
? "suspicious"
|
||||
: skippedDependencyCount > 0
|
||||
? "skipped"
|
||||
: "clean";
|
||||
const manifests = [...new Set(params.dependencies.map((dependency) => dependency.manifestPath))];
|
||||
return {
|
||||
status,
|
||||
provider: "osv",
|
||||
scannerVersion: SCANNER_VERSION,
|
||||
dependencyCount: params.dependencies.length,
|
||||
scannedDependencyCount: scannedDependencies.length,
|
||||
skippedDependencyCount,
|
||||
manifests,
|
||||
findings,
|
||||
summary: summarizeDependencyScan(status, findings, scannedDependencies.length),
|
||||
checkedAt: params.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanDependencyScanResult(params: {
|
||||
dependencies: PackageDependency[];
|
||||
checkedAt: number;
|
||||
}): PackageDependencyScanResult {
|
||||
const scannedDependencyCount = params.dependencies.filter(
|
||||
(dependency) => dependency.resolvedVersion,
|
||||
).length;
|
||||
const status: PackageDependencyScanStatus =
|
||||
params.dependencies.length > 0 && scannedDependencyCount === 0 ? "skipped" : "clean";
|
||||
return {
|
||||
status,
|
||||
provider: "osv",
|
||||
scannerVersion: SCANNER_VERSION,
|
||||
dependencyCount: params.dependencies.length,
|
||||
scannedDependencyCount,
|
||||
skippedDependencyCount: params.dependencies.length - scannedDependencyCount,
|
||||
manifests: [...new Set(params.dependencies.map((dependency) => dependency.manifestPath))],
|
||||
findings: [],
|
||||
summary:
|
||||
params.dependencies.length === 0
|
||||
? "No npm dependency manifests found."
|
||||
: "No exact npm dependency versions found for OSV scanning.",
|
||||
checkedAt: params.checkedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function failedDependencyScanResult(params: {
|
||||
dependencies: PackageDependency[];
|
||||
checkedAt: number;
|
||||
error: string;
|
||||
}): PackageDependencyScanResult {
|
||||
return {
|
||||
status: "error",
|
||||
provider: "osv",
|
||||
scannerVersion: SCANNER_VERSION,
|
||||
dependencyCount: params.dependencies.length,
|
||||
scannedDependencyCount: params.dependencies.filter((dependency) => dependency.resolvedVersion)
|
||||
.length,
|
||||
skippedDependencyCount: params.dependencies.filter((dependency) => !dependency.resolvedVersion)
|
||||
.length,
|
||||
manifests: [...new Set(params.dependencies.map((dependency) => dependency.manifestPath))],
|
||||
findings: [],
|
||||
summary: "Dependency scan failed.",
|
||||
checkedAt: params.checkedAt,
|
||||
error: params.error,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeDependencyScan(
|
||||
status: PackageDependencyScanStatus,
|
||||
findings: PackageDependencyScanFinding[],
|
||||
scannedDependencyCount: number,
|
||||
) {
|
||||
if (status === "malicious") {
|
||||
const count = findings.filter((finding) => finding.classification === "malware").length;
|
||||
return `Detected ${count} malicious dependency advisory${count === 1 ? "" : "ies"}.`;
|
||||
}
|
||||
if (status === "suspicious") {
|
||||
return `Detected ${findings.length} dependency vulnerabilit${findings.length === 1 ? "y" : "ies"}.`;
|
||||
}
|
||||
if (status === "skipped") {
|
||||
return "No exact npm dependency versions found for OSV scanning.";
|
||||
}
|
||||
return `Scanned ${scannedDependencyCount} exact npm dependenc${scannedDependencyCount === 1 ? "y" : "ies"} with no OSV findings.`;
|
||||
}
|
||||
@@ -165,6 +165,110 @@ describe("packageSecurity", () => {
|
||||
expect(getPackageDownloadSecurityBlock(release)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps ordinary dependency vulnerabilities advisory", () => {
|
||||
const release = {
|
||||
dependencyScan: {
|
||||
status: "suspicious",
|
||||
findings: [{ classification: "vulnerability", confidence: "medium" }],
|
||||
},
|
||||
staticScan: { status: "clean" },
|
||||
verification: { scanStatus: "pending" },
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never;
|
||||
|
||||
expect(resolvePackageReleaseScanStatus(release)).toBe("pending");
|
||||
expect(getPackageDownloadSecurityBlock(release)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not block low-confidence dependency scan statuses", () => {
|
||||
const release = {
|
||||
dependencyScan: {
|
||||
status: "malicious",
|
||||
findings: [{ classification: "malware", confidence: "medium" }],
|
||||
},
|
||||
verification: { scanStatus: "pending" },
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never;
|
||||
|
||||
expect(resolvePackageReleaseScanStatus(release)).toBe("pending");
|
||||
expect(getPackageDownloadSecurityBlock(release)).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks package releases with malicious dependency scan results", () => {
|
||||
const release = {
|
||||
dependencyScan: {
|
||||
status: "malicious",
|
||||
findings: [
|
||||
{
|
||||
classification: "malware",
|
||||
confidence: "high",
|
||||
advisoryId: "MAL-2026-1234",
|
||||
},
|
||||
],
|
||||
},
|
||||
verification: { scanStatus: "pending" },
|
||||
llmAnalysis: { status: "clean", verdict: "benign" },
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never;
|
||||
|
||||
expect(resolvePackageReleaseScanStatus(release)).toBe("malicious");
|
||||
expect(getPackageDownloadSecurityBlock(release)).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 403,
|
||||
message: expect.stringContaining("malicious"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let stale manual approval override later dependency malware", () => {
|
||||
const release = {
|
||||
dependencyScan: {
|
||||
status: "malicious",
|
||||
checkedAt: 2,
|
||||
findings: [
|
||||
{
|
||||
classification: "malware",
|
||||
confidence: "high",
|
||||
advisoryId: "MAL-2026-1234",
|
||||
},
|
||||
],
|
||||
},
|
||||
manualModeration: { state: "approved", updatedAt: 1 },
|
||||
verification: { scanStatus: "pending" },
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never;
|
||||
|
||||
expect(resolvePackageReleaseScanStatus(release)).toBe("malicious");
|
||||
expect(getPackageDownloadSecurityBlock(release)).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 403,
|
||||
message: expect.stringContaining("malicious"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets newer manual approval clear dependency malware", () => {
|
||||
const release = {
|
||||
dependencyScan: {
|
||||
status: "malicious",
|
||||
checkedAt: 2,
|
||||
findings: [
|
||||
{
|
||||
classification: "malware",
|
||||
confidence: "high",
|
||||
advisoryId: "MAL-2026-1234",
|
||||
},
|
||||
],
|
||||
},
|
||||
manualModeration: { state: "approved", updatedAt: 3 },
|
||||
verification: { scanStatus: "pending" },
|
||||
sha256hash: "a".repeat(64),
|
||||
} as never;
|
||||
|
||||
expect(resolvePackageReleaseScanStatus(release)).toBe("clean");
|
||||
expect(getPackageDownloadSecurityBlock(release)).toBeNull();
|
||||
});
|
||||
|
||||
it("lets manual package moderation approve or block releases", () => {
|
||||
expect(
|
||||
resolvePackageReleaseScanStatus({
|
||||
|
||||
@@ -8,7 +8,13 @@ export type PackageScanStatus = Doc<"packages">["scanStatus"];
|
||||
|
||||
type PackageReleaseSecurityLike = Pick<
|
||||
Doc<"packageReleases">,
|
||||
"sha256hash" | "vtAnalysis" | "llmAnalysis" | "verification" | "staticScan" | "manualModeration"
|
||||
| "sha256hash"
|
||||
| "vtAnalysis"
|
||||
| "llmAnalysis"
|
||||
| "verification"
|
||||
| "staticScan"
|
||||
| "dependencyScan"
|
||||
| "manualModeration"
|
||||
>;
|
||||
|
||||
export function normalizePackageScanStatus(status: string | null | undefined): PackageScanStatus {
|
||||
@@ -16,10 +22,32 @@ export function normalizePackageScanStatus(status: string | null | undefined): P
|
||||
return normalized === "failed" ? undefined : (normalized as PackageScanStatus);
|
||||
}
|
||||
|
||||
export function hasMaliciousPackageDependencyScan(
|
||||
dependencyScan: PackageReleaseSecurityLike["dependencyScan"],
|
||||
) {
|
||||
return (
|
||||
dependencyScan?.status === "malicious" &&
|
||||
dependencyScan.findings.some(
|
||||
(finding) => finding.classification === "malware" && finding.confidence === "high",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function manualApprovalCoversDependencyScan(release: PackageReleaseSecurityLike) {
|
||||
return (
|
||||
release.manualModeration?.state === "approved" &&
|
||||
release.dependencyScan?.checkedAt !== undefined &&
|
||||
release.manualModeration.updatedAt >= release.dependencyScan.checkedAt
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePackageReleaseScanStatus(
|
||||
release: PackageReleaseSecurityLike,
|
||||
): Exclude<PackageScanStatus, undefined> {
|
||||
if (release.manualModeration?.state === "approved") return "clean";
|
||||
const dependencyScanMalicious = hasMaliciousPackageDependencyScan(release.dependencyScan);
|
||||
if (release.manualModeration?.state === "approved") {
|
||||
if (!dependencyScanMalicious || manualApprovalCoversDependencyScan(release)) return "clean";
|
||||
}
|
||||
if (
|
||||
release.manualModeration?.state === "quarantined" ||
|
||||
release.manualModeration?.state === "revoked"
|
||||
@@ -27,6 +55,8 @@ export function resolvePackageReleaseScanStatus(
|
||||
return "malicious";
|
||||
}
|
||||
|
||||
if (dependencyScanMalicious) return "malicious";
|
||||
|
||||
const llmStatus = normalizePackageScanStatus(
|
||||
release.llmAnalysis?.verdict ?? release.llmAnalysis?.status,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+236
-10
@@ -67,6 +67,18 @@ import { readGlobalPublicPluginsCount } from "./lib/globalStats";
|
||||
import { toDayKey } from "./lib/leaderboards";
|
||||
import { isOfficialPublisher } from "./lib/officialPublishers";
|
||||
import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts";
|
||||
import {
|
||||
buildOsvQueryBatchRequest,
|
||||
cleanDependencyScanResult,
|
||||
extractNpmDependencies,
|
||||
failedDependencyScanResult,
|
||||
mergeOsvQueryBatchResponses,
|
||||
normalizeOsvQueryBatchResponse,
|
||||
splitOsvQueryBatchRequest,
|
||||
type PackageDependency,
|
||||
type PackageDependencyScanResult,
|
||||
type DependencyManifestFile,
|
||||
} from "./lib/packageDependencyScan";
|
||||
import {
|
||||
assertPackageVersion,
|
||||
derivePluginManifestSummary,
|
||||
@@ -85,6 +97,7 @@ import {
|
||||
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
|
||||
import {
|
||||
getPackageTrustReasons,
|
||||
hasMaliciousPackageDependencyScan,
|
||||
isPackageBlockedFromPublic,
|
||||
normalizePackageScanStatus,
|
||||
resolvePackageReleaseScanStatus,
|
||||
@@ -482,6 +495,8 @@ const internalRefs = internal as unknown as {
|
||||
publishPackageForUserInternal: unknown;
|
||||
insertAuditLogInternal: unknown;
|
||||
updateReleaseStaticScanInternal: unknown;
|
||||
updateReleaseDependencyScanInternal: unknown;
|
||||
scanPackageReleaseDependenciesInternal: unknown;
|
||||
backfillLatestPackageScanStatusInternal: unknown;
|
||||
normalizeOfficialPublisherPackagesInternal: unknown;
|
||||
insertPackageInspectorWarningsInternal: unknown;
|
||||
@@ -564,6 +579,51 @@ const packageInspectorFindingInputValidator = v.object({
|
||||
decision: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const packageDependencyScanFindingInputValidator = v.object({
|
||||
source: v.literal("osv"),
|
||||
advisoryId: v.string(),
|
||||
packageName: v.string(),
|
||||
manifestName: v.optional(v.string()),
|
||||
ecosystem: v.literal("npm"),
|
||||
version: v.optional(v.string()),
|
||||
summary: v.string(),
|
||||
aliases: v.array(v.string()),
|
||||
classification: v.union(v.literal("malware"), v.literal("vulnerability")),
|
||||
confidence: v.union(v.literal("high"), v.literal("medium")),
|
||||
severity: v.optional(v.string()),
|
||||
url: v.optional(v.string()),
|
||||
manifestPath: v.optional(v.string()),
|
||||
dependencyKind: v.optional(
|
||||
v.union(
|
||||
v.literal("dependencies"),
|
||||
v.literal("optionalDependencies"),
|
||||
v.literal("peerDependencies"),
|
||||
v.literal("bundledDependencies"),
|
||||
v.literal("bundleDependencies"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const packageDependencyScanInputValidator = v.object({
|
||||
status: v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("skipped"),
|
||||
v.literal("error"),
|
||||
),
|
||||
provider: v.literal("osv"),
|
||||
scannerVersion: v.string(),
|
||||
dependencyCount: v.number(),
|
||||
scannedDependencyCount: v.number(),
|
||||
skippedDependencyCount: v.number(),
|
||||
manifests: v.array(v.string()),
|
||||
findings: v.array(packageDependencyScanFindingInputValidator),
|
||||
summary: v.string(),
|
||||
checkedAt: v.number(),
|
||||
error: v.optional(v.string()),
|
||||
});
|
||||
|
||||
type PackageInspectorAuthorRemediation = {
|
||||
summary: string;
|
||||
docsUrl?: string;
|
||||
@@ -7145,12 +7205,14 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
]);
|
||||
|
||||
const releases = [
|
||||
...recentReleases,
|
||||
...backlogReleases.filter(
|
||||
(release, index, all) =>
|
||||
recentReleases.findIndex((candidate) => candidate._id === release._id) === -1 &&
|
||||
all.findIndex((candidate) => candidate._id === release._id) === index,
|
||||
),
|
||||
...recentReleases.map((release) => ({ release, source: "recent" as const })),
|
||||
...backlogReleases
|
||||
.filter(
|
||||
(release, index, all) =>
|
||||
recentReleases.findIndex((candidate) => candidate._id === release._id) === -1 &&
|
||||
all.findIndex((candidate) => candidate._id === release._id) === index,
|
||||
)
|
||||
.map((release) => ({ release, source: "backlog" as const })),
|
||||
];
|
||||
|
||||
const results: Array<{
|
||||
@@ -7159,12 +7221,17 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
needsDependency?: true;
|
||||
}> = [];
|
||||
let nextCursor = cursor;
|
||||
let consideredBacklogReleases = 0;
|
||||
|
||||
for (const release of releases) {
|
||||
nextCursor = release._creationTime;
|
||||
for (const { release, source } of releases) {
|
||||
if (results.length >= batchSize) break;
|
||||
if (source === "backlog") {
|
||||
nextCursor = release._creationTime;
|
||||
consideredBacklogReleases += 1;
|
||||
}
|
||||
if (release.softDeletedAt) continue;
|
||||
|
||||
const pkg = await ctx.db.get(release.packageId);
|
||||
@@ -7173,7 +7240,10 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
const needsVt = !release.vtAnalysis;
|
||||
const needsLlm = !release.llmAnalysis || release.llmAnalysis.status === "error";
|
||||
const needsStatic = !release.staticScan;
|
||||
if (!needsVt && !needsLlm && !needsStatic) continue;
|
||||
const needsDependency =
|
||||
(!release.dependencyScan || release.dependencyScan.status === "error") &&
|
||||
(release.files ?? []).some((file) => isDependencyManifestPath(file.path));
|
||||
if (!needsVt && !needsLlm && !needsStatic && !needsDependency) continue;
|
||||
|
||||
results.push({
|
||||
releaseId: release._id,
|
||||
@@ -7181,13 +7251,16 @@ export const getPackageReleaseScanBackfillBatchInternal = internalQuery({
|
||||
needsVt,
|
||||
needsLlm,
|
||||
needsStatic,
|
||||
...(needsDependency ? { needsDependency: true as const } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
releases: results,
|
||||
nextCursor,
|
||||
done: backlogReleases.length < batchSize * 3,
|
||||
done:
|
||||
consideredBacklogReleases >= backlogReleases.length &&
|
||||
backlogReleases.length < batchSize * 3,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -7905,6 +7978,9 @@ async function publishPackageImpl(
|
||||
releaseId: publishResult.releaseId,
|
||||
source: "publish",
|
||||
});
|
||||
await runAfterRef(ctx, 0, internalRefs.packages.scanPackageReleaseDependenciesInternal, {
|
||||
releaseId: publishResult.releaseId,
|
||||
});
|
||||
|
||||
return inspectorFindings.length > 0 ? { ...publishResult, inspectorFindings } : publishResult;
|
||||
}
|
||||
@@ -8248,6 +8324,9 @@ async function runPackagePublishPostFinalizeFollowups(
|
||||
releaseId: publishResult.releaseId,
|
||||
source: "publish",
|
||||
});
|
||||
await runAfterRef(ctx, 0, internalRefs.packages.scanPackageReleaseDependenciesInternal, {
|
||||
releaseId: publishResult.releaseId,
|
||||
});
|
||||
}
|
||||
|
||||
export const reservePackageNameInternal = internalMutation({
|
||||
@@ -10115,6 +10194,147 @@ export const updateReleaseStaticScanInternal = internalMutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const updateReleaseDependencyScanInternal = internalMutation({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
dependencyScan: packageDependencyScanInputValidator,
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await ctx.db.get(args.releaseId);
|
||||
if (!release || release.softDeletedAt) return;
|
||||
const activeRelease = release;
|
||||
|
||||
const patch: Partial<Doc<"packageReleases">> = {
|
||||
dependencyScan: args.dependencyScan,
|
||||
};
|
||||
if (activeRelease.verification) {
|
||||
const previousVerificationWasDependencyMalicious =
|
||||
activeRelease.verification.scanStatus === "malicious" &&
|
||||
hasMaliciousPackageDependencyScan(activeRelease.dependencyScan);
|
||||
const { scanStatus: _previousDependencyScanStatus, ...verificationWithoutScanStatus } =
|
||||
activeRelease.verification;
|
||||
const verificationForResolution =
|
||||
previousVerificationWasDependencyMalicious && args.dependencyScan.status !== "error"
|
||||
? verificationWithoutScanStatus
|
||||
: activeRelease.verification;
|
||||
const nextScanStatus = resolvePackageReleaseScanStatus({
|
||||
...activeRelease,
|
||||
verification: verificationForResolution,
|
||||
dependencyScan: args.dependencyScan,
|
||||
});
|
||||
patch.verification = {
|
||||
...activeRelease.verification,
|
||||
scanStatus: nextScanStatus,
|
||||
};
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.releaseId, patch);
|
||||
const updatedRelease = {
|
||||
...activeRelease,
|
||||
...patch,
|
||||
} as Doc<"packageReleases">;
|
||||
await syncLatestPackageVerification(
|
||||
ctx,
|
||||
updatedRelease,
|
||||
hasMaliciousPackageDependencyScan(args.dependencyScan)
|
||||
? {
|
||||
quarantineMaliciousLatest: true,
|
||||
maliciousTrigger: "malicious.dependency_malware",
|
||||
}
|
||||
: {},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function isDependencyManifestPath(path: string) {
|
||||
return /(?:^|\/)(?:package\.json|package-lock\.json|npm-shrinkwrap\.json)$/i.test(path);
|
||||
}
|
||||
|
||||
async function readDependencyManifestFiles(
|
||||
ctx: Pick<ActionCtx, "storage">,
|
||||
files: Doc<"packageReleases">["files"],
|
||||
): Promise<DependencyManifestFile[]> {
|
||||
const manifests: DependencyManifestFile[] = [];
|
||||
for (const file of files) {
|
||||
if (!isDependencyManifestPath(file.path)) continue;
|
||||
manifests.push({
|
||||
path: file.path,
|
||||
content: await readStorageText(ctx, file.storageId),
|
||||
});
|
||||
}
|
||||
return manifests;
|
||||
}
|
||||
|
||||
export const scanPackageReleaseDependenciesInternal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const release = await runQueryRef<Doc<"packageReleases"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getReleaseByIdInternal,
|
||||
{ releaseId: args.releaseId },
|
||||
);
|
||||
if (!release || release.softDeletedAt) {
|
||||
return { ok: true as const, skipped: "missing_release" as const };
|
||||
}
|
||||
const pkg = await runQueryRef<Doc<"packages"> | null>(
|
||||
ctx,
|
||||
internalRefs.packages.getPackageByIdInternal,
|
||||
{ packageId: release.packageId },
|
||||
);
|
||||
if (!pkg || pkg.softDeletedAt || pkg.family === "skill") {
|
||||
return { ok: true as const, skipped: "missing_package" as const };
|
||||
}
|
||||
|
||||
let dependencyScan: PackageDependencyScanResult;
|
||||
let dependencies: PackageDependency[] = [];
|
||||
try {
|
||||
const manifestFiles = await readDependencyManifestFiles(ctx, release.files);
|
||||
dependencies = extractNpmDependencies(manifestFiles);
|
||||
const request = buildOsvQueryBatchRequest(dependencies);
|
||||
if (request.queries.length === 0) {
|
||||
dependencyScan = cleanDependencyScanResult({ dependencies, checkedAt: Date.now() });
|
||||
} else {
|
||||
const responses: unknown[] = [];
|
||||
for (const batch of splitOsvQueryBatchRequest(request)) {
|
||||
const response = await fetch("https://api.osv.dev/v1/querybatch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(batch),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSV query failed with HTTP ${response.status}`);
|
||||
}
|
||||
responses.push((await response.json()) as unknown);
|
||||
}
|
||||
dependencyScan = normalizeOsvQueryBatchResponse({
|
||||
dependencies,
|
||||
response: mergeOsvQueryBatchResponses(responses),
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
dependencyScan = failedDependencyScanResult({
|
||||
dependencies,
|
||||
checkedAt: Date.now(),
|
||||
error: message.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
await runMutationRef(ctx, internalRefs.packages.updateReleaseDependencyScanInternal, {
|
||||
releaseId: args.releaseId,
|
||||
dependencyScan,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
status: dependencyScan.status,
|
||||
findingCount: dependencyScan.findings.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const scanPackageReleaseStaticallyInternal = internalAction({
|
||||
args: {
|
||||
releaseId: v.id("packageReleases"),
|
||||
@@ -10186,6 +10406,7 @@ export const backfillPackageReleaseScansInternal = internalAction({
|
||||
needsVt: boolean;
|
||||
needsLlm: boolean;
|
||||
needsStatic: boolean;
|
||||
needsDependency?: true;
|
||||
}>;
|
||||
nextCursor: number;
|
||||
done: boolean;
|
||||
@@ -10210,6 +10431,11 @@ export const backfillPackageReleaseScansInternal = internalAction({
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
if (release.needsDependency) {
|
||||
await runAfterRef(ctx, 0, internalRefs.packages.scanPackageReleaseDependenciesInternal, {
|
||||
releaseId: release.releaseId,
|
||||
});
|
||||
}
|
||||
scheduled += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,51 @@ const staticScanValidator = v.object({
|
||||
checkedAt: v.number(),
|
||||
});
|
||||
|
||||
const packageDependencyScanFindingValidator = v.object({
|
||||
source: v.literal("osv"),
|
||||
advisoryId: v.string(),
|
||||
packageName: v.string(),
|
||||
manifestName: v.optional(v.string()),
|
||||
ecosystem: v.literal("npm"),
|
||||
version: v.optional(v.string()),
|
||||
summary: v.string(),
|
||||
aliases: v.array(v.string()),
|
||||
classification: v.union(v.literal("malware"), v.literal("vulnerability")),
|
||||
confidence: v.union(v.literal("high"), v.literal("medium")),
|
||||
severity: v.optional(v.string()),
|
||||
url: v.optional(v.string()),
|
||||
manifestPath: v.optional(v.string()),
|
||||
dependencyKind: v.optional(
|
||||
v.union(
|
||||
v.literal("dependencies"),
|
||||
v.literal("optionalDependencies"),
|
||||
v.literal("peerDependencies"),
|
||||
v.literal("bundledDependencies"),
|
||||
v.literal("bundleDependencies"),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const packageDependencyScanValidator = v.object({
|
||||
status: v.union(
|
||||
v.literal("clean"),
|
||||
v.literal("suspicious"),
|
||||
v.literal("malicious"),
|
||||
v.literal("skipped"),
|
||||
v.literal("error"),
|
||||
),
|
||||
provider: v.literal("osv"),
|
||||
scannerVersion: v.string(),
|
||||
dependencyCount: v.number(),
|
||||
scannedDependencyCount: v.number(),
|
||||
skippedDependencyCount: v.number(),
|
||||
manifests: v.array(v.string()),
|
||||
findings: v.array(packageDependencyScanFindingValidator),
|
||||
summary: v.string(),
|
||||
checkedAt: v.number(),
|
||||
error: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const users = defineTable({
|
||||
name: v.optional(v.string()),
|
||||
image: v.optional(v.string()),
|
||||
@@ -1674,6 +1719,7 @@ const packageReleases = defineTable({
|
||||
checkedAt: v.number(),
|
||||
}),
|
||||
),
|
||||
dependencyScan: v.optional(packageDependencyScanValidator),
|
||||
staticScan: v.optional(
|
||||
v.object({
|
||||
status: v.union(v.literal("clean"), v.literal("suspicious"), v.literal("malicious")),
|
||||
|
||||
@@ -153,6 +153,16 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
the same artifact. Static scan findings are ClawScan input context only and
|
||||
must not schedule account autobans or set public/install-blocking trust by
|
||||
themselves.
|
||||
- Plugin package artifacts with inspectable dependency manifests may have a
|
||||
dedicated dependency scan result on the exact package release. Static scan
|
||||
findings remain ClawScan input context only; dependency scan findings are a
|
||||
separate supply-chain signal. High-confidence malicious package advisories
|
||||
must set exact-release package trust to `malicious` and therefore make
|
||||
`trust.blockedFromDownload` true. Ordinary dependency vulnerabilities should
|
||||
remain advisory or `suspicious` until policy explicitly promotes them. This
|
||||
keeps dependency-risk decisions in ClawHub for ClawHub-sourced artifacts
|
||||
instead of requiring OpenClaw install clients to duplicate local package
|
||||
scanning.
|
||||
- Pending skill ownership transfers must not be accepted when the requesting
|
||||
owner is deleted/deactivated or when the skill is malicious, hidden, or
|
||||
removed. The accept path is the final shared gate before ownership changes,
|
||||
|
||||
Reference in New Issue
Block a user