Compare commits

...
Author SHA1 Message Date
Jesse Merhi a918170f95 Add dependency scanning for package releases 2026-07-09 12:59:15 +10:00
8 changed files with 2604 additions and 12 deletions
+642
View File
@@ -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/);
});
});
+567
View File
@@ -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.`;
}
+104
View File
@@ -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({
+32 -2
View File
@@ -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
View File
@@ -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;
}
+46
View File
@@ -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")),
+10
View File
@@ -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,