From d553e6282ec7edcc6687c333a9ee6571b504419b Mon Sep 17 00:00:00 2001
From: Jagpreet Singh
Date: Sun, 13 Sep 2026 21:23:04 +0530
Subject: [PATCH 1/5] Add repository discovery, project selection, and accurate
dependency tracing
---
biome.json | 36 ++
package-lock.json | 203 ++++++++++-
package.json | 9 +-
src/server/advisories.ts | 93 +++++
src/server/app.ts | 326 +++++++++++++----
src/server/connectors.ts | 652 +++++++++++++++++++++++++++-------
src/server/demo.ts | 111 +++++-
src/server/discovery.ts | 129 +++++++
src/server/graph.ts | 294 +++++++++++++--
src/server/index.ts | 15 +-
src/server/jobs.ts | 356 ++++++++++++++++---
src/server/parsers.ts | 260 ++++++++++----
src/server/resolve-project.ts | 367 +++++++++++++++++++
src/server/sbom.ts | 55 ++-
src/server/store.ts | 81 ++++-
src/server/telemetry.ts | 20 +-
src/server/transport.ts | 206 ++++++++---
src/shared/types.ts | 162 ++++++++-
tests/api.test.ts | 200 +++++++++--
tests/core.test.ts | 275 ++++++++++----
tests/discovery.test.ts | 263 ++++++++++++++
21 files changed, 3559 insertions(+), 554 deletions(-)
create mode 100644 biome.json
create mode 100644 src/server/advisories.ts
create mode 100644 src/server/discovery.ts
create mode 100644 src/server/resolve-project.ts
create mode 100644 tests/discovery.test.ts
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..dc45828
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,36 @@
+{
+ "files": {
+ "includes": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "tests/**/*.ts",
+ "scripts/**/*.mjs",
+ "!src/web/components/ui/hero-section-6.tsx",
+ "!src/web/components/ui/animated-group.tsx"
+ ]
+ },
+ "formatter": {
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 100
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": false,
+ "correctness": {
+ "noUnreachable": "error",
+ "noInvalidUseBeforeDeclaration": "error",
+ "noConstantCondition": "error"
+ },
+ "suspicious": {
+ "noDuplicateObjectKeys": "error",
+ "noDuplicateParameters": "error",
+ "useIsArray": "error"
+ },
+ "a11y": {
+ "useKeyWithClickEvents": "error"
+ }
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 67b35fa..85ed9ee 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/sdk-metrics": "^2.11.0",
"@opentelemetry/sdk-node": "^0.222.0",
+ "@pandatix/js-cvss": "^0.4.4",
"@tailwindcss/vite": "^4.3.3",
"clsx": "^2.1.1",
"fast-xml-parser": "^5.11.1",
@@ -30,11 +31,14 @@
"react": "^19.3.0",
"react-dom": "^19.3.0",
"semver": "^7.8.5",
+ "smol-toml": "^1.8.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
+ "yaml": "^2.9.1",
"zod": "^4.6.1"
},
"devDependencies": {
+ "@biomejs/biome": "^2.5.13",
"@playwright/test": "^1.63.0",
"@types/node": "^22.20.2",
"@types/react": "^19.3.0",
@@ -51,6 +55,181 @@
"node": ">=24.0.0"
}
},
+ "node_modules/@biomejs/biome": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.13.tgz",
+ "integrity": "sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-darwin-arm64": "2.5.13",
+ "@biomejs/cli-darwin-x64": "2.5.13",
+ "@biomejs/cli-linux-arm64": "2.5.13",
+ "@biomejs/cli-linux-arm64-musl": "2.5.13",
+ "@biomejs/cli-linux-x64": "2.5.13",
+ "@biomejs/cli-linux-x64-musl": "2.5.13",
+ "@biomejs/cli-win32-arm64": "2.5.13",
+ "@biomejs/cli-win32-x64": "2.5.13"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.13.tgz",
+ "integrity": "sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.13.tgz",
+ "integrity": "sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.13.tgz",
+ "integrity": "sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.13.tgz",
+ "integrity": "sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.13.tgz",
+ "integrity": "sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.13.tgz",
+ "integrity": "sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.13.tgz",
+ "integrity": "sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "2.5.13",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.13.tgz",
+ "integrity": "sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -2285,6 +2464,12 @@
"url": "https://github.com/sponsors/oxc-project"
}
},
+ "node_modules/@pandatix/js-cvss": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/@pandatix/js-cvss/-/js-cvss-0.4.4.tgz",
+ "integrity": "sha512-hhBbQ7IKfoRoV3QKTP9hLKJT166/e0FkeiVSLZZIEVsO7DjxVmIWhJlPvbp+RXaM4w215xghtnXQv13Ebdlj/w==",
+ "license": "MIT"
+ },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -5631,6 +5816,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
+ "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
@@ -6079,9 +6276,9 @@
}
},
"node_modules/yaml": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
- "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
diff --git a/package.json b/package.json
index 83d86d7..6a3e3b7 100644
--- a/package.json
+++ b/package.json
@@ -13,9 +13,10 @@
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test tests/*.test.ts",
- "check": "npm run typecheck && npm test && npm run build",
+ "check": "npm run lint && npm run typecheck && npm test && npm run build",
"local": "npm run build && npm start",
- "test:e2e": "playwright test"
+ "test:e2e": "playwright test",
+ "lint": "biome lint src tests scripts"
},
"dependencies": {
"@fastify/helmet": "^13.1.1",
@@ -29,6 +30,7 @@
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/sdk-metrics": "^2.11.0",
"@opentelemetry/sdk-node": "^0.222.0",
+ "@pandatix/js-cvss": "^0.4.4",
"@tailwindcss/vite": "^4.3.3",
"clsx": "^2.1.1",
"fast-xml-parser": "^5.11.1",
@@ -40,11 +42,14 @@
"react": "^19.3.0",
"react-dom": "^19.3.0",
"semver": "^7.8.5",
+ "smol-toml": "^1.8.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
+ "yaml": "^2.9.1",
"zod": "^4.6.1"
},
"devDependencies": {
+ "@biomejs/biome": "^2.5.13",
"@playwright/test": "^1.63.0",
"@types/node": "^22.20.2",
"@types/react": "^19.3.0",
diff --git a/src/server/advisories.ts b/src/server/advisories.ts
new file mode 100644
index 0000000..9cd92c2
--- /dev/null
+++ b/src/server/advisories.ts
@@ -0,0 +1,93 @@
+import { CVSS20, CVSS30, CVSS31, CVSS40 } from "@pandatix/js-cvss";
+import { PackageURL } from "packageurl-js";
+import semver from "semver";
+import type { Advisory, PackageNode, Provenance } from "../shared/types.js";
+export function cvssFromVector(vector: string): number | undefined {
+ try {
+ return vector.startsWith("CVSS:4.0/")
+ ? new CVSS40(vector).Score()
+ : vector.startsWith("CVSS:3.1/")
+ ? new CVSS31(vector).BaseScore()
+ : vector.startsWith("CVSS:3.0/")
+ ? new CVSS30(vector).BaseScore()
+ : vector.startsWith("AV:")
+ ? new CVSS20(vector).BaseScore()
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
+const norm = (name: string) => name.toLowerCase().replace(/[-_.]+/g, "-");
+export function osvAdvisory(v: any, n: PackageNode, provenance: Provenance): Advisory | undefined {
+ if (v.withdrawn || typeof v.id !== "string") return;
+ const ecosystem = { npm: "npm", pypi: "PyPI", maven: "Maven" }[n.ecosystem];
+ const matching = (v.affected || []).filter((a: any) => {
+ if (a.package?.purl) {
+ try {
+ const p = PackageURL.fromString(a.package.purl);
+ const own = PackageURL.fromString(n.purl);
+ return p.type === own.type && p.name === own.name && p.namespace === own.namespace;
+ } catch {
+ return false;
+ }
+ }
+ return (
+ a.package?.ecosystem === ecosystem &&
+ (n.ecosystem === "pypi"
+ ? norm(a.package.name || "") === norm(n.name)
+ : a.package.name === n.name)
+ );
+ });
+ if (!matching.length) return; // Never attach details for a different package returned by a source.
+ const scores = [...(v.severity || []), ...matching.flatMap((a: any) => a.severity || [])]
+ .filter((s: any) => typeof s.score === "string")
+ .map((s: any) => ({ vector: s.score, score: cvssFromVector(s.score) }))
+ .filter((s: { score?: number }) => s.score !== undefined)
+ .sort((a: { score?: number }, b: { score?: number }) => (b.score || 0) - (a.score || 0));
+ const fixed: string[] = [];
+ const affected: string[] = [];
+ for (const a of matching)
+ for (const range of a.ranges || []) {
+ // Only list a fixed endpoint from the interval containing the installed version.
+ // Semver intervals are evaluated; other ecosystem ranges remain source text.
+ let introduced = "0";
+ for (const event of range.events || []) {
+ if (event.introduced !== undefined) introduced = event.introduced;
+ if (event.fixed) {
+ const text = `${introduced === "0" ? "Earlier versions" : `From ${introduced}`} to before ${event.fixed}`;
+ affected.push(text);
+ if (
+ range.type === "SEMVER" &&
+ semver.valid(n.version) &&
+ semver.valid(event.fixed) &&
+ (introduced === "0" ||
+ (semver.valid(introduced) && semver.gte(n.version, introduced))) &&
+ semver.lt(n.version, event.fixed)
+ )
+ fixed.push(event.fixed);
+ else if (
+ range.type !== "SEMVER" &&
+ matching.length === 1 &&
+ (a.versions || []).includes(n.version)
+ )
+ fixed.push(event.fixed);
+ }
+ }
+ }
+ const severity = String(v.database_specific?.severity || "").toLowerCase();
+ return {
+ id: v.id,
+ aliases: (v.aliases || []).filter((s: unknown) => typeof s === "string"),
+ summary: v.summary || v.details?.slice(0, 400) || "Known vulnerability reported by the source",
+ cvss: scores[0]?.score,
+ cvssVector: scores[0]?.vector,
+ severity: ["critical", "high", "medium", "low"].includes(severity)
+ ? (severity as Advisory["severity"])
+ : undefined,
+ fixed: [...new Set(fixed)].join(", ") || undefined,
+ affected: [...new Set(affected)],
+ provenance: [
+ { ...provenance, url: `https://osv.dev/vulnerability/${encodeURIComponent(v.id)}` },
+ ],
+ };
+}
diff --git a/src/server/app.ts b/src/server/app.ts
index 21d064e..603418c 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -1,83 +1,261 @@
-import Fastify from 'fastify';
-import helmet from '@fastify/helmet';
-import rateLimit from '@fastify/rate-limit';
-import staticPlugin from '@fastify/static';
-import { createHmac, timingSafeEqual } from 'node:crypto';
-import { resolve } from 'node:path';
-import { existsSync } from 'node:fs';
-import { z } from 'zod';
-import { metrics } from '@opentelemetry/api';
-import { Store } from './store.js';
-import { Jobs, connectors } from './jobs.js';
-import { canonicalRepository } from './connectors.js';
-import { simulate } from './graph.js';
-import { sbom } from './sbom.js';
-const inputSchema=z.discriminatedUnion('mode',[
- z.object({mode:z.literal('demo')}),
- z.object({mode:z.literal('github'),repository:z.string().max(250),ref:z.string().min(1).max(200).regex(/^[A-Za-z0-9_./-]+$/).default('HEAD'),installationId:z.number().int().positive().optional()}),
- z.object({mode:z.literal('manifest'),filename:z.string().max(250),content:z.string().min(1).max(2_000_000)})
+import Fastify from "fastify";
+import helmet from "@fastify/helmet";
+import rateLimit from "@fastify/rate-limit";
+import staticPlugin from "@fastify/static";
+import { createHmac, timingSafeEqual } from "node:crypto";
+import { resolve } from "node:path";
+import { existsSync } from "node:fs";
+import { z } from "zod";
+import { metrics } from "@opentelemetry/api";
+import { Store } from "./store.js";
+import { Jobs, connectors } from "./jobs.js";
+import { canonicalRepository } from "./connectors.js";
+import { simulate } from "./graph.js";
+import { sbom } from "./sbom.js";
+const inputSchema = z.discriminatedUnion("mode", [
+ z.object({ mode: z.literal("demo") }),
+ z.object({
+ mode: z.literal("github"),
+ repository: z.string().max(250),
+ ref: z
+ .string()
+ .min(1)
+ .max(200)
+ .regex(/^[A-Za-z0-9_./-]+$/)
+ .default("HEAD"),
+ installationId: z.number().int().positive().optional(),
+ }),
+ z.object({
+ mode: z.literal("manifest"),
+ filename: z.string().max(250),
+ content: z.string().min(1).max(2_000_000),
+ }),
]);
-export function verifySignature(body:Buffer,signature:string,secret:string) {
- if(!/^sha256=[a-f0-9]{64}$/.test(signature))return false;
- const expected=createHmac('sha256',secret).update(body).digest();const actual=Buffer.from(signature.slice(7),'hex');
- return actual.length===expected.length && timingSafeEqual(expected,actual);
+export function verifySignature(body: Buffer, signature: string, secret: string) {
+ if (!/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
+ const expected = createHmac("sha256", secret).update(body).digest();
+ const actual = Buffer.from(signature.slice(7), "hex");
+ return actual.length === expected.length && timingSafeEqual(expected, actual);
}
-export async function createApp(store=new Store(),startJobs=true) {
- if(process.env.NODE_ENV==='production' && (!process.env.API_TOKEN || process.env.API_TOKEN.length<32))throw new Error('Production requires API_TOKEN of at least 32 characters.');
- const app=Fastify({logger:process.env.NODE_ENV!=='test'?{redact:['req.headers.authorization','req.headers.cookie'],level:process.env.LOG_LEVEL||'info'}:false,bodyLimit:2_100_000,requestTimeout:15_000});
- const jobs=new Jobs(store);if(startJobs)jobs.kick();
- await app.register(helmet,{contentSecurityPolicy:{directives:{defaultSrc:["'self'"],scriptSrc:["'self'"],styleSrc:["'self'","'unsafe-inline'"],fontSrc:["'self'"],imgSrc:["'self'",'data:'],connectSrc:["'self'"],objectSrc:["'none'"],frameAncestors:["'none'"],upgradeInsecureRequests:process.env.NODE_ENV==='production'?[]:null}}});
- await app.register(rateLimit,{max:120,timeWindow:'1 minute'});
- app.addHook('onRequest',async(req,reply)=>{
- if(!req.url.startsWith('/api/v1/') || req.url.startsWith('/api/v1/health') || req.url==='/api/v1/webhooks/github')return;
- const origin=req.headers.origin;
- if(origin && origin!==`${req.protocol}://${req.host}` && origin!==process.env.PUBLIC_ORIGIN)return reply.code(403).send({error:'Cross-origin requests are not allowed.'});
- const secret=process.env.API_TOKEN;
- if(secret){const supplied=Buffer.from(req.headers.authorization?.replace(/^Bearer /,'')||'');const expected=Buffer.from(secret);if(supplied.length!==expected.length || !timingSafeEqual(supplied,expected))return reply.code(401).send({error:'Enter the server access token to continue.'});}
+export async function createApp(store = new Store(), startJobs = true) {
+ if (
+ process.env.NODE_ENV === "production" &&
+ (!process.env.API_TOKEN || process.env.API_TOKEN.length < 32)
+ )
+ throw new Error("Production requires API_TOKEN of at least 32 characters.");
+ const app = Fastify({
+ logger:
+ process.env.NODE_ENV !== "test"
+ ? {
+ redact: ["req.headers.authorization", "req.headers.cookie"],
+ level: process.env.LOG_LEVEL || "info",
+ }
+ : false,
+ bodyLimit: 2_100_000,
+ requestTimeout: 15_000,
});
- app.setErrorHandler((error,req,reply)=>{
- const err=error as Error & {statusCode?:number};
- const validation=error instanceof z.ZodError;
- const status=validation?400:err.statusCode||500;
- if(status>=500)req.log.error({err},'Request failed');
- reply.code(status).send({error:validation?'Invalid request. Check the repository, ref or manifest fields.':status>=500?'Request failed. Check server logs and try again.':err.message});
+ const jobs = new Jobs(store);
+ if (startJobs) jobs.kick();
+ await app.register(helmet, {
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ fontSrc: ["'self'"],
+ imgSrc: ["'self'", "data:"],
+ connectSrc: ["'self'"],
+ objectSrc: ["'none'"],
+ frameAncestors: ["'none'"],
+ upgradeInsecureRequests: process.env.NODE_ENV === "production" ? [] : null,
+ },
+ },
});
- app.get('/api/v1/health',async()=>({status:'ok',database:store.db.prepare('SELECT 1 AS ok').get()?.ok===1?'ready':'failed',connectors:connectors(),authRequired:Boolean(process.env.API_TOKEN)}));
- app.get('/api/v1/scans',async()=>store.list().map(({graph,risks,...scan})=>({...scan,packages:graph?.nodes.filter(n=>n.kind==='package').length||0})));
- app.post('/api/v1/scans',{config:{rateLimit:{max:10,timeWindow:'1 minute'}}},async(req,reply)=>{
- const input=inputSchema.parse(req.body);
- try {if(input.mode==='github')canonicalRepository(input.repository);return reply.code(202).send(jobs.create(input));}
- catch(error){return reply.code(400).send({error:(error as Error).message});}
+ await app.register(rateLimit, { max: 120, timeWindow: "1 minute" });
+ app.addHook("onRequest", async (req, reply) => {
+ if (
+ !req.url.startsWith("/api/v1/") ||
+ req.url.startsWith("/api/v1/health") ||
+ req.url === "/api/v1/webhooks/github"
+ )
+ return;
+ const origin = req.headers.origin;
+ if (
+ origin &&
+ origin !== `${req.protocol}://${req.host}` &&
+ origin !== process.env.PUBLIC_ORIGIN
+ )
+ return reply.code(403).send({ error: "Cross-origin requests are not allowed." });
+ const secret = process.env.API_TOKEN;
+ if (secret) {
+ const supplied = Buffer.from(req.headers.authorization?.replace(/^Bearer /, "") || "");
+ const expected = Buffer.from(secret);
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
+ return reply.code(401).send({ error: "Enter the server access token to continue." });
+ }
});
- function getScan(id:string,ready=false){const scan=store.get(id);if(!scan)throw Object.assign(new Error('Scan not found.'),{statusCode:404});if(ready&&scan.status!=='completed')throw Object.assign(new Error('Scan is not complete.'),{statusCode:409});return scan;}
- app.get<{Params:{scanId:string}}>('/api/v1/scans/:scanId',async req=>getScan(req.params.scanId));
- app.get<{Params:{scanId:string}}>('/api/v1/scans/:scanId/graph',async req=>getScan(req.params.scanId,true).graph);
- app.get<{Params:{scanId:string}}>('/api/v1/scans/:scanId/risks',async req=>getScan(req.params.scanId,true).risks);
- app.post<{Params:{scanId:string}}>('/api/v1/scans/:scanId/simulate',async(req,reply)=>{const {nodeId}=z.object({nodeId:z.string().max(100)}).parse(req.body);try{return simulate(getScan(req.params.scanId,true).graph!,nodeId);}catch(error){return reply.code((error as any).statusCode||404).send({error:(error as Error).message});}});
- app.get<{Params:{scanId:string}}>('/api/v1/scans/:scanId/sbom',async(req,reply)=>reply.header('Content-Disposition',`attachment; filename="rippleguard-${req.params.scanId.replace(/[^a-zA-Z0-9-]/g,'')}.spdx.json"`).send(sbom(getScan(req.params.scanId,true))));
- await app.register(async webhook=>{
- webhook.removeAllContentTypeParsers();webhook.addContentTypeParser('application/json',{parseAs:'buffer'},(_req,body,done)=>done(null,body));
- webhook.post('/api/v1/webhooks/github',async(req,reply)=>{
- const secret=process.env.GITHUB_WEBHOOK_SECRET;if(!secret)return reply.code(503).send({error:'Webhook is not configured.'});
- const raw=req.body as Buffer;
- if(!verifySignature(raw,String(req.headers['x-hub-signature-256']||''),secret)){metrics.getMeter('rippleguard').createCounter('webhook_validation_failures').add(1);return reply.code(401).send({error:'Invalid webhook signature.'});}
- const id=z.string().min(1).max(100).parse(req.headers['x-github-delivery']);
- const event=req.headers['x-github-event'];if(event==='ping')return {received:true};if(event!=='push')return reply.code(202).send({ignored:true});
- let body:any;try{body=JSON.parse(raw.toString('utf8'));}catch{return reply.code(400).send({error:'Invalid JSON.'});}
- const repo=canonicalRepository(z.string().parse(body.repository?.full_name));
- if(!(process.env.GITHUB_ALLOWED_REPOS||'').split(',').map(s=>s.trim().toLowerCase()).includes(repo.toLowerCase()))return reply.code(403).send({error:'Repository is not enabled for webhook scans.'});
- if(body.deleted)return reply.code(202).send({ignored:true});
- const sha=z.string().regex(/^[a-f0-9]{40}$/).parse(body.after);
- const installationId=z.number().int().positive().parse(body.installation?.id);
- if(!(process.env.GITHUB_INSTALLATION_IDS||'').split(',').map(Number).includes(installationId))return reply.code(403).send({error:'Installation is not allowed.'});
+ app.setErrorHandler((error, req, reply) => {
+ const err = error as Error & { statusCode?: number };
+ const validation = error instanceof z.ZodError;
+ const status = validation ? 400 : err.statusCode || 500;
+ if (status >= 500) req.log.error({ err }, "Request failed");
+ reply
+ .code(status)
+ .send({
+ error: validation
+ ? "Invalid request. Check the repository, ref or manifest fields."
+ : status >= 500
+ ? "Request failed. Check server logs and try again."
+ : err.message,
+ });
+ });
+ app.get("/api/v1/health", async () => ({
+ status: "ok",
+ database: store.db.prepare("SELECT 1 AS ok").get()?.ok === 1 ? "ready" : "failed",
+ connectors: connectors(),
+ authRequired: Boolean(process.env.API_TOKEN),
+ }));
+ app.get("/api/v1/scans", async () =>
+ store
+ .list()
+ .map(({ graph, risks, ...scan }) => ({
+ ...scan,
+ packages: graph?.nodes.filter((n) => n.kind === "package").length || 0,
+ })),
+ );
+ app.post(
+ "/api/v1/scans",
+ { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
+ async (req, reply) => {
+ const input = inputSchema.parse(req.body);
+ try {
+ if (input.mode === "github") canonicalRepository(input.repository);
+ return reply.code(202).send(jobs.create(input));
+ } catch (error) {
+ return reply.code(400).send({ error: (error as Error).message });
+ }
+ },
+ );
+ function getScan(id: string, ready = false) {
+ const scan = store.get(id);
+ if (!scan) throw Object.assign(new Error("Scan not found."), { statusCode: 404 });
+ if (ready && scan.status !== "completed")
+ throw Object.assign(new Error("Scan is not complete."), { statusCode: 409 });
+ return scan;
+ }
+ app.post<{ Params: { scanId: string } }>("/api/v1/scans/:scanId/analyze", async (req, reply) => {
+ const selection = z
+ .object({
+ projects: z
+ .array(
+ z.object({ id: z.string().max(100), name: z.string().trim().min(1).max(100) }).strict(),
+ )
+ .min(1)
+ .max(5000),
+ })
+ .strict()
+ .parse(req.body);
+ try {
+ return reply.code(202).send(jobs.analyze(req.params.scanId, selection.projects));
+ } catch (error) {
+ return reply.code(400).send({ error: (error as Error).message });
+ }
+ });
+ app.get<{ Params: { scanId: string } }>("/api/v1/scans/:scanId", async (req) =>
+ getScan(req.params.scanId),
+ );
+ app.get<{ Params: { scanId: string } }>(
+ "/api/v1/scans/:scanId/graph",
+ async (req) => getScan(req.params.scanId, true).graph,
+ );
+ app.get<{ Params: { scanId: string } }>(
+ "/api/v1/scans/:scanId/risks",
+ async (req) => getScan(req.params.scanId, true).risks,
+ );
+ app.post<{ Params: { scanId: string } }>("/api/v1/scans/:scanId/simulate", async (req, reply) => {
+ const { nodeId } = z.object({ nodeId: z.string().max(100) }).parse(req.body);
+ try {
+ return simulate(getScan(req.params.scanId, true).graph!, nodeId);
+ } catch (error) {
+ return reply.code((error as any).statusCode || 404).send({ error: (error as Error).message });
+ }
+ });
+ app.get<{ Params: { scanId: string } }>("/api/v1/scans/:scanId/sbom", async (req, reply) =>
+ reply
+ .header(
+ "Content-Disposition",
+ `attachment; filename="rippleguard-${req.params.scanId.replace(/[^a-zA-Z0-9-]/g, "")}.spdx.json"`,
+ )
+ .send(sbom(getScan(req.params.scanId, true))),
+ );
+ await app.register(async (webhook) => {
+ webhook.removeAllContentTypeParsers();
+ webhook.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) =>
+ done(null, body),
+ );
+ webhook.post("/api/v1/webhooks/github", async (req, reply) => {
+ const secret = process.env.GITHUB_WEBHOOK_SECRET;
+ if (!secret) return reply.code(503).send({ error: "Webhook is not configured." });
+ const raw = req.body as Buffer;
+ if (!verifySignature(raw, String(req.headers["x-hub-signature-256"] || ""), secret)) {
+ metrics.getMeter("rippleguard").createCounter("webhook_validation_failures").add(1);
+ return reply.code(401).send({ error: "Invalid webhook signature." });
+ }
+ const id = z.string().min(1).max(100).parse(req.headers["x-github-delivery"]);
+ const event = req.headers["x-github-event"];
+ if (event === "ping") return { received: true };
+ if (event !== "push") return reply.code(202).send({ ignored: true });
+ let body: any;
+ try {
+ body = JSON.parse(raw.toString("utf8"));
+ } catch {
+ return reply.code(400).send({ error: "Invalid JSON." });
+ }
+ const repo = canonicalRepository(z.string().parse(body.repository?.full_name));
+ if (
+ !(process.env.GITHUB_ALLOWED_REPOS || "")
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .includes(repo.toLowerCase())
+ )
+ return reply.code(403).send({ error: "Repository is not enabled for webhook scans." });
+ if (body.deleted) return reply.code(202).send({ ignored: true });
+ const sha = z
+ .string()
+ .regex(/^[a-f0-9]{40}$/)
+ .parse(body.after);
+ const installationId = z.number().int().positive().parse(body.installation?.id);
+ if (
+ !(process.env.GITHUB_INSTALLATION_IDS || "").split(",").map(Number).includes(installationId)
+ )
+ return reply.code(403).send({ error: "Installation is not allowed." });
// Delivery insertion and durable scan enqueue are one transaction.
- store.db.exec('BEGIN IMMEDIATE');
- try{if(!store.dedupe(id)){store.db.exec('COMMIT');return {duplicate:true};}
- const scan=jobs.create({mode:'github',repository:repo,ref:sha,installationId});store.db.exec('COMMIT');return reply.code(202).send({scanId:scan.id});
- }catch(error){store.db.exec('ROLLBACK');throw error;}
+ store.db.exec("BEGIN IMMEDIATE");
+ try {
+ if (!store.dedupe(id)) {
+ store.db.exec("COMMIT");
+ return { duplicate: true };
+ }
+ const scan = jobs.create({ mode: "github", repository: repo, ref: sha, installationId });
+ store.db.exec("COMMIT");
+ return reply.code(202).send({ scanId: scan.id });
+ } catch (error) {
+ store.db.exec("ROLLBACK");
+ throw error;
+ }
});
});
- if(existsSync(resolve('dist/web/index.html'))){await app.register(staticPlugin,{root:resolve('dist/web')});app.setNotFoundHandler((req,reply)=>req.url.startsWith('/api/')?reply.code(404).send({error:'Endpoint not found.'}):reply.sendFile('index.html'));}
- app.addHook('onClose',async()=>{await jobs.close();store.close();});
- return {app,jobs,store};
+ if (existsSync(resolve("dist/web/index.html"))) {
+ await app.register(staticPlugin, { root: resolve("dist/web") });
+ app.setNotFoundHandler((req, reply) =>
+ req.url.startsWith("/api/")
+ ? reply.code(404).send({ error: "Endpoint not found." })
+ : reply.sendFile("index.html"),
+ );
+ }
+ app.addHook("onClose", async () => {
+ await jobs.close();
+ store.close();
+ });
+ return { app, jobs, store };
}
diff --git a/src/server/connectors.ts b/src/server/connectors.ts
index 8c2d0fb..274e5c3 100644
--- a/src/server/connectors.ts
+++ b/src/server/connectors.ts
@@ -1,164 +1,550 @@
-import { XMLParser } from 'fast-xml-parser';
-import { importPKCS8, SignJWT } from 'jose';
-import { readFile } from 'node:fs/promises';
-import type { Advisory, Graph, PackageNode, Provenance } from '../shared/types.js';
-import type { HttpSource } from './transport.js';
-const encode=encodeURIComponent;
+import { XMLParser } from "fast-xml-parser";
+import { importPKCS8, SignJWT } from "jose";
+import { readFile } from "node:fs/promises";
+import type { Advisory, Graph, PackageNode, Provenance } from "../shared/types.js";
+import { discoverProjects, type RepositoryFile, type SourceFile } from "./discovery.js";
+import type { HttpSource } from "./transport.js";
+import { osvAdvisory } from "./advisories.js";
+const encode = encodeURIComponent;
export class Registries {
- constructor(private http:HttpSource) {}
- async enrich(n:PackageNode) {
- if(n.ecosystem==='npm') {
- const {data,provenance}=await this.http.get('npm',`https://registry.npmjs.org/${encode(n.name)}/${encode(n.version)}`);
- if(data.name!==n.name || data.version!==n.version) throw new Error('Registry identity mismatch.');
- n.license=typeof data.license==='string'?data.license:undefined;n.provenance.push(provenance);
- } else if(n.ecosystem==='pypi') {
- const {data,provenance}=await this.http.get('PyPI',`https://pypi.org/pypi/${encode(n.name)}/${encode(n.version)}/json`);
- n.license=data.info?.license_expression||undefined;n.provenance.push(provenance);
- for(const v of data.vulnerabilities||[]) if(!v.withdrawn) n.advisories.push({id:v.id,aliases:v.aliases||[],summary:v.summary||'PyPI reported advisory',fixed:v.fixed_in?.join(', '),provenance:[provenance,...(v.link?[{...provenance,url:v.link}]:[])]});
+ constructor(private http: HttpSource) {}
+ async enrich(n: PackageNode) {
+ if (n.ecosystem === "npm") {
+ const { data, provenance } = await this.http.get(
+ "npm",
+ `https://registry.npmjs.org/${encode(n.name)}/${encode(n.version)}`,
+ );
+ if (data.name !== n.name || data.version !== n.version)
+ throw new Error("Registry identity mismatch.");
+ n.license = typeof data.license === "string" ? data.license : undefined;
+ n.provenance.push(provenance);
+ } else if (n.ecosystem === "pypi") {
+ const { data, provenance } = await this.http.get(
+ "PyPI",
+ `https://pypi.org/pypi/${encode(n.name)}/${encode(n.version)}/json`,
+ );
+ n.license = data.info?.license_expression || undefined;
+ n.provenance.push(provenance);
+ for (const v of data.vulnerabilities || [])
+ if (!v.withdrawn)
+ n.advisories.push({
+ id: v.id,
+ aliases: v.aliases || [],
+ summary: v.summary || "PyPI reported advisory",
+ fixed: v.fixed_in?.join(", "),
+ provenance: [provenance, ...(v.link ? [{ ...provenance, url: v.link }] : [])],
+ });
} else {
- const [group,artifact]=n.name.split(':');
- const path=group.split('.').map(encode).join('/')+'/'+encode(artifact)+'/'+encode(n.version)+'/'+encode(artifact)+'-'+encode(n.version)+'.pom';
- const {data,provenance}=await this.http.get('Maven Central',`https://repo.maven.apache.org/maven2/${path}`);
- if(/(
+ "Maven Central",
+ `https://repo.maven.apache.org/maven2/${path}`,
+ );
+ if (/('OSV','https://api.osv.dev/v1/query',{method:'POST',body:{package:{purl:n.purl},...(pageToken?{page_token:pageToken}:{})}});
- for(const v of data.vulns||[]) {
- if(v.withdrawn) continue;
- const fixed=(v.affected||[]).flatMap((a:any)=>(a.ranges||[]).flatMap((r:any)=>(r.events||[]).filter((e:any)=>e.fixed).map((e:any)=>e.fixed)));
- n.advisories.push({id:v.id,aliases:v.aliases||[],summary:v.summary||v.details?.slice(0,350)||'Source-reported advisory',fixed:[...new Set(fixed)].join(', ')||undefined,provenance:[{...provenance,url:`https://osv.dev/vulnerability/${encode(v.id)}`} ]});
+ const { data, provenance } = await this.http.get("OSV", "https://api.osv.dev/v1/query", {
+ method: "POST",
+ body: { package: { purl: n.purl }, ...(pageToken ? { page_token: pageToken } : {}) },
+ interval: 25,
+ });
+ if (data.vulns !== undefined && !Array.isArray(data.vulns))
+ throw new Error("Invalid OSV response.");
+ for (const v of data.vulns || []) {
+ const advisory = osvAdvisory(v, n, provenance);
+ if (advisory) n.advisories.push(advisory);
}
- pageToken=data.next_page_token;if(++pages>=5 && pageToken) throw new Error('OSV pagination limit reached.');
- } while(pageToken);
- }
- async nvd(id:string):Promise<{cvss?:number;provenance:Provenance}> {
- if(!/^CVE-\d{4}-\d{4,}$/.test(id)) throw new Error('Invalid CVE identifier.');
- const {data,provenance}=await this.http.get('NVD',`https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encode(id)}`,{headers:process.env.NVD_API_KEY?{apiKey:process.env.NVD_API_KEY}:undefined,interval:process.env.NVD_API_KEY?700:6500,ttl:86400000});
- const cve=data.vulnerabilities?.find((v:any)=>v.cve?.id===id)?.cve;
- const scores=[...(cve?.metrics?.cvssMetricV40||[]),...(cve?.metrics?.cvssMetricV31||[]),...(cve?.metrics?.cvssMetricV30||[]),...(cve?.metrics?.cvssMetricV2||[])];
- const primary=scores.find((m:any)=>m.type==='Primary')||scores[0];
- const score=primary?.cvssData?.baseScore;
- return {cvss:typeof score==='number' && score>=0 && score<=10?score:undefined,provenance};
- }
- async cve(id:string) {
- if(!/^CVE-\d{4}-\d{4,}$/.test(id)) throw new Error('Invalid CVE identifier.');
- return this.http.get('CVE Program',`https://cveawg.mitre.org/api/cve/${encode(id)}`,{ttl:86400000});
- }
- async oss(nodes:PackageNode[]) {
- if(!process.env.OSS_INDEX_EMAIL || !process.env.OSS_INDEX_TOKEN) return;
- for(let i=0;i('OSS Index','https://ossindex.sonatype.org/api/v3/component-report',{method:'POST',body:{coordinates:batch.map(n=>n.purl)},headers:{Authorization:'Basic '+Buffer.from(process.env.OSS_INDEX_EMAIL+':'+process.env.OSS_INDEX_TOKEN).toString('base64')}});
- for(const record of data) {
- const n=batch.find(n=>n.purl===record.coordinates);if(!n)continue;
- for(const v of record.vulnerabilities||[]) n.advisories.push({id:v.cve||v.id,aliases:v.cve?[v.cve]:[],summary:v.title||'OSS Index advisory',cvss:typeof v.cvssScore==='number'?Math.max(0,Math.min(10,v.cvssScore)):undefined,provenance:[{...provenance,url:v.reference||provenance.url}]});
+ pageToken = data.next_page_token;
+ if (++pages >= 5 && pageToken) throw new Error("OSV pagination limit reached.");
+ } while (pageToken);
+ }
+ async nvd(id: string): Promise<{ cvss?: number; provenance: Provenance }> {
+ if (!/^CVE-\d{4}-\d{4,}$/.test(id)) throw new Error("Invalid CVE identifier.");
+ const { data, provenance } = await this.http.get(
+ "NVD",
+ `https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=${encode(id)}`,
+ {
+ headers: process.env.NVD_API_KEY ? { apiKey: process.env.NVD_API_KEY } : undefined,
+ interval: process.env.NVD_API_KEY ? 700 : 6500,
+ ttl: 86400000,
+ },
+ );
+ const cve = data.vulnerabilities?.find((v: any) => v.cve?.id === id)?.cve;
+ const scores = [
+ ...(cve?.metrics?.cvssMetricV40 || []),
+ ...(cve?.metrics?.cvssMetricV31 || []),
+ ...(cve?.metrics?.cvssMetricV30 || []),
+ ...(cve?.metrics?.cvssMetricV2 || []),
+ ];
+ const primary = scores.find((m: any) => m.type === "Primary") || scores[0];
+ const score = primary?.cvssData?.baseScore;
+ return {
+ cvss: typeof score === "number" && score >= 0 && score <= 10 ? score : undefined,
+ provenance,
+ };
+ }
+ async cve(id: string) {
+ if (!/^CVE-\d{4}-\d{4,}$/.test(id)) throw new Error("Invalid CVE identifier.");
+ return this.http.get("CVE Program", `https://cveawg.mitre.org/api/cve/${encode(id)}`, {
+ ttl: 86400000,
+ });
+ }
+ async oss(nodes: PackageNode[]) {
+ if (!process.env.OSS_INDEX_EMAIL || !process.env.OSS_INDEX_TOKEN) return;
+ for (let i = 0; i < nodes.length; i += 128) {
+ const batch = nodes.slice(i, i + 128);
+ const { data, provenance } = await this.http.get(
+ "OSS Index",
+ "https://ossindex.sonatype.org/api/v3/component-report",
+ {
+ method: "POST",
+ body: { coordinates: batch.map((n) => n.purl) },
+ headers: {
+ Authorization:
+ "Basic " +
+ Buffer.from(process.env.OSS_INDEX_EMAIL + ":" + process.env.OSS_INDEX_TOKEN).toString(
+ "base64",
+ ),
+ },
+ },
+ );
+ for (const record of data) {
+ const n = batch.find((n) => n.purl === record.coordinates);
+ if (!n) continue;
+ for (const v of record.vulnerabilities || [])
+ n.advisories.push({
+ id: v.cve || v.id,
+ aliases: v.cve ? [v.cve] : [],
+ summary: v.title || "OSS Index advisory",
+ cvss:
+ typeof v.cvssScore === "number" ? Math.max(0, Math.min(10, v.cvssScore)) : undefined,
+ provenance: [{ ...provenance, url: v.reference || provenance.url }],
+ });
}
}
}
/** Paginated incremental feed; consumers must map applicability independently. */
- async *nvdSince(start:string,end:string) {
- const delta=Date.parse(end)-Date.parse(start);
- if(!Number.isFinite(delta)||delta<0||delta>120*86400000)throw new Error('NVD sync window must be 0–120 days.');
- let index=0,total=1;
- while(index('NVD',`https://services.nvd.nist.gov/rest/json/cves/2.0?${query}`,{headers:process.env.NVD_API_KEY?{apiKey:process.env.NVD_API_KEY}:undefined,interval:process.env.NVD_API_KEY?700:6500});
- yield result;total=result.data.totalResults||0;index+=result.data.resultsPerPage||100;
+ async *nvdSince(start: string, end: string) {
+ const delta = Date.parse(end) - Date.parse(start);
+ if (!Number.isFinite(delta) || delta < 0 || delta > 120 * 86400000)
+ throw new Error("NVD sync window must be 0–120 days.");
+ let index = 0,
+ total = 1;
+ while (index < total) {
+ const query = new URLSearchParams({
+ lastModStartDate: start,
+ lastModEndDate: end,
+ startIndex: String(index),
+ resultsPerPage: "100",
+ });
+ const result = await this.http.get(
+ "NVD",
+ `https://services.nvd.nist.gov/rest/json/cves/2.0?${query}`,
+ {
+ headers: process.env.NVD_API_KEY ? { apiKey: process.env.NVD_API_KEY } : undefined,
+ interval: process.env.NVD_API_KEY ? 700 : 6500,
+ },
+ );
+ yield result;
+ total = result.data.totalResults || 0;
+ index += result.data.resultsPerPage || 100;
}
}
}
-export function mergeAdvisories(advisories:Advisory[]):Advisory[] {
- const groups:Advisory[]=[];
- for(const a of advisories) {
- const ids=new Set([a.id,...a.aliases]);
- const matches=groups.filter(b=>[b.id,...b.aliases].some(id=>ids.has(id)));
- if(!matches.length){groups.push({...a,aliases:[...a.aliases],provenance:[...a.provenance]});continue;}
- const target=matches[0];
- for(const item of [a,...matches.slice(1)]) {
- target.aliases=[...new Set([...target.aliases,item.id,...item.aliases])].filter(id=>id!==target.id);
- if(item.cvss!==undefined)target.cvss=Math.max(target.cvss||0,item.cvss);
- target.fixed ||= item.fixed;target.exploited ||= item.exploited;target.provenance.push(...item.provenance);
+export function mergeAdvisories(advisories: Advisory[]): Advisory[] {
+ const groups: Advisory[] = [];
+ for (const a of advisories) {
+ const ids = new Set([a.id, ...a.aliases]);
+ const matches = groups.filter((b) => [b.id, ...b.aliases].some((id) => ids.has(id)));
+ if (!matches.length) {
+ groups.push({ ...a, aliases: [...a.aliases], provenance: [...a.provenance] });
+ continue;
+ }
+ const target = matches[0];
+ for (const item of [a, ...matches.slice(1)]) {
+ target.aliases = [...new Set([...target.aliases, item.id, ...item.aliases])].filter(
+ (id) => id !== target.id,
+ );
+ if (item.cvss !== undefined) target.cvss = Math.max(target.cvss || 0, item.cvss);
+ target.fixed ||= item.fixed;
+ target.exploited ||= item.exploited;
+ target.provenance.push(...item.provenance);
}
- for(const duplicate of matches.slice(1)) groups.splice(groups.indexOf(duplicate),1);
+ for (const duplicate of matches.slice(1)) groups.splice(groups.indexOf(duplicate), 1);
}
return groups;
}
-export async function enrichGraph(graph:Graph,http:HttpSource,progress:()=>void) {
- const registries=new Registries(http);const vulnerabilities=new Vulnerabilities(http);
- const packages=graph.nodes.filter(n=>n.kind==='package');const limit=Number(process.env.MAX_ENRICH_PACKAGES||100);
- const selected=packages.slice(0,Math.min(500,limit));
- if(selected.length{
- while(cursor void,
+) {
+ const registries = new Registries(http);
+ const vulnerabilities = new Vulnerabilities(http);
+ const groups = new Map();
+ for (const n of graph.nodes.filter((n) => n.kind === "package"))
+ groups.set(n.purl, [...(groups.get(n.purl) || []), n]);
+ const packages = [...groups.values()].map((g) => g[0]);
+ const configured = Number(process.env.MAX_ENRICH_PACKAGES || 2000);
+ const limit = Number.isFinite(configured) ? Math.max(1, Math.min(10000, configured)) : 2000;
+ const selected = packages.slice(0, limit);
+ if (selected.length < packages.length)
+ graph.warnings.push(
+ `Checked ${selected.length} of ${packages.length} package versions due to the scan budget. Other packages remain unchecked.`,
+ );
+ let cursor = 0,
+ checked = 0,
+ consecutiveFailures = 0;
+ await Promise.all(
+ Array.from({ length: 6 }, async () => {
+ while (cursor < selected.length) {
+ const n = selected[cursor++];
+ if (consecutiveFailures >= 4) {
+ n.coverage = "partial";
+ continue;
+ }
+ try {
+ await vulnerabilities.osv(n);
+ n.coverage = "checked";
+ consecutiveFailures = 0;
+ } catch {
+ n.coverage = "partial";
+ consecutiveFailures++;
+ }
+ n.advisories = mergeAdvisories(n.advisories);
+ checked++;
+ progress(
+ checked,
+ packages.length,
+ `Checked ${checked} of ${packages.length} package versions`,
+ );
+ }
+ }),
+ );
+ const incomplete = selected.filter((n) => n.coverage !== "checked").length;
+ if (incomplete)
+ graph.warnings.push(
+ `Vulnerability coverage is incomplete for ${incomplete} packages because the source was unavailable or rate limited. These packages are not marked safe.`,
+ );
+ const findings = selected.filter((n) => n.advisories.length);
+ // Metadata is secondary to vulnerability coverage: enrich only packages with findings.
+ let registryFailures = 0;
+ for (const n of findings.slice(0, 20)) {
+ if (registryFailures >= 2) break;
+ try {
+ await registries.enrich(n);
+ } catch {
+ registryFailures++;
+ }
+ progress(checked, packages.length, `Adding source evidence for ${n.name}`);
+ }
+ try {
+ await vulnerabilities.oss(selected);
+ } catch {
+ graph.warnings.push("Optional OSS Index lookup failed.");
+ }
+ const cveIds = [
+ ...new Set(
+ findings
+ .flatMap((n) => n.advisories.flatMap((a) => [a.id, ...a.aliases]))
+ .filter((id) => /^CVE-\d{4}-\d{4,}$/.test(id)),
+ ),
+ ];
+ if (cveIds.length) {
+ try {
+ const result = await http.get(
+ "CISA KEV",
+ "https://raw.githubusercontent.com/cisagov/kev-data/main/known_exploited_vulnerabilities.json",
+ { ttl: 3600000 },
+ );
+ const data = typeof result.data === "string" ? JSON.parse(result.data) : result.data;
+ if (!Array.isArray(data.vulnerabilities)) throw new Error("Invalid CISA response");
+ const exploited = new Set(data.vulnerabilities.map((v: any) => v.cveID));
+ for (const n of findings)
+ for (const a of n.advisories)
+ if ([a.id, ...a.aliases].some((id) => exploited.has(id))) {
+ a.exploited = true;
+ a.provenance.push(result.provenance);
+ }
+ } catch {
+ graph.warnings.push(
+ "Known-exploitation evidence could not be checked. Absence of a signal is not evidence of no exploitation.",
+ );
+ }
+ }
+ // OSV CVSS vectors are already scored. NVD fills missing CVSS, bounded for demo latency.
+ const missing = [
+ ...new Set(
+ findings
+ .flatMap((n) =>
+ n.advisories.filter((a) => a.cvss === undefined).flatMap((a) => [a.id, ...a.aliases]),
+ )
+ .filter((id) => /^CVE-\d{4}-\d{4,}$/.test(id)),
+ ),
+ ];
+ const cap = Math.max(0, Math.min(20, Number(process.env.MAX_NVD_CVES || 3)));
+ let failures = 0;
+ for (const id of missing.slice(0, cap)) {
+ if (failures >= 2) break;
+ progress(checked, packages.length, `Looking up CVSS evidence for ${id}`);
+ try {
+ const result = await vulnerabilities.nvd(id);
+ for (const n of selected)
+ for (const a of n.advisories)
+ if (a.id === id || a.aliases.includes(id)) {
+ if (result.cvss !== undefined) a.cvss = result.cvss;
+ a.provenance.push(result.provenance);
+ }
+ } catch {
+ failures++;
+ }
+ }
+ if (missing.length > cap || failures)
+ graph.warnings.push(
+ "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound.",
+ );
+ for (const n of selected) {
+ n.advisories = mergeAdvisories(n.advisories);
+ for (const instance of groups.get(n.purl)!) {
+ instance.advisories = structuredClone(n.advisories);
+ instance.coverage = n.coverage;
+ if (n.license) instance.license = n.license;
+ instance.provenance.push(
+ ...n.provenance.filter(
+ (p) => !instance.provenance.some((old) => old.url === p.url && old.source === p.source),
+ ),
+ );
}
- }));
- try{await vulnerabilities.oss(selected);}catch{graph.warnings.push('Optional OSS Index lookup failed.');}
- const cveIds=[...new Set(selected.flatMap(n=>n.advisories.flatMap(a=>[a.id,...a.aliases])).filter(id=>/^CVE-\d{4}-\d{4,}$/.test(id)))];
- const cveLimit=Number(process.env.MAX_NVD_CVES||20);
- if(cveIds.length>cveLimit)graph.warnings.push(`NVD enrichment capped at ${cveLimit} CVEs; some severities remain unknown.`);
- for(const id of cveIds.slice(0,cveLimit)) {
- const relevant=selected.flatMap(n=>n.advisories).filter(a=>a.id===id||a.aliases.includes(id));
- try{const result=await vulnerabilities.nvd(id);for(const a of relevant){if(result.cvss!==undefined)a.cvss=Math.max(a.cvss||0,result.cvss);a.provenance.push(result.provenance);}}catch{graph.warnings.push(`NVD enrichment unavailable for ${id}.`);}
- try{const result=await vulnerabilities.cve(id);for(const a of relevant)a.provenance.push(result.provenance);}catch{graph.warnings.push(`Canonical CVE record unavailable for ${id}.`);}
- progress();
- }
- selected.forEach(n=>{n.advisories=mergeAdvisories(n.advisories);if(n.advisories.some(a=>a.cvss===undefined))n.coverage='partial';});
+ }
}
-export function canonicalRepository(value:string) {
- const match=value.match(/^(?:https:\/\/github\.com\/)?([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9_.-]{1,100}?)(?:\.git)?\/?$/);
- if(!match || ['.','..'].includes(match[2]))throw new Error('Use a canonical GitHub owner/repository or https://github.com/owner/repository URL.');
- return match[1]+'/'+match[2];
+export function canonicalRepository(value: string) {
+ const match = value.match(
+ /^(?:https:\/\/github\.com\/)?([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9_.-]{1,100}?)(?:\.git)?\/?$/,
+ );
+ if (!match || [".", ".."].includes(match[2]))
+ throw new Error(
+ "Use a canonical GitHub owner/repository or https://github.com/owner/repository URL.",
+ );
+ return match[1] + "/" + match[2];
}
export class GitHub {
- constructor(private http:HttpSource) {}
- async headers(installationId?:number):Promise> {
- const headers:Record={'X-GitHub-Api-Version':'2022-11-28','Accept':'application/vnd.github+json'};
- if(installationId) {
- const allowed=(process.env.GITHUB_INSTALLATION_IDS||'').split(',').map(Number);
- if(!allowed.includes(installationId))throw new Error('GitHub installation is not allowed.');
- if(!process.env.GITHUB_APP_ID || !process.env.GITHUB_APP_PRIVATE_KEY_PATH)throw new Error('GitHub App credentials are not configured.');
- const key=await importPKCS8(await readFile(process.env.GITHUB_APP_PRIVATE_KEY_PATH,'utf8'),'RS256');
- const jwt=await new SignJWT({}).setProtectedHeader({alg:'RS256'}).setIssuedAt(Math.floor(Date.now()/1000)-60).setExpirationTime('9m').setIssuer(process.env.GITHUB_APP_ID).sign(key);
- const {data}=await this.http.get('GitHub',`https://api.github.com/app/installations/${installationId}/access_tokens`,{method:'POST',headers:{...headers,Authorization:'Bearer '+jwt},body:{permissions:{contents:'read'}},ttl:0});
- headers.Authorization='Bearer '+data.token;
+ constructor(private http: HttpSource) {}
+ async headers(installationId?: number): Promise> {
+ const headers: Record = {
+ "X-GitHub-Api-Version": "2022-11-28",
+ Accept: "application/vnd.github+json",
+ };
+ if (installationId) {
+ const allowed = (process.env.GITHUB_INSTALLATION_IDS || "").split(",").map(Number);
+ if (!allowed.includes(installationId)) throw new Error("GitHub installation is not allowed.");
+ if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_APP_PRIVATE_KEY_PATH)
+ throw new Error("GitHub App credentials are not configured.");
+ const key = await importPKCS8(
+ await readFile(process.env.GITHUB_APP_PRIVATE_KEY_PATH, "utf8"),
+ "RS256",
+ );
+ const jwt = await new SignJWT({})
+ .setProtectedHeader({ alg: "RS256" })
+ .setIssuedAt(Math.floor(Date.now() / 1000) - 60)
+ .setExpirationTime("9m")
+ .setIssuer(process.env.GITHUB_APP_ID)
+ .sign(key);
+ const { data } = await this.http.get(
+ "GitHub",
+ `https://api.github.com/app/installations/${installationId}/access_tokens`,
+ {
+ method: "POST",
+ headers: { ...headers, Authorization: "Bearer " + jwt },
+ body: { permissions: { contents: "read" } },
+ ttl: 0,
+ },
+ );
+ headers.Authorization = "Bearer " + data.token;
}
return headers;
}
- async manifests(repository:string,ref:string,installationId?:number) {
- const repo=canonicalRepository(repository);const headers=await this.headers(installationId);
- const {data:commit}=await this.http.get('GitHub',`https://api.github.com/repos/${repo}/commits/${encode(ref)}`,{headers,ttl:60_000});
- if(!/^[a-f0-9]{40}$/.test(commit.sha))throw new Error('GitHub did not return a valid commit.');
- const {data:tree}=await this.http.get('GitHub',`https://api.github.com/repos/${repo}/git/trees/${commit.sha}?recursive=1`,{headers});
- if(tree.truncated)throw new Error('Repository tree exceeds GitHub API limits. Submit a manifest instead.');
- const {supportedFiles}=await import('./parsers.js');
- let paths=(tree.tree||[]).filter((f:any)=>f.type==='blob' && supportedFiles.includes(f.path.split('/').pop()) && !/(^|\/)(node_modules|vendor|\.git)\//.test(f.path));
- // Prefer one complete snapshot per directory and ecosystem.
- paths=paths.filter((f:any)=>!paths.some((other:any)=>other!==f && other.path.slice(0,other.path.lastIndexOf('/')+1)===f.path.slice(0,f.path.lastIndexOf('/')+1) && ((f.path.endsWith('package-lock.json') && other.path.endsWith('npm-shrinkwrap.json'))||(f.path.endsWith('pom.xml')&&other.path.endsWith('dependency-tree.json'))||(f.path.endsWith('requirements.txt')&&other.path.endsWith('Pipfile.lock')))));
- if(!paths.length)throw new Error('No supported resolved manifests found. Commit a lockfile or upload a supported manifest.');
- if(paths.length>12)throw new Error('More than 12 manifests found. Submit a scoped manifest for this V1 scan.');
- const files=[];
- for(const path of paths) {
- if(path.size>2_000_000)throw new Error('Repository manifest exceeds 2 MB.');
- const {data,provenance}=await this.http.get('GitHub',`https://api.github.com/repos/${repo}/git/blobs/${path.sha}`,{headers});
- if(data.encoding!=='base64')throw new Error('Unexpected GitHub blob encoding.');
- files.push({filename:path.path,content:Buffer.from(data.content,'base64').toString('utf8'),provenance:{...provenance,url:`https://github.com/${repo}/blob/${commit.sha}/${path.path}`}});
+ async discover(repository: string, ref: string, installationId?: number) {
+ const repo = canonicalRepository(repository);
+ const headers = await this.headers(installationId);
+ let commit: any;
+ let actualRef = ref;
+ try {
+ const metadata = await this.http.get("GitHub", `https://api.github.com/repos/${repo}`, {
+ headers,
+ ttl: 300_000,
+ });
+ if (ref === "HEAD") actualRef = metadata.data.default_branch;
+ commit = (
+ await this.http.get(
+ "GitHub",
+ `https://api.github.com/repos/${repo}/commits/${encode(actualRef)}`,
+ { headers, ttl: 60_000 },
+ )
+ ).data;
+ } catch (error) {
+ const message = (error as Error).message;
+ throw new Error(
+ message.includes("404")
+ ? "Repository or ref was not found. Check the owner/name and branch; private repositories need a configured GitHub App."
+ : message.includes("403") || message.includes("429")
+ ? "GitHub is rate limiting this connection. Try again later or configure the existing GitHub App."
+ : message,
+ );
+ }
+ if (!/^[a-f0-9]{40}$/.test(commit.sha))
+ throw new Error("GitHub did not return a valid commit.");
+ const { data: tree } = await this.http.get(
+ "GitHub",
+ `https://api.github.com/repos/${repo}/git/trees/${commit.sha}?recursive=1`,
+ { headers },
+ );
+ let files: RepositoryFile[] = tree.tree || [];
+ const warnings: string[] = [];
+ if (tree.truncated) {
+ files = [];
+ const queue = [{ sha: commit.sha, path: "" }];
+ for (let i = 0; i < queue.length; i++) {
+ try {
+ const { data } = await this.http.get(
+ "GitHub",
+ `https://api.github.com/repos/${repo}/git/trees/${queue[i].sha}`,
+ { headers },
+ );
+ for (const entry of data.tree || []) {
+ const path = queue[i].path + entry.path;
+ if (
+ entry.type === "tree" &&
+ !/(^|\/)(node_modules|vendor|\.git|\.venv)(\/|$)/.test(path)
+ )
+ queue.push({ sha: entry.sha, path: path + "/" });
+ else if (entry.type === "blob") files.push({ ...entry, path });
+ }
+ } catch {
+ warnings.push(
+ `Could not list ${queue[i].path || "repository root"}. Discovery is partial.`,
+ );
+ }
+ if (i >= 499) {
+ warnings.push(
+ "Large repository: discovery stopped after 500 directory requests. Discovered projects can still be analyzed.",
+ );
+ break;
+ }
+ }
+ }
+ const map = discoverProjects(files, repo, actualRef, commit.sha);
+ map.warnings.push(...warnings);
+ let cursor = 0;
+ await Promise.all(
+ Array.from({ length: 6 }, async () => {
+ while (cursor < map.projects.length) {
+ const project = map.projects[cursor++];
+ if (!project.manifest?.endsWith("package.json")) continue;
+ try {
+ const f = await this.file(
+ repo,
+ commit.sha,
+ files.find((f) => f.path === project.manifest)!,
+ installationId,
+ );
+ const manifest = JSON.parse(f.content);
+ if (typeof manifest.name === "string") project.name = manifest.name;
+ if (manifest.workspaces) {
+ project.role = "workspace";
+ project.notes.push("Workspace root; child packages are listed separately.");
+ if (!Object.keys(manifest.dependencies || {}).length) project.selected = false;
+ } else if (manifest.scripts?.start || manifest.scripts?.serve)
+ project.role = "application";
+ else if (
+ !Object.keys(manifest.dependencies || {}).length &&
+ Object.keys(manifest.devDependencies || {}).length
+ )
+ project.role = "tooling";
+ } catch {
+ project.notes.push("Project metadata could not be read; using its directory name.");
+ }
+ }
+ }),
+ );
+ return { map, files };
+ }
+ async file(
+ repository: string,
+ commit: string,
+ file: RepositoryFile,
+ installationId?: number,
+ ): Promise {
+ if (!file) throw new Error("Dependency file is missing from the pinned repository snapshot.");
+ if ((file.size || 0) > 8_000_000)
+ throw new Error(
+ "This dependency file is larger than the 8 MB analysis budget. Other projects can still be analyzed.",
+ );
+ if (installationId) {
+ const { data, provenance } = await this.http.get(
+ "GitHub",
+ `https://api.github.com/repos/${canonicalRepository(repository)}/git/blobs/${file.sha}`,
+ { headers: await this.headers(installationId) },
+ );
+ if (data.encoding !== "base64") throw new Error("Unexpected GitHub blob encoding.");
+ return {
+ filename: file.path,
+ content: Buffer.from(data.content, "base64").toString("utf8"),
+ provenance: {
+ ...provenance,
+ url: `https://github.com/${repository}/blob/${commit}/${file.path}`,
+ },
+ };
+ }
+ // Public content pinned to the verified commit avoids one API request per blob.
+ const path = file.path.split("/").map(encode).join("/");
+ const { data, provenance } = await this.http.get(
+ "GitHub",
+ `https://raw.githubusercontent.com/${canonicalRepository(repository)}/${commit}/${path}`,
+ { interval: 30 },
+ );
+ return {
+ filename: file.path,
+ content: typeof data === "string" ? data : JSON.stringify(data),
+ provenance: { ...provenance, url: `https://github.com/${repository}/blob/${commit}/${path}` },
+ };
+ }
+ async manifests(repository: string, ref: string, installationId?: number) {
+ const { map, files } = await this.discover(repository, ref, installationId);
+ const results: SourceFile[] = [];
+ for (const project of map.projects) {
+ const file = files.find((f) => f.path === (project.lockfile || project.manifest));
+ if (!file) continue;
+ try {
+ results.push(await this.file(repository, map.commit, file, installationId));
+ } catch (error) {
+ map.warnings.push(`${project.path}: ${(error as Error).message}`);
+ }
}
- return {files,commit:commit.sha};
+ return { files: results, commit: map.commit, warnings: map.warnings };
}
}
diff --git a/src/server/demo.ts b/src/server/demo.ts
index b3a3eaa..df1d87c 100644
--- a/src/server/demo.ts
+++ b/src/server/demo.ts
@@ -1,14 +1,105 @@
-import { node, finalize } from './graph.js';
-import type { Graph } from '../shared/types.js';
+import { node, finalize } from "./graph.js";
+import type { Graph } from "../shared/types.js";
export function demoGraph(): Graph {
- const origin={source:'RippleGuard demo fixture',url:'',retrievedAt:'2026-09-10T00:00:00.000Z',fixture:true};
+ const origin = {
+ source: "RippleGuard demo fixture",
+ url: "",
+ retrievedAt: "2026-09-10T00:00:00.000Z",
+ fixture: true,
+ };
const definitions = [
- ['npm','storefront','1.0.0','service'],['npm','checkout-api','1.0.0','service'],['pypi','recommendations','1.0.0','service'],['maven','demo:fulfillment','1.0.0','service'],
- ['npm','express','4.17.1'],['npm','axios','0.21.1'],['npm','lodash','4.17.20'],['npm','body-parser','1.19.0'],['npm','qs','6.7.0'],['npm','follow-redirects','1.14.0'],['npm','debug','2.6.9'],['npm','ms','2.0.0'],['npm','cookie','0.4.0'],['npm','react','18.2.0'],['npm','zod','3.22.4'],['pypi','requests','2.28.0'],['pypi','urllib3','1.26.5'],['pypi','certifi','2022.12.7'],['maven','org.apache.logging.log4j:log4j-core','2.14.1'],['maven','org.apache.logging.log4j:log4j-api','2.14.1'],['npm','mime-types','2.1.35'],['npm','mime-db','1.52.0'],['pypi','charset-normalizer','2.0.12'],['pypi','idna','3.3']
+ ["npm", "storefront", "1.0.0", "service"],
+ ["npm", "checkout-api", "1.0.0", "service"],
+ ["npm", "admin-console", "1.0.0", "service"],
+ ["maven", "demo:fulfillment", "1.0.0", "service"],
+ ["npm", "express", "4.17.1"],
+ ["npm", "axios", "0.21.1"],
+ ["npm", "lodash", "4.17.20"],
+ ["npm", "body-parser", "1.19.0"],
+ ["npm", "qs", "6.7.0"],
+ ["npm", "follow-redirects", "1.14.0"],
+ ["npm", "debug", "2.6.9"],
+ ["npm", "ms", "2.0.0"],
+ ["npm", "cookie", "0.4.0"],
+ ["npm", "react", "18.2.0"],
+ ["npm", "zod", "3.22.4"],
+ ["pypi", "requests", "2.28.0"],
+ ["pypi", "urllib3", "1.26.5"],
+ ["pypi", "certifi", "2022.12.7"],
+ ["maven", "org.apache.logging.log4j:log4j-core", "2.14.1"],
+ ["maven", "org.apache.logging.log4j:log4j-api", "2.14.1"],
+ ["npm", "mime-types", "2.1.35"],
+ ["npm", "mime-db", "1.52.0"],
+ ["pypi", "charset-normalizer", "2.0.12"],
+ ["pypi", "idna", "3.3"],
];
- const nodes=definitions.map(([eco,name,version,kind])=>{const n=node(eco as 'npm'|'pypi'|'maven',name,version,(kind||'package') as 'service'|'package');n.coverage='fixture';n.provenance=[origin];return n;});
- const pairs=[[0,4],[0,5],[0,6],[0,13],[0,14],[1,4],[1,5],[1,6],[1,18],[2,15],[2,6],[3,18],[3,19],[4,7],[4,8],[4,10],[4,12],[4,20],[5,9],[7,8],[7,10],[7,20],[10,11],[15,16],[15,17],[15,22],[15,23],[18,19],[20,21]];
- const fixtures:[[number,number,string,string],...[number,number,string,string][]]=[[6,7.4,'DEMO-001','Prototype pollution in a shared utility'],[5,7.5,'DEMO-002','Server-side request forgery'],[8,7.5,'DEMO-003','Unbounded parsing can exhaust resources'],[16,6.5,'DEMO-004','Sensitive headers exposed across redirects'],[18,10,'DEMO-005','Remote code execution in a logging component']];
- fixtures.forEach(([i,cvss,id,summary])=>nodes[i].advisories=[{id,aliases:[],summary,cvss,exploited:i===18,fixed:undefined,provenance:[origin]}]);
- return finalize({nodes,edges:pairs.map(([a,b])=>({from:nodes[a].id,to:nodes[b].id})),warnings:['Illustrative multi-service graph. DEMO advisories and scores are synthetic fixtures, not a current security assessment.']});
+ const nodes = definitions.map(([eco, name, version, kind]) => {
+ const n = node(
+ eco as "npm" | "pypi" | "maven",
+ name,
+ version,
+ (kind || "package") as "service" | "package",
+ );
+ n.coverage = "fixture";
+ n.provenance = [origin];
+ return n;
+ });
+ const pairs = [
+ [0, 4],
+ [0, 13],
+ [0, 14],
+ [1, 4],
+ [1, 18],
+ [2, 4],
+ [2, 5],
+ [2, 15],
+ [7, 6],
+ [9, 6],
+ [3, 18],
+ [3, 19],
+ [4, 7],
+ [4, 8],
+ [4, 10],
+ [4, 12],
+ [4, 20],
+ [5, 9],
+ [7, 8],
+ [7, 10],
+ [7, 20],
+ [10, 11],
+ [15, 16],
+ [15, 17],
+ [15, 22],
+ [15, 23],
+ [18, 19],
+ [20, 21],
+ ];
+ const fixtures: [[number, number, string, string], ...[number, number, string, string][]] = [
+ [6, 9.8, "DEMO-001", "Prototype pollution in a shared utility"],
+ [5, 7.5, "DEMO-002", "Server-side request forgery"],
+ [8, 7.5, "DEMO-003", "Unbounded parsing can exhaust resources"],
+ [16, 6.5, "DEMO-004", "Sensitive headers exposed across redirects"],
+ [18, 10, "DEMO-005", "Remote code execution in a logging component"],
+ ];
+ fixtures.forEach(
+ ([i, cvss, id, summary]) =>
+ (nodes[i].advisories = [
+ {
+ id,
+ aliases: [],
+ summary,
+ cvss,
+ exploited: i === 6,
+ fixed: undefined,
+ provenance: [origin],
+ },
+ ]),
+ );
+ return finalize({
+ nodes,
+ edges: pairs.map(([a, b]) => ({ from: nodes[a].id, to: nodes[b].id })),
+ warnings: [
+ "Illustrative multi-service graph. DEMO advisories and scores are synthetic fixtures, not a current security assessment.",
+ ],
+ });
}
diff --git a/src/server/discovery.ts b/src/server/discovery.ts
new file mode 100644
index 0000000..bad5e0f
--- /dev/null
+++ b/src/server/discovery.ts
@@ -0,0 +1,129 @@
+import { createHash } from "node:crypto";
+import { posix } from "node:path";
+import type { Ecosystem, Project, RepositoryMap } from "../shared/types.js";
+export interface RepositoryFile {
+ path: string;
+ sha: string;
+ size?: number;
+ type?: string;
+}
+export interface SourceFile {
+ filename: string;
+ content: string;
+ provenance: import("../shared/types.js").Provenance;
+}
+const ecosystemFiles: Record = {
+ npm: ["package.json", "npm-shrinkwrap.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"],
+ pypi: [
+ "pyproject.toml",
+ "Pipfile",
+ "Pipfile.lock",
+ "requirements.txt",
+ "requirements-dev.txt",
+ "uv.lock",
+ "poetry.lock",
+ "setup.py",
+ "setup.cfg",
+ ],
+ maven: ["pom.xml", "dependency-tree.json"],
+};
+export const relevantFile = (path: string) =>
+ Object.values(ecosystemFiles).flat().includes(posix.basename(path));
+export const projectId = (ecosystem: Ecosystem, path: string) =>
+ createHash("sha256")
+ .update(ecosystem + ":" + path)
+ .digest("hex")
+ .slice(0, 16);
+/** File discovery does not execute manifests. Related manifests/locks become one project. */
+export function discoverProjects(
+ files: RepositoryFile[],
+ repository: string,
+ ref: string,
+ commit: string,
+): RepositoryMap {
+ const groups = new Map();
+ for (const file of files) {
+ if (file.type && file.type !== "blob") continue;
+ if (/(^|\/)(node_modules|vendor|\.git|\.venv|venv|site-packages)\//.test(file.path)) continue;
+ const base = posix.basename(file.path);
+ const eco = (Object.keys(ecosystemFiles) as Ecosystem[]).find((e) =>
+ ecosystemFiles[e].includes(base),
+ );
+ if (!eco) continue;
+ const directory = posix.dirname(file.path);
+ const key = eco + ":" + directory;
+ const group = groups.get(key) || { path: directory, ecosystem: eco, files: [] };
+ group.files.push(file.path);
+ groups.set(key, group);
+ }
+ const projects: Project[] = [...groups.values()]
+ .map((group): Project => {
+ const find = (names: string[]) =>
+ names.map((n) => group.files.find((f) => posix.basename(f) === n)).find(Boolean);
+ const manifest = find(
+ group.ecosystem === "npm"
+ ? ["package.json"]
+ : group.ecosystem === "pypi"
+ ? ["pyproject.toml", "Pipfile", "requirements.txt", "setup.cfg", "setup.py"]
+ : ["pom.xml"],
+ );
+ const lockfile = find(
+ group.ecosystem === "npm"
+ ? ["npm-shrinkwrap.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"]
+ : group.ecosystem === "pypi"
+ ? ["uv.lock", "poetry.lock", "Pipfile.lock"]
+ : ["dependency-tree.json"],
+ );
+ const resolved =
+ !!lockfile && /package-lock.json|npm-shrinkwrap.json|dependency-tree.json/.test(lockfile);
+ const tooling =
+ /(^|\/)(test|tests|fixtures|examples|benchmarks|docs|scripts|tools)(\/|$)/.test(group.path);
+ return {
+ id: projectId(group.ecosystem, group.path),
+ name:
+ group.path === "."
+ ? repository.split("/").pop() || "Application"
+ : posix.basename(group.path),
+ path: group.path,
+ ecosystem: group.ecosystem,
+ files: group.files.sort(),
+ manifest,
+ lockfile,
+ role: tooling ? "tooling" : "project",
+ selected: !tooling,
+ resolution: resolved ? "resolved" : "partial",
+ notes: resolved
+ ? ["Lockfile found. Installed relationships will be validated during analysis."]
+ : ["Exact indirect dependency relationships may not be available."],
+ };
+ })
+ .sort((a, b) => a.path.localeCompare(b.path) || a.ecosystem.localeCompare(b.ecosystem));
+ // Shared lockfiles are candidates only; the importer validates workspace membership.
+ for (const project of projects)
+ if (project.ecosystem === "npm" && !project.lockfile) {
+ const ancestor = projects
+ .filter(
+ (p) =>
+ p.ecosystem === "npm" &&
+ p.lockfile &&
+ p.path !== project.path &&
+ (p.path === "." || project.path.startsWith(p.path + "/")),
+ )
+ .sort((a, b) => b.path.length - a.path.length)[0];
+ if (ancestor) {
+ project.lockfile = ancestor.lockfile;
+ project.notes = [
+ "Uses a parent lockfile if this package is recorded as a workspace; otherwise exact manifest pins only.",
+ ];
+ }
+ }
+ if (projects.length === 1) projects[0].selected = true;
+ return {
+ repository,
+ ref,
+ commit,
+ projects,
+ warnings: [],
+ fileCount: projects.reduce((sum, p) => sum + p.files.length, 0),
+ };
+}
diff --git a/src/server/graph.ts b/src/server/graph.ts
index c187360..7f80a1c 100644
--- a/src/server/graph.ts
+++ b/src/server/graph.ts
@@ -1,50 +1,268 @@
-import { PackageURL } from 'packageurl-js';
-import { createHash } from 'node:crypto';
-import type { Ecosystem, Graph, PackageNode, Risk, Simulation } from '../shared/types.js';
+import { PackageURL } from "packageurl-js";
+import { createHash } from "node:crypto";
+import type { Ecosystem, Graph, PackageNode, Risk, Simulation } from "../shared/types.js";
export function purl(ecosystem: Ecosystem, name: string, version: string) {
- if (ecosystem === 'pypi') name = name.toLowerCase().replace(/[-_.]+/g, '-');
- const split = ecosystem === 'maven' ? name.lastIndexOf(':') : name.startsWith('@') ? name.indexOf('/') : -1;
- return new PackageURL(ecosystem, split < 0 ? undefined : name.slice(0, split), split < 0 ? name : name.slice(split + 1), version, undefined, undefined).toString();
+ if (ecosystem === "pypi") name = name.toLowerCase().replace(/[-_.]+/g, "-");
+ const split =
+ ecosystem === "maven" ? name.lastIndexOf(":") : name.startsWith("@") ? name.indexOf("/") : -1;
+ return new PackageURL(
+ ecosystem,
+ split < 0 ? undefined : name.slice(0, split),
+ split < 0 ? name : name.slice(split + 1),
+ version,
+ undefined,
+ undefined,
+ ).toString();
}
-export function node(ecosystem: Ecosystem, name: string, version: string, kind: PackageNode['kind'] = 'package'): PackageNode {
+export function node(
+ ecosystem: Ecosystem,
+ name: string,
+ version: string,
+ kind: PackageNode["kind"] = "package",
+): PackageNode {
const identity = purl(ecosystem, name, version);
- return { id: createHash('sha256').update(identity).digest('hex').slice(0, 20), purl: identity, ecosystem, name, version, kind, scope: 'runtime', depth: null, advisories: [], provenance: [], coverage: 'unchecked' };
+ return {
+ id: createHash("sha256").update(identity).digest("hex").slice(0, 20),
+ purl: identity,
+ ecosystem,
+ name,
+ version,
+ kind,
+ scope: "runtime",
+ depth: null,
+ advisories: [],
+ provenance: [],
+ coverage: "unchecked",
+ };
}
export function finalize(graph: Graph): Graph {
- if (graph.nodes.length > 2000 || graph.edges.length > 12000) throw new Error('Graph exceeds V1 limit (2,000 nodes / 12,000 edges).');
- const nodes = new Map(graph.nodes.map(n => [n.id, n]));
- graph.nodes = [...nodes.values()];
- graph.edges = [...new Map(graph.edges.filter(e => nodes.has(e.from) && nodes.has(e.to) && e.from !== e.to).map(e => [e.from + ':' + e.to, e])).values()];
- const children = new Map();
- for (const e of graph.edges) children.set(e.from, [...(children.get(e.from) || []), e.to]);
- graph.nodes.forEach(n => { n.depth = null; });
- const queue = graph.nodes.filter(n => n.kind === 'service'); queue.forEach(n => { n.depth = 0; });
- for (let i = 0; i < queue.length; i++) for (const id of children.get(queue[i].id) || []) {
- const child = nodes.get(id)!;
- if (child.depth === null) { child.depth = queue[i].depth! + 1; queue.push(child); }
+ const nodes = new Map();
+ for (const n of graph.nodes) {
+ const prior = nodes.get(n.id);
+ if (!prior) nodes.set(n.id, n);
+ else {
+ if (n.scope === "runtime") prior.scope = "runtime";
+ prior.provenance = [
+ ...new Map(
+ [...prior.provenance, ...n.provenance].map((p) => [p.url + ":" + p.source, p]),
+ ).values(),
+ ];
+ }
}
+ graph.nodes = [...nodes.values()];
+ graph.edges = [
+ ...new Map(
+ graph.edges
+ .filter((e) => nodes.has(e.from) && nodes.has(e.to) && e.from !== e.to)
+ .map((e) => [
+ e.from +
+ ":" +
+ e.to +
+ ":" +
+ (e.projectId || "") +
+ ":" +
+ (e.scope || "") +
+ ":" +
+ (e.relationship || ""),
+ e,
+ ]),
+ ).values(),
+ ];
+ graph.warnings = [...new Set(graph.warnings)];
+ const children = new Map();
+ for (const e of graph.edges) children.set(e.from, [...(children.get(e.from) || []), e]);
+ graph.nodes.forEach((n) => {
+ n.depth = null;
+ });
+ const queue = graph.nodes.filter((n) => n.kind === "service");
+ queue.forEach((n) => {
+ n.depth = 0;
+ });
+ for (let i = 0; i < queue.length; i++)
+ for (const e of children.get(queue[i].id) || []) {
+ if (e.relationship === "listed") continue;
+ const child = nodes.get(e.to)!;
+ if (child.depth === null) {
+ child.depth = queue[i].depth! + 1;
+ queue.push(child);
+ }
+ }
+ const runtime = new Set(graph.nodes.filter((n) => n.kind === "service").map((n) => n.id));
+ const todo = [...runtime];
+ for (let i = 0; i < todo.length; i++)
+ for (const e of children.get(todo[i]) || []) {
+ if (e.scope === "development" || e.scope === "unknown" || e.relationship === "listed")
+ continue;
+ if (!runtime.has(e.to)) {
+ runtime.add(e.to);
+ todo.push(e.to);
+ }
+ }
+ graph.nodes.forEach((n) => {
+ if (n.kind === "package" && n.scope !== "unknown")
+ n.scope = runtime.has(n.id) ? "runtime" : "development";
+ });
return graph;
}
-export function simulate(graph: Graph, nodeId: string): Simulation {
- if (!graph.nodes.some(n => n.id === nodeId)) throw new Error('Package not found in this scan.');
- const parents = new Map();
- graph.edges.forEach(e => parents.set(e.to, [...(parents.get(e.to) || []), e.from]));
- const seen = new Set([nodeId]); const queue = [nodeId]; const paths = new Map([[nodeId, [nodeId]]]);
- for (let i = 0; i < queue.length; i++) for (const id of parents.get(queue[i]) || []) {
- if (!seen.has(id)) { seen.add(id); queue.push(id); paths.set(id, [id, ...paths.get(queue[i])!]); }
+function indexGraph(graph: Graph) {
+ const parents = new Map();
+ const byPurl = new Map();
+ graph.edges.forEach((e) => parents.set(e.to, [...(parents.get(e.to) || []), e]));
+ graph.nodes.forEach((n) => byPurl.set(n.purl, [...(byPurl.get(n.purl) || []), n.id]));
+ return {
+ parents,
+ byPurl,
+ nodes: new Map(graph.nodes.map((n) => [n.id, n])),
+ roots: graph.nodes.filter((n) => n.kind === "service"),
+ };
+}
+function trace(
+ graph: Graph,
+ nodeId: string,
+ index: ReturnType,
+ includePaths = true,
+): Simulation {
+ const selected = index.nodes.get(nodeId);
+ if (!selected) throw new Error("Package not found in this scan.");
+ const seeds = selected.kind === "service" ? [nodeId] : index.byPurl.get(selected.purl)!;
+ const seedSet = new Set(seeds);
+ const seen = new Set(seeds);
+ const queue = [...seeds];
+ const distances: Record = Object.fromEntries(seeds.map((id) => [id, 0]));
+ const shortest = new Map(seeds.map((id) => [id, [id]]));
+ for (let i = 0; i < queue.length; i++)
+ for (const e of index.parents.get(queue[i]) || []) {
+ if (!seen.has(e.from)) {
+ seen.add(e.from);
+ queue.push(e.from);
+ shortest.set(e.from, [e.from, ...shortest.get(queue[i])!]);
+ distances[e.from] = distances[queue[i]] + 1;
+ }
+ }
+ const services = index.roots
+ .filter((n) => seen.has(n.id) && !seedSet.has(n.id))
+ .map((n) => n.id)
+ .sort();
+ const affected = [...seen].filter((id) => !seedSet.has(id)).sort();
+ // All simple paths within a bounded search, not merely one path per service.
+ const paths: string[][] = [];
+ const signatures = new Set();
+ let truncated = false;
+ let steps = 0;
+ if (includePaths) {
+ const stack = seeds.map((id) => [id]);
+ while (stack.length) {
+ const path = stack.pop()!;
+ if (++steps > 20000 || paths.length >= 200) {
+ truncated = true;
+ break;
+ }
+ const first = path[0];
+ if (index.nodes.get(first)?.kind === "service" && !seedSet.has(first)) {
+ const key = path.join("/");
+ if (!signatures.has(key)) {
+ signatures.add(key);
+ paths.push(path);
+ }
+ continue;
+ }
+ for (const e of index.parents.get(first) || [])
+ if (!path.includes(e.from)) stack.push([e.from, ...path]);
+ }
+ // Guarantee at least one displayed shortest path for every affected service.
+ for (const id of services) if (!paths.some((p) => p[0] === id)) paths.push(shortest.get(id)!);
}
- const services = graph.nodes.filter(n => n.kind === 'service' && n.id !== nodeId && seen.has(n.id)).map(n => n.id);
- const affected = queue.slice(1).sort();
- return {nodeId, affected, services, edges: graph.edges.filter(e => seen.has(e.from) && seen.has(e.to)), impact: Math.round(100 * affected.length / Math.max(1, graph.nodes.length - 1)), paths: services.map(id => paths.get(id)!)};
+ const edges = graph.edges.filter((e) => seen.has(e.from) && seen.has(e.to));
+ return {
+ nodeId,
+ seedIds: seeds,
+ affected,
+ services,
+ edges,
+ impact: Math.round((100 * affected.length) / Math.max(1, graph.nodes.length - seeds.length)),
+ paths,
+ pathCount: paths.length,
+ pathsTruncated: truncated,
+ totalServices: index.roots.length,
+ serviceImpact: Math.round((100 * services.length) / Math.max(1, index.roots.length)),
+ distances,
+ hypothetical: !selected.advisories.length,
+ relationshipUncertain: edges.some((e) => e.relationship === "listed"),
+ };
+}
+export function simulate(graph: Graph, nodeId: string): Simulation {
+ return trace(graph, nodeId, indexGraph(graph));
}
export function risks(graph: Graph): Risk[] {
- return graph.nodes.filter(n => n.kind === 'package').map(n => {
- const ripple = simulate(graph, n.id);
- const severity = Math.max(0, ...n.advisories.map(a => a.cvss || 0)) * 6;
- const exploit = n.advisories.some(a => a.exploited === true) ? 15 : 0;
- const exposure = n.depth === null ? 0 : Math.round(10 / Math.max(1, n.depth) * (n.scope === 'development' ? 0.5 : 1));
- const blastRadius = Math.round(15 * ripple.affected.length / Math.max(1, graph.nodes.length - 1));
- const score = Math.min(100, Math.round(severity + exploit + exposure + blastRadius));
- return {nodeId:n.id, score, level: score >= 80 ? 'critical' as const : score >= 60 ? 'high' as const : score >= 35 ? 'medium' as const : 'low' as const, factors:{severity:Math.round(severity * 10) / 10, exploit, exposure, blastRadius}, ancestors:ripple.affected.length, services:ripple.services, advisories:n.advisories.length, coverage:n.coverage};
- }).sort((a,b) => b.score - a.score || a.nodeId.localeCompare(b.nodeId));
+ const index = indexGraph(graph);
+ const unique = [
+ ...new Map(graph.nodes.filter((n) => n.kind === "package").map((n) => [n.purl, n])).values(),
+ ];
+ return unique
+ .map((n) => {
+ const ripple = trace(graph, n.id, index, false);
+ const instances = index.byPurl.get(n.purl)!.map((id) => index.nodes.get(id)!);
+ const advisories = instances.flatMap((n) => n.advisories);
+ const severity = Math.max(0, ...advisories.map((a) => a.cvss || 0)) * 6;
+ const exploit = advisories.some((a) => a.exploited === true) ? 15 : 0;
+ const knownDepths = instances.map((n) => n.depth).filter((d): d is number => d !== null);
+ const depth = knownDepths.length ? Math.min(...knownDepths) : null;
+ const runtime = instances.some((n) => n.scope === "runtime");
+ const devOnly = instances.every((n) => n.scope === "development");
+ const exposure =
+ depth === null ? 0 : Math.round((10 / Math.max(1, depth)) * (devOnly ? 0.5 : 1));
+ const blastRadius = Math.round(
+ (12 * ripple.services.length) / Math.max(1, index.roots.length) +
+ (3 * ripple.affected.length) / Math.max(1, graph.nodes.length - instances.length),
+ );
+ const score = Math.min(100, Math.round(severity + exploit + exposure + blastRadius));
+ const cvss = Math.max(0, ...advisories.map((a) => a.cvss || 0));
+ const reasons = [
+ advisories.length
+ ? cvss >= 9
+ ? "Critical vulnerability"
+ : cvss >= 7
+ ? "High severity vulnerability"
+ : cvss
+ ? "Known vulnerability"
+ : "Known advisory; severity not scored"
+ : "No known advisory in checked sources",
+ ...(exploit ? ["Known exploitation reported by a source"] : []),
+ depth === null
+ ? "Exact dependency depth is unknown"
+ : depth === 1
+ ? "Direct dependency"
+ : `Hidden ${depth} levels deep`,
+ runtime
+ ? "Used at runtime"
+ : devOnly
+ ? "Development tooling only"
+ : "Runtime/development use not fully determined",
+ `Used by ${ripple.services.length} of ${index.roots.length} projects`,
+ ];
+ return {
+ nodeId: n.id,
+ score,
+ level:
+ score >= 80
+ ? ("critical" as const)
+ : score >= 60
+ ? ("high" as const)
+ : score >= 35
+ ? ("medium" as const)
+ : ("low" as const),
+ factors: { severity: Math.round(severity * 10) / 10, exploit, exposure, blastRadius },
+ ancestors: ripple.affected.length,
+ services: ripple.services,
+ advisories: new Set(advisories.map((a) => a.id)).size,
+ coverage: n.coverage,
+ reasons,
+ };
+ })
+ .sort(
+ (a, b) =>
+ Number(b.advisories > 0) - Number(a.advisories > 0) ||
+ b.score - a.score ||
+ a.nodeId.localeCompare(b.nodeId),
+ );
}
diff --git a/src/server/index.ts b/src/server/index.ts
index 47380e3..2aa5112 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1,5 +1,10 @@
-import { telemetry } from './telemetry.js';
-const {createApp}=await import('./app.js');
-const {app}=await createApp();
-await app.listen({port:Number(process.env.PORT||3000),host:process.env.HOST||'127.0.0.1'});
-for(const signal of ['SIGINT','SIGTERM'])process.on(signal,async()=>{await app.close();await telemetry?.shutdown();process.exit(0);});
+import { telemetry } from "./telemetry.js";
+const { createApp } = await import("./app.js");
+const { app } = await createApp();
+await app.listen({ port: Number(process.env.PORT || 3000), host: process.env.HOST || "127.0.0.1" });
+for (const signal of ["SIGINT", "SIGTERM"])
+ process.on(signal, async () => {
+ await app.close();
+ await telemetry?.shutdown();
+ process.exit(0);
+ });
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 3d1629f..65e5a73 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -1,49 +1,323 @@
-import { randomUUID } from 'node:crypto';
-import { metrics, trace } from '@opentelemetry/api';
-import { Store } from './store.js';
-import { Transport } from './transport.js';
-import { GitHub, canonicalRepository, enrichGraph } from './connectors.js';
-import { parseManifest } from './parsers.js';
-import { demoGraph } from './demo.js';
-import { finalize, risks } from './graph.js';
-import type { Scan, ScanInput, ConnectorHealth } from '../shared/types.js';
-const meter=metrics.getMeter('rippleguard');const duration=meter.createHistogram('scan_duration',{unit:'ms'});const errors=meter.createCounter('scan_failures');
-export const connectors=():ConnectorHealth[]=>[
- ...['GitHub','npm','PyPI','Maven Central','OSV','NVD','CVE Program'].map(name=>({name,status:'ready' as const,message:'Not queried in this scan'})),
- {name:'OSS Index',status:process.env.OSS_INDEX_EMAIL && process.env.OSS_INDEX_TOKEN?'ready':'disabled',message:'Optional; requires server credentials'}
+import { randomUUID } from "node:crypto";
+import { metrics } from "@opentelemetry/api";
+import { Store } from "./store.js";
+import { Transport } from "./transport.js";
+import { GitHub, canonicalRepository, enrichGraph } from "./connectors.js";
+import { demoGraph } from "./demo.js";
+import { finalize, risks } from "./graph.js";
+import { discoverProjects, type SourceFile } from "./discovery.js";
+import { resolveProject } from "./resolve-project.js";
+import type {
+ Scan,
+ ScanInput,
+ ConnectorHealth,
+ ProjectSelection,
+ ScanProgress,
+ Graph,
+} from "../shared/types.js";
+export const connectors = (): ConnectorHealth[] => [
+ ...["GitHub", "npm", "PyPI", "Maven Central", "OSV", "NVD", "CVE Program", "CISA KEV"].map(
+ (name) => ({ name, status: "ready" as const, message: "Not queried in this scan" }),
+ ),
+ {
+ name: "OSS Index",
+ status: process.env.OSS_INDEX_EMAIL && process.env.OSS_INDEX_TOKEN ? "ready" : "disabled",
+ message: "Optional; requires server credentials",
+ },
];
export class Jobs {
- private running=false;private stopped=false;private current?:Promise;
- constructor(readonly store:Store) {}
- create(input:ScanInput):Scan {
- if(this.store.pending().length>=20)throw new Error('Scan queue is full. Try again after pending scans finish.');
- const scan:Scan={id:randomUUID(),status:'queued',name:input.mode==='demo'?'acme / commerce-platform':input.mode==='github'?canonicalRepository(input.repository!):input.filename!,ref:input.ref||'HEAD',mode:input.mode,createdAt:new Date().toISOString(),connectors:connectors()};
- this.store.save(scan,input);this.kick();return scan;
+ private running = false;
+ private stopped = false;
+ private current?: Promise;
+ constructor(
+ readonly store: Store,
+ private httpFactory = () => new Transport(store),
+ ) {}
+ create(input: ScanInput): Scan {
+ if (this.store.pending().length >= 20)
+ throw new Error("Scan queue is full. Try again after pending scans finish.");
+ const scan: Scan = {
+ id: randomUUID(),
+ status: "queued",
+ name:
+ input.mode === "demo"
+ ? "acme/commerce-platform"
+ : input.mode === "github"
+ ? canonicalRepository(input.repository!)
+ : input.filename!,
+ ref: input.ref || "HEAD",
+ mode: input.mode,
+ createdAt: new Date().toISOString(),
+ connectors: connectors(),
+ progress: { stage: "discover", message: "Reading repository structure", completed: [] },
+ };
+ this.store.save(scan, input);
+ this.kick();
+ return scan;
}
- kick(){if(!this.running&&!this.stopped){this.current=this.drain();}}
- async close(){this.stopped=true;await this.current;}
- async drain(){
- this.running=true;
- try{while(!this.stopped){const scan=this.store.pending().at(-1);if(!scan)break;await this.run(scan);}}
- finally{this.running=false;}
+ analyze(id: string, selections: ProjectSelection[]) {
+ const scan = this.store.get(id);
+ if (!scan) throw new Error("Scan not found.");
+ if (scan.status !== "mapped")
+ throw new Error("Project selection is only available after discovery and before analysis.");
+ if (!selections.length) throw new Error("Select at least one project to analyze.");
+ if (
+ new Set(selections.map((s) => s.id)).size !== selections.length ||
+ selections.some((s) => !scan.repositoryMap!.projects.some((p) => p.id === s.id))
+ )
+ throw new Error("The selection contains an unknown or duplicate project.");
+ scan.repositoryMap!.projects.forEach((p) => {
+ const chosen = selections.find((s) => s.id === p.id);
+ p.selected = !!chosen;
+ if (chosen) p.name = chosen.name.trim() || p.name;
+ });
+ scan.analysisRequested = true;
+ scan.status = "queued";
+ this.stage(scan, "extract", "Waiting to extract selected projects");
+ this.store.save(scan);
+ this.kick();
+ return scan;
}
- private async run(scan:Scan){
- const started=Date.now();const span=trace.getTracer('rippleguard').startSpan('scan');const http=new Transport(this.store);
- const persist=()=>{scan.connectors=connectors().map(c=>http.health.get(c.name)||c);this.store.save(scan);};
+ kick() {
+ if (!this.running && !this.stopped) this.current = this.drain();
+ }
+ async close() {
+ this.stopped = true;
+ await this.current;
+ }
+ async drain() {
+ this.running = true;
+ try {
+ while (!this.stopped) {
+ const scan = this.store.pending().at(-1);
+ if (!scan) break;
+ await this.run(scan);
+ }
+ } finally {
+ this.running = false;
+ }
+ }
+ private stage(
+ scan: Scan,
+ stage: ScanProgress["stage"],
+ message: string,
+ current?: number,
+ total?: number,
+ ) {
+ const completed = scan.progress?.completed || [];
+ if (scan.progress && scan.progress.stage !== stage && !completed.includes(scan.progress.stage))
+ completed.push(scan.progress.stage);
+ scan.progress = { stage, message, current, total, completed };
+ this.store.save(scan);
+ }
+ private async run(scan: Scan) {
+ const http = this.httpFactory();
+ const github = new GitHub(http);
+ const input = this.store.input(scan.id);
+ const persist = () => {
+ scan.connectors = connectors().map((c) => http.health.get(c.name) || c);
+ this.store.save(scan);
+ };
try {
- const input=this.store.input(scan.id);scan.status='scanning';persist();
- if(input.mode==='demo') {
- scan.graph=demoGraph();scan.connectors=connectors().map(c=>({...c,status:'fixture',message:'Demo is offline; connector not queried'}));
- } else if(input.mode==='manifest') scan.graph=parseManifest(input.filename!,input.content!);
- else {
- const result=await new GitHub(http).manifests(input.repository!,input.ref||'HEAD',input.installationId);scan.commit=result.commit;
- const graphs=result.files.map(f=>parseManifest(f.filename,f.content,f.provenance));
- scan.graph=finalize({nodes:graphs.flatMap(g=>g.nodes),edges:graphs.flatMap(g=>g.edges),warnings:graphs.flatMap(g=>g.warnings)});
+ if (!scan.analysisRequested) {
+ scan.status = "discovering";
+ this.stage(scan, "discover", "Finding projects and grouping dependency files");
+ if (input.mode === "github") {
+ const { map, files } = await github.discover(
+ input.repository!,
+ input.ref || "HEAD",
+ input.installationId,
+ );
+ scan.repositoryMap = map;
+ scan.name = map.repository;
+ scan.ref = map.ref;
+ scan.commit = map.commit;
+ this.store.saveFiles(scan.id, files);
+ } else if (input.mode === "demo") {
+ const graph = demoGraph();
+ scan.repositoryMap = {
+ repository: scan.name,
+ ref: "demo",
+ commit: "offline-fixture",
+ fileCount: 8,
+ warnings: ["Synthetic demo security data. No external APIs are used."],
+ projects: graph.nodes
+ .filter((n) => n.kind === "service")
+ .map((n) => ({
+ id: n.id,
+ name: n.name,
+ path: "apps/" + n.name.split(":").pop(),
+ ecosystem: n.ecosystem,
+ manifest:
+ n.ecosystem === "npm"
+ ? "package.json"
+ : n.ecosystem === "pypi"
+ ? "requirements.txt"
+ : "pom.xml",
+ lockfile: n.ecosystem === "npm" ? "package-lock.json" : undefined,
+ files: [],
+ selected: true,
+ role: "application",
+ resolution: "resolved",
+ notes: ["Illustrative application and dependency graph."],
+ })),
+ };
+ } else
+ scan.repositoryMap = discoverProjects(
+ [{ path: input.filename!, sha: "" }],
+ input.filename!,
+ "upload",
+ "uploaded-file",
+ );
+ scan.status = "mapped";
+ this.stage(
+ scan,
+ "map",
+ scan.repositoryMap.projects.length
+ ? `Found ${scan.repositoryMap.projects.length} projects. Choose what to analyze.`
+ : "No supported dependency files were found. Try a repository with npm, Python or Maven dependency files.",
+ );
+ persist();
+ return;
+ }
+ scan.status = "scanning";
+ const chosen = scan.repositoryMap!.projects.filter((p) => p.selected);
+ const graph: Graph = { nodes: [], edges: [], warnings: [...scan.repositoryMap!.warnings] };
+ this.stage(
+ scan,
+ "extract",
+ "Extracting dependencies from selected projects",
+ 0,
+ chosen.length,
+ );
+ if (input.mode === "demo") {
+ const original = demoGraph();
+ const included = new Set(chosen.map((p) => p.id));
+ const queue = [...included];
+ for (let i = 0; i < queue.length; i++)
+ for (const e of original.edges.filter((e) => e.from === queue[i]))
+ if (!included.has(e.to)) {
+ included.add(e.to);
+ queue.push(e.to);
+ }
+ graph.nodes = original.nodes.filter((n) => included.has(n.id));
+ graph.nodes.forEach((n) => {
+ const project = chosen.find((p) => p.id === n.id);
+ if (project) {
+ n.name = project.name;
+ n.projectId = project.id;
+ }
+ });
+ graph.edges = original.edges.filter((e) => included.has(e.from) && included.has(e.to));
+ graph.warnings.push(...original.warnings);
+ chosen.forEach((p) => {
+ p.analyzed = true;
+ });
+ } else {
+ const tree = this.store.files(scan.id);
+ const loaded = new Map();
+ for (let i = 0; i < chosen.length; i++) {
+ const project = chosen[i];
+ this.stage(scan, "extract", `Reading ${project.name}`, i, chosen.length);
+ try {
+ const paths = [
+ ...new Set([project.manifest, project.lockfile].filter((p): p is string => !!p)),
+ ];
+ const files: SourceFile[] = [];
+ for (const path of paths) {
+ try {
+ if (!loaded.has(path))
+ loaded.set(
+ path,
+ input.mode === "manifest"
+ ? {
+ filename: path,
+ content: input.content!,
+ provenance: {
+ source: "Uploaded manifest",
+ url: "",
+ retrievedAt: scan.createdAt,
+ },
+ }
+ : await github.file(
+ input.repository!,
+ scan.commit!,
+ tree.find((f) => f.path === path)!,
+ input.installationId,
+ ),
+ );
+ files.push(loaded.get(path)!);
+ } catch (error) {
+ project.notes.push((error as Error).message);
+ }
+ }
+ const g = resolveProject(project, files);
+ graph.nodes.push(...g.nodes);
+ graph.edges.push(...g.edges);
+ graph.warnings.push(...g.warnings.map((w) => `${project.name}: ${w}`));
+ project.notes = [...new Set([...project.notes, ...g.warnings])];
+ project.resolution = g.warnings.length ? "partial" : "resolved";
+ project.packageCount = g.nodes.filter((n) => n.kind === "package").length;
+ project.analyzed = true;
+ } catch (error) {
+ project.resolution = "failed";
+ project.notes.push((error as Error).message);
+ graph.warnings.push(
+ `${project.name}: ${(error as Error).message}. Other projects were retained.`,
+ );
+ }
+ this.stage(
+ scan,
+ "extract",
+ `Read ${i + 1} of ${chosen.length} projects`,
+ i + 1,
+ chosen.length,
+ );
+ }
+ }
+ this.stage(scan, "resolve", "Resolving dependency relationships");
+ scan.graph = finalize(graph);
+ persist();
+ if (input.mode !== "demo") {
+ scan.status = "enriching";
+ this.stage(
+ scan,
+ "vulnerabilities",
+ "Checking known vulnerabilities",
+ 0,
+ graph.nodes.filter((n) => n.kind === "package").length,
+ );
+ await enrichGraph(scan.graph, http, (current, total, message) => {
+ this.stage(
+ scan,
+ "vulnerabilities",
+ message || "Checking known vulnerabilities",
+ current,
+ total,
+ );
+ persist();
+ });
+ persist();
+ } else {
+ this.stage(scan, "vulnerabilities", "Loaded labeled synthetic advisory fixtures");
+ scan.connectors = connectors().map((c) => ({
+ ...c,
+ status: "fixture",
+ message: "Offline demo; no source was queried",
+ }));
}
- if(input.mode!=='demo'){scan.status='enriching';persist();await enrichGraph(scan.graph,http,persist);persist();}
- scan.risks=risks(scan.graph);scan.status='completed';scan.completedAt=new Date().toISOString();
- meter.createHistogram('graph_nodes').record(scan.graph.nodes.length);meter.createHistogram('graph_edges').record(scan.graph.edges.length);
- } catch(error){scan.status='failed';scan.error=error instanceof Error?error.message:'Scan failed.';errors.add(1);}
- finally{this.store.save(scan);duration.record(Date.now()-started,{mode:scan.mode,status:scan.status});span.end();}
+ this.stage(scan, "risk", "Calculating explainable Ripple Priority");
+ scan.risks = risks(scan.graph);
+ this.stage(scan, "ripple", "Preparing dependency paths to affected applications");
+ scan.status = "completed";
+ scan.completedAt = new Date().toISOString();
+ this.stage(scan, "done", "Analysis complete");
+ } catch (error) {
+ scan.status = "failed";
+ scan.error = (error as Error).message;
+ metrics.getMeter("rippleguard").createCounter("scan_failures").add(1);
+ } finally {
+ this.store.save(scan);
+ }
}
}
diff --git a/src/server/parsers.ts b/src/server/parsers.ts
index 3e90e01..7937679 100644
--- a/src/server/parsers.ts
+++ b/src/server/parsers.ts
@@ -1,87 +1,201 @@
-import { XMLParser } from 'fast-xml-parser';
-import { node, finalize } from './graph.js';
-import type { Ecosystem, Graph, PackageNode, Provenance } from '../shared/types.js';
-const MAX_FILE = 2_000_000;
-const exact = (v: unknown): v is string => typeof v === 'string' && /^[0-9][0-9A-Za-z.!+_\-]*$/.test(v);
-export const supportedFiles = ['npm-shrinkwrap.json','package-lock.json','Pipfile.lock','requirements.txt','dependency-tree.json','pom.xml'];
+import { XMLParser } from "fast-xml-parser";
+import { node, finalize } from "./graph.js";
+import type { Ecosystem, Graph, PackageNode, Provenance } from "../shared/types.js";
+const MAX_FILE = 8_000_000;
+const exact = (v: unknown): v is string =>
+ typeof v === "string" && /^[0-9][0-9A-Za-z.!+_\-]*$/.test(v);
+export const supportedFiles = [
+ "npm-shrinkwrap.json",
+ "package-lock.json",
+ "Pipfile.lock",
+ "requirements.txt",
+ "dependency-tree.json",
+ "pom.xml",
+];
export function parseManifest(filename: string, content: string, source?: Provenance): Graph {
- if(Buffer.byteLength(content) > MAX_FILE) throw new Error('Manifest exceeds 2 MB.');
- const file = filename.split('/').pop()!;
- const graph: Graph = {nodes:[], edges:[], warnings:[]};
- const origin = source || {source:filename,url:'',retrievedAt:new Date().toISOString()};
- function add(eco:Ecosystem,name:string,version:string,kind:PackageNode['kind']='package') {
- if(!exact(version)) throw new Error(`Unresolved version for ${name}. Supply an exact lockfile or resolved dependency tree.`);
- const n=node(eco,name,version,kind); n.provenance=[origin]; graph.nodes.push(n); return n;
+ if (Buffer.byteLength(content) > MAX_FILE) throw new Error("Manifest exceeds 2 MB.");
+ const file = filename.split("/").pop()!;
+ const graph: Graph = { nodes: [], edges: [], warnings: [] };
+ const origin = source || { source: filename, url: "", retrievedAt: new Date().toISOString() };
+ function add(
+ eco: Ecosystem,
+ name: string,
+ version: string,
+ kind: PackageNode["kind"] = "package",
+ ) {
+ if (!exact(version))
+ throw new Error(
+ `Unresolved version for ${name}. Supply an exact lockfile or resolved dependency tree.`,
+ );
+ const n = node(eco, name, version, kind);
+ n.provenance = [origin];
+ graph.nodes.push(n);
+ return n;
}
- if(file === 'package-lock.json' || file === 'npm-shrinkwrap.json') {
+ if (file === "package-lock.json" || file === "npm-shrinkwrap.json") {
const data = JSON.parse(content);
- if(![2,3].includes(data.lockfileVersion) || !data.packages?.['']) throw new Error('Use npm package-lock v2 or v3 with a packages map.');
- const entries = data.packages as Record;devDependencies?:Record;optionalDependencies?:Record;peerDependencies?:Record}>;
- const byPath=new Map();
- for(const [path,info] of Object.entries(entries)) {
- if(info.link) {graph.warnings.push(`Workspace link ${path} was not resolved; graph coverage is partial.`);continue;}
- const name=info.name || (path ? path.split('node_modules/').pop()! : data.name || 'application');
- if(!info.version && path) {graph.warnings.push(`Unresolved dependency ${name} omitted.`);continue;}
- const n=add('npm',name,info.version || data.version || '0.0.0',path ? 'package':'service');
- n.scope=info.dev ? 'development':'runtime';n.license=info.license;byPath.set(path,n);
+ if (![2, 3].includes(data.lockfileVersion) || !data.packages?.[""])
+ throw new Error("Use npm package-lock v2 or v3 with a packages map.");
+ const entries = data.packages as Record<
+ string,
+ {
+ name?: string;
+ version?: string;
+ link?: boolean;
+ dev?: boolean;
+ license?: string;
+ dependencies?: Record;
+ devDependencies?: Record;
+ optionalDependencies?: Record;
+ peerDependencies?: Record;
+ }
+ >;
+ const byPath = new Map();
+ for (const [path, info] of Object.entries(entries)) {
+ if (info.link) {
+ graph.warnings.push(`Workspace link ${path} was not resolved; graph coverage is partial.`);
+ continue;
+ }
+ const name =
+ info.name || (path ? path.split("node_modules/").pop()! : data.name || "application");
+ if (!info.version && path) {
+ graph.warnings.push(`Unresolved dependency ${name} omitted.`);
+ continue;
+ }
+ const n = add(
+ "npm",
+ name,
+ info.version || data.version || "0.0.0",
+ path ? "package" : "service",
+ );
+ n.scope = info.dev ? "development" : "runtime";
+ n.license = info.license;
+ byPath.set(path, n);
}
- function resolve(from:string,name:string) {
- let path=from;
- while(true) {
- const found=byPath.get((path ? path+'/':'')+'node_modules/'+name);
- if(found) return found;
- if(!path) return undefined;
- const index=path.lastIndexOf('/node_modules/'); path=index<0 ? '' : path.slice(0,index);
+ function resolve(from: string, name: string) {
+ let path = from;
+ while (true) {
+ const found = byPath.get((path ? path + "/" : "") + "node_modules/" + name);
+ if (found) return found;
+ if (!path) return undefined;
+ const index = path.lastIndexOf("/node_modules/");
+ path = index < 0 ? "" : path.slice(0, index);
}
}
- for(const [path,info] of Object.entries(entries)) {
- const parent=byPath.get(path);if(!parent) continue;
- const dependencies={...info.dependencies,...info.optionalDependencies,...info.peerDependencies,...(!path ? info.devDependencies : {})};
- for(const name of Object.keys(dependencies)) {
- const child=resolve(path,name);
- if(child) graph.edges.push({from:parent.id,to:child.id});
+ for (const [path, info] of Object.entries(entries)) {
+ const parent = byPath.get(path);
+ if (!parent) continue;
+ const dependencies = {
+ ...info.dependencies,
+ ...info.optionalDependencies,
+ ...info.peerDependencies,
+ ...(!path ? info.devDependencies : {}),
+ };
+ for (const name of Object.keys(dependencies)) {
+ const child = resolve(path, name);
+ if (child) graph.edges.push({ from: parent.id, to: child.id });
else graph.warnings.push(`Unresolved or platform-optional edge: ${parent.name} → ${name}.`);
}
}
- } else if(file==='requirements.txt') {
- const root=add('pypi','python-application','0.0.0','service');
- for(const line of content.split('\n')) {
- const value=line.split('#')[0].trim();if(!value || value.startsWith('--hash')) continue;
- const match=value.match(/^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,.-]+\])?==([0-9][A-Za-z0-9.!+_-]*)(?:\s*(?:;.*|--hash=.*|\\))?$/);
- if(!match) throw new Error('requirements.txt must contain exact name==version pins; URLs, includes and ranges are not supported.');
- const child=add('pypi',match[1],match[2]);graph.edges.push({from:root.id,to:child.id});
+ } else if (file === "requirements.txt") {
+ const root = add("pypi", "python-application", "0.0.0", "service");
+ for (const line of content.split("\n")) {
+ const value = line.split("#")[0].trim();
+ if (!value || value.startsWith("--hash")) continue;
+ const match = value.match(
+ /^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,.-]+\])?==([0-9][A-Za-z0-9.!+_-]*)(?:\s*(?:;.*|--hash=.*|\\))?$/,
+ );
+ if (!match) {
+ graph.warnings.push(
+ `Unresolved requirement omitted: ${value.slice(0, 100)}. Supply an exact name==version pin.`,
+ );
+ continue;
+ }
+ const child = add("pypi", match[1], match[2]);
+ graph.edges.push({ from: root.id, to: child.id });
}
- graph.warnings.push('Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.');
- } else if(file==='Pipfile.lock') {
- const data=JSON.parse(content);const root=add('pypi','python-application','0.0.0','service');
- for(const group of ['default','develop']) for(const [name,raw] of Object.entries(data[group] || {})) {
- const info=raw as {version?:string};if(!info.version?.startsWith('==')) throw new Error(`Pipfile.lock requires an exact version for ${name}.`);
- const child=add('pypi',name,info.version.slice(2));child.scope=group==='develop'?'development':'runtime';graph.edges.push({from:root.id,to:child.id});
+ graph.warnings.push(
+ "Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ );
+ } else if (file === "Pipfile.lock") {
+ const data = JSON.parse(content);
+ const root = add("pypi", "python-application", "0.0.0", "service");
+ for (const group of ["default", "develop"])
+ for (const [name, raw] of Object.entries(data[group] || {})) {
+ const info = raw as { version?: string };
+ if (!info.version?.startsWith("==")) {
+ graph.warnings.push(`No exact installed version for ${name}; skipped.`);
+ continue;
+ }
+ const child = add("pypi", name, info.version.slice(2));
+ child.scope = group === "develop" ? "development" : "runtime";
+ graph.edges.push({ from: root.id, to: child.id });
+ }
+ graph.warnings.push(
+ "Pipfile.lock is a flat snapshot; direct/transitive ancestry and environment markers are not resolved.",
+ );
+ } else if (file === "dependency-tree.json") {
+ const tree = JSON.parse(content);
+ function walk(raw: any, parent?: PackageNode, depth = 0) {
+ if (depth > 100 || graph.nodes.length > 10000) {
+ graph.warnings.push("Large Maven tree: remaining branches were not expanded.");
+ return;
+ }
+ if (!raw.groupId || !raw.artifactId)
+ throw new Error("Expected Maven dependency:tree JSON coordinates.");
+ const n = add(
+ "maven",
+ raw.groupId + ":" + raw.artifactId,
+ raw.version,
+ parent ? "package" : "service",
+ );
+ n.scope = raw.scope === "test" ? "development" : "runtime";
+ if (parent) graph.edges.push({ from: parent.id, to: n.id });
+ for (const child of raw.children || []) walk(child, n, depth + 1);
}
- graph.warnings.push('Pipfile.lock is a flat snapshot; direct/transitive ancestry and environment markers are not resolved.');
- } else if(file==='dependency-tree.json') {
- const tree=JSON.parse(content);
- function walk(raw:any, parent?:PackageNode,depth=0) {
- if(depth>100 || graph.nodes.length>2000) throw new Error('Maven tree exceeds graph limits.');
- if(!raw.groupId || !raw.artifactId) throw new Error('Expected Maven dependency:tree JSON coordinates.');
- const n=add('maven',raw.groupId+':'+raw.artifactId,raw.version,parent?'package':'service');n.scope=raw.scope==='test'?'development':'runtime';
- if(parent) graph.edges.push({from:parent.id,to:n.id});
- for(const child of raw.children || []) walk(child,n,depth+1);
- }walk(tree);
- } else if(file==='pom.xml') {
- if(/doc.properties?.[key]||'');
- if(!exact(version)) {graph.warnings.push(`Unresolved managed version: ${dep.groupId}:${dep.artifactId}. Export dependency-tree.json for full resolution.`);continue;}
- const child=add('maven',dep.groupId+':'+dep.artifactId,version);child.scope=dep.scope==='test'?'development':'runtime';graph.edges.push({from:root.id,to:child.id});
+ walk(tree);
+ } else if (file === "pom.xml") {
+ if (/ doc.properties?.[key] || "",
+ );
+ if (!exact(version)) {
+ graph.warnings.push(
+ `Unresolved managed version: ${dep.groupId}:${dep.artifactId}. Export dependency-tree.json for full resolution.`,
+ );
+ continue;
+ }
+ const child = add("maven", dep.groupId + ":" + dep.artifactId, version);
+ child.scope = dep.scope === "test" ? "development" : "runtime";
+ graph.edges.push({ from: root.id, to: child.id });
}
- graph.warnings.push('POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.');
- } else throw new Error(`Unsupported file. Supported: ${supportedFiles.join(', ')}.`);
- if(!graph.nodes.some(n=>n.kind==='package')) throw new Error('No resolved packages were found in this manifest.');
+ graph.warnings.push(
+ "POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.",
+ );
+ } else throw new Error(`Unsupported file. Supported: ${supportedFiles.join(", ")}.`);
+ if (!graph.nodes.some((n) => n.kind === "package"))
+ graph.warnings.push(
+ "No exact package versions could be extracted. This is an incomplete dependency snapshot.",
+ );
return finalize(graph);
}
diff --git a/src/server/resolve-project.ts b/src/server/resolve-project.ts
new file mode 100644
index 0000000..f2cbcd6
--- /dev/null
+++ b/src/server/resolve-project.ts
@@ -0,0 +1,367 @@
+import { createHash } from "node:crypto";
+import { posix } from "node:path";
+import { parse as parseToml } from "smol-toml";
+import { parse as parseYaml } from "yaml";
+import semver from "semver";
+import { node, finalize } from "./graph.js";
+import { parseManifest } from "./parsers.js";
+import type { Graph, PackageNode, Project, Provenance } from "../shared/types.js";
+import type { SourceFile } from "./discovery.js";
+const hash = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 20);
+const exact = (s: unknown): s is string =>
+ typeof s === "string" && /^[0-9][0-9A-Za-z.!+_-]*$/.test(s);
+function rootFor(p: Project, version = "0.0.0") {
+ const n = node(
+ p.ecosystem,
+ p.ecosystem === "maven" ? `repository:${p.id}` : p.name,
+ exact(version) ? version : "0.0.0",
+ "service",
+ );
+ n.id = "service-" + p.id;
+ n.name = p.name;
+ n.projectId = p.id;
+ n.path = p.path;
+ return n;
+}
+export function resolveProject(project: Project, files: SourceFile[]): Graph {
+ const lock = files.find((f) => f.filename === project.lockfile);
+ const manifest = files.find((f) => f.filename === project.manifest);
+ if (lock && /(?:package-lock|npm-shrinkwrap)\.json$/.test(lock.filename))
+ return npmLock(project, lock, manifest);
+ if (lock?.filename.endsWith("pnpm-lock.yaml")) return pnpmLock(project, lock, manifest);
+ if (lock && /(?:uv|poetry)\.lock$/.test(lock.filename)) return pythonLock(project, lock);
+ if (project.ecosystem === "npm") return npmManifest(project, manifest);
+ const preferred = lock && !lock.filename.endsWith("yarn.lock") ? lock : manifest;
+ if (!preferred)
+ return {
+ nodes: [rootFor(project)],
+ edges: [],
+ warnings: ["No readable dependency snapshot was found."],
+ };
+ if (/pyproject.toml|setup.py|setup.cfg|Pipfile$/.test(preferred.filename))
+ return pythonManifest(project, preferred);
+ const graph = parseManifest(preferred.filename, preferred.content, preferred.provenance);
+ const ids = new Map(
+ graph.nodes.map((n) => [
+ n.id,
+ n.kind === "service" ? "service-" + project.id : hash(project.id + ":" + n.id),
+ ]),
+ );
+ graph.nodes.forEach((n) => {
+ n.id = ids.get(n.id)!;
+ n.projectId = project.id;
+ if (n.kind === "service") {
+ n.name = project.name;
+ n.path = project.path;
+ }
+ if (project.ecosystem === "pypi") {
+ n.relationship = "listed";
+ n.scope = n.scope === "development" ? "development" : "unknown";
+ }
+ });
+ graph.edges.forEach((e) => {
+ e.from = ids.get(e.from)!;
+ e.to = ids.get(e.to)!;
+ e.projectId = project.id;
+ if (project.ecosystem === "pypi") e.relationship = "listed";
+ });
+ return finalize(graph);
+}
+function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
+ const data = JSON.parse(file.content);
+ const info = manifest ? JSON.parse(manifest.content) : {};
+ if (![2, 3].includes(data.lockfileVersion) || !data.packages)
+ return {
+ ...npmManifest(p, manifest),
+ warnings: [
+ "This npm lockfile version is unsupported; only exact manifest pins were imported.",
+ ],
+ };
+ const entries = data.packages as Record;
+ const base = posix.dirname(file.filename);
+ const entry = posix.relative(base, p.path === "." ? "." : p.path);
+ if (!Object.hasOwn(entries, entry)) {
+ const g = npmManifest(p, manifest);
+ g.warnings.push(
+ "The parent lockfile does not record this workspace. Indirect relationships could not be determined.",
+ );
+ return g;
+ }
+ const root = rootFor(p, info.version || entries[entry].version);
+ root.provenance = [file.provenance];
+ const graph: Graph = { nodes: [root], edges: [], warnings: [] };
+ const byPath = new Map();
+ const pending: string[] = [];
+ const resolvePath = (from: string, name: string): string | undefined => {
+ let at = from;
+ while (true) {
+ const path = (at ? at + "/" : "") + "node_modules/" + name;
+ let record = entries[path];
+ if (record) {
+ if (record.link) {
+ const target = record.resolved;
+ if (typeof target !== "string" || !Object.hasOwn(entries, target)) {
+ graph.warnings.push(`Workspace link ${name} is missing its target.`);
+ return;
+ }
+ return target;
+ }
+ return path;
+ }
+ if (!at) return;
+ at = posix.dirname(at);
+ if (at === ".") at = "";
+ }
+ };
+ function add(path: string) {
+ let n = byPath.get(path);
+ if (n) return n;
+ const record = entries[path];
+ const name = record.name || path.split("node_modules/").pop()!;
+ if (!exact(record.version)) {
+ graph.warnings.push(`Installed version is missing for ${name}.`);
+ return;
+ }
+ n = node("npm", name, record.version);
+ n.id = hash(file.filename + ":" + path + ":" + n.purl);
+ n.scope = record.dev ? "development" : "runtime";
+ n.relationship = "known";
+ n.provenance = [file.provenance];
+ n.license = typeof record.license === "string" ? record.license : undefined;
+ byPath.set(path, n);
+ graph.nodes.push(n);
+ pending.push(path);
+ return n;
+ }
+ function connect(parent: PackageNode, path: string, record: any, isRoot = false) {
+ const deps = {
+ ...record.dependencies,
+ ...record.optionalDependencies,
+ ...record.peerDependencies,
+ ...(isRoot ? record.devDependencies : {}),
+ };
+ for (const [name, requested] of Object.entries(deps)) {
+ const resolved = resolvePath(path, name);
+ const child = resolved ? add(resolved) : undefined;
+ if (child) {
+ graph.edges.push({
+ from: parent.id,
+ to: child.id,
+ projectId: p.id,
+ scope:
+ isRoot && record.devDependencies?.[name] && !record.dependencies?.[name]
+ ? "development"
+ : "runtime",
+ relationship: "known",
+ });
+ if (
+ typeof requested === "string" &&
+ semver.validRange(requested) &&
+ !semver.satisfies(child.version, requested)
+ )
+ graph.warnings.push(
+ `Lockfile version for ${name} does not satisfy its declared range ${requested}; regenerate the lockfile.`,
+ );
+ } else if (!record.optionalDependencies?.[name] && !record.peerDependencies?.[name])
+ graph.warnings.push(
+ `Could not determine the installed dependency ${name} used by ${parent.name}.`,
+ );
+ }
+ }
+ connect(root, entry, { ...entries[entry], ...info }, true);
+ for (let i = 0; i < pending.length; i++) {
+ if (i >= 10000) {
+ graph.warnings.push(
+ "Large project: dependency expansion stopped at 10,000 installed entries.",
+ );
+ break;
+ }
+ const path = pending[i];
+ connect(byPath.get(path)!, path, entries[path]);
+ }
+ if (!graph.nodes.some((n) => n.kind === "package"))
+ graph.warnings.push("No installed package dependencies were found for this project.");
+ return finalize(graph);
+}
+function npmManifest(p: Project, file?: SourceFile): Graph {
+ const data = file ? JSON.parse(file.content) : {};
+ const root = rootFor(p, data.version);
+ const graph: Graph = {
+ nodes: [root],
+ edges: [],
+ warnings: [
+ "No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ ],
+ };
+ for (const [name, version] of Object.entries({
+ ...data.dependencies,
+ ...data.optionalDependencies,
+ ...data.devDependencies,
+ })) {
+ if (!semver.valid(version as string)) {
+ graph.warnings.push(
+ `${name}: ${String(version)} is not an exact installed version; omitted from vulnerability checks.`,
+ );
+ continue;
+ }
+ const n = node("npm", name, version as string);
+ n.id = hash(p.id + ":" + n.purl);
+ n.provenance = file ? [file.provenance] : [];
+ n.scope = data.dependencies?.[name] ? "runtime" : "development";
+ n.relationship = "known";
+ graph.nodes.push(n);
+ graph.edges.push({ from: root.id, to: n.id, scope: n.scope, projectId: p.id });
+ }
+ return finalize(graph);
+}
+function pnpmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
+ const data = parseYaml(file.content, { maxAliasCount: 50 });
+ const base = posix.dirname(file.filename);
+ const key = posix.relative(base, p.path) || ".";
+ const importer = data.importers?.[key] || (key === "." ? data : undefined);
+ if (!importer) {
+ const g = npmManifest(p, manifest);
+ g.warnings.push("This project is not an importer in the parent pnpm lockfile.");
+ return g;
+ }
+ const root = rootFor(p);
+ const graph: Graph = { nodes: [root], edges: [], warnings: [] };
+ const records = data.snapshots || data.packages || {};
+ const packages = data.packages || {};
+ const visited = new Map();
+ const queue: { n: PackageNode; key: string }[] = [];
+ function connect(parent: PackageNode, name: string, value: any, scope: PackageNode["scope"]) {
+ const raw = typeof value === "string" ? value : value?.version;
+ if (typeof raw !== "string") return;
+ if (/^(link:|workspace:|file:)/.test(raw)) {
+ graph.warnings.push(`Local pnpm link ${name} is not expanded by this importer.`);
+ return;
+ }
+ const candidates = [`${name}@${raw}`, `/${name}@${raw}`, `/${name}/${raw}`];
+ const recordKey = candidates.find((k) => Object.hasOwn(records, k));
+ const version = raw.split("(")[0].split("_")[0];
+ if (!exact(version)) {
+ graph.warnings.push(`Could not read the installed pnpm version of ${name}.`);
+ return;
+ }
+ const id = recordKey || `${name}@${raw}`;
+ let n = visited.get(id);
+ if (!n) {
+ n = node("npm", name, version);
+ n.id = hash(file.filename + ":" + id);
+ n.scope = scope;
+ n.provenance = [file.provenance];
+ n.relationship = "known";
+ visited.set(id, n);
+ graph.nodes.push(n);
+ if (recordKey) queue.push({ n, key: recordKey });
+ else graph.warnings.push(`Indirect relationships are missing for ${name}@${version}.`);
+ }
+ if (scope === "runtime") n.scope = "runtime";
+ graph.edges.push({ from: parent.id, to: n.id, projectId: p.id, scope });
+ }
+ for (const group of ["dependencies", "optionalDependencies", "devDependencies"])
+ for (const [name, v] of Object.entries(importer[group] || {}))
+ connect(root, name, v, group === "devDependencies" ? "development" : "runtime");
+ for (let i = 0; i < queue.length; i++) {
+ if (i >= 10000) {
+ graph.warnings.push("Large pnpm graph: expansion was limited to 10,000 entries.");
+ break;
+ }
+ const { n, key } = queue[i];
+ const record = records[key] || packages[key];
+ for (const [name, v] of Object.entries({
+ ...record.dependencies,
+ ...record.optionalDependencies,
+ }))
+ connect(n, name, v, n.scope);
+ }
+ return finalize(graph);
+}
+function pythonLock(p: Project, file: SourceFile): Graph {
+ const doc = parseToml(file.content) as any;
+ const records = doc.package || [];
+ const root = rootFor(p);
+ const graph: Graph = { nodes: [root], edges: [], warnings: [] };
+ const byName = new Map();
+ const normalize = (n: string) => n.toLowerCase().replace(/[-_.]+/g, "-");
+ for (const record of records) {
+ if (
+ !exact(record.version) ||
+ record.source?.editable !== undefined ||
+ record.source?.virtual !== undefined
+ )
+ continue;
+ const n = node("pypi", record.name, record.version);
+ n.id = hash(p.id + ":" + n.purl);
+ n.provenance = [file.provenance];
+ n.scope = "unknown";
+ n.relationship = "listed";
+ graph.nodes.push(n);
+ const name = normalize(n.name);
+ byName.set(name, [...(byName.get(name) || []), n]);
+ }
+ for (const record of records) {
+ const from = (byName.get(normalize(record.name)) || []).find(
+ (n) => n.version === record.version,
+ );
+ if (!from) continue;
+ const deps = Array.isArray(record.dependencies)
+ ? record.dependencies
+ : Object.keys(record.dependencies || {}).map((name) => ({ name }));
+ for (const d of deps) {
+ const options = (byName.get(normalize(d.name)) || []).filter(
+ (n) => !d.version || n.version === d.version,
+ );
+ if (options.length === 1) {
+ graph.edges.push({
+ from: from.id,
+ to: options[0].id,
+ projectId: p.id,
+ relationship: "known",
+ scope: "unknown",
+ });
+ } else
+ graph.warnings.push(
+ `Conditional or ambiguous Python dependency: ${record.name} → ${d.name}.`,
+ );
+ }
+ }
+ // Python lockfiles can cover multiple environments; root membership is not guessed.
+ for (const n of graph.nodes.filter((n) => n.kind === "package"))
+ graph.edges.push({
+ from: root.id,
+ to: n.id,
+ projectId: p.id,
+ relationship: "listed",
+ scope: "unknown",
+ });
+ graph.warnings.push(
+ "Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies.",
+ );
+ return finalize(graph);
+}
+function pythonManifest(p: Project, file: SourceFile): Graph {
+ const root = rootFor(p);
+ const graph: Graph = {
+ nodes: [root],
+ edges: [],
+ warnings: [
+ "No exact resolved Python snapshot is available. Unpinned dependencies are not checked; use uv.lock, poetry.lock, Pipfile.lock or pinned requirements.",
+ ],
+ };
+ if (file.filename.endsWith("pyproject.toml")) {
+ const doc = parseToml(file.content) as any;
+ for (const req of doc.project?.dependencies || []) {
+ const m = String(req).match(/^([A-Za-z0-9_.-]+)==([0-9][\w.!+-]*)$/);
+ if (!m) continue;
+ const n = node("pypi", m[1], m[2]);
+ n.id = hash(p.id + ":" + n.purl);
+ n.provenance = [file.provenance];
+ n.scope = "runtime";
+ graph.nodes.push(n);
+ graph.edges.push({ from: root.id, to: n.id, projectId: p.id });
+ }
+ }
+ return finalize(graph);
+}
diff --git a/src/server/sbom.ts b/src/server/sbom.ts
index 516bd8d..71334e3 100644
--- a/src/server/sbom.ts
+++ b/src/server/sbom.ts
@@ -1,6 +1,51 @@
-import type { Scan } from '../shared/types.js';
-export function sbom(scan:Scan) {
- if(!scan.graph || scan.status!=='completed')throw new Error('Scan is not complete.');
- const spdx=(id:string)=>'SPDXRef-'+id;
- return {spdxVersion:'SPDX-2.3',dataLicense:'CC0-1.0',SPDXID:'SPDXRef-DOCUMENT',name:'RippleGuard-'+scan.name.replace(/[^A-Za-z0-9.-]/g,'-'),documentNamespace:`https://rippleguard.local/spdx/${scan.id}`,creationInfo:{creators:['Tool: RippleGuard-1.0.0'],created:scan.completedAt!.replace(/\.\d{3}Z$/,'Z'),comment:scan.mode==='demo'?'Illustrative demo fixture. Not a live assessment.':'Resolved snapshot; review scan warnings for coverage limits.'},documentDescribes:scan.graph.nodes.filter(n=>n.kind==='service').map(n=>spdx(n.id)),packages:scan.graph.nodes.map(n=>({SPDXID:spdx(n.id),name:n.name,versionInfo:n.version,downloadLocation:'NOASSERTION',filesAnalyzed:false,licenseConcluded:'NOASSERTION',licenseDeclared:n.license||'NOASSERTION',copyrightText:'NOASSERTION',externalRefs:[{referenceCategory:'PACKAGE-MANAGER',referenceType:'purl',referenceLocator:n.purl}],sourceInfo:n.provenance.map(p=>`${p.source}; ${p.url||'uploaded/fixture'}; retrieved ${p.retrievedAt}`).join('\n')})),relationships:[...scan.graph.nodes.filter(n=>n.kind==='service').map(n=>({spdxElementId:'SPDXRef-DOCUMENT',relationshipType:'DESCRIBES',relatedSpdxElement:spdx(n.id)})),...scan.graph.edges.map(e=>({spdxElementId:spdx(e.from),relationshipType:'DEPENDS_ON',relatedSpdxElement:spdx(e.to)}))]};
+import type { Scan } from "../shared/types.js";
+export function sbom(scan: Scan) {
+ if (!scan.graph || scan.status !== "completed") throw new Error("Scan is not complete.");
+ const spdx = (id: string) => "SPDXRef-" + id;
+ return {
+ spdxVersion: "SPDX-2.3",
+ dataLicense: "CC0-1.0",
+ SPDXID: "SPDXRef-DOCUMENT",
+ name: "RippleGuard-" + scan.name.replace(/[^A-Za-z0-9.-]/g, "-"),
+ documentNamespace: `https://rippleguard.local/spdx/${scan.id}`,
+ creationInfo: {
+ creators: ["Tool: RippleGuard-1.0.0"],
+ created: scan.completedAt!.replace(/\.\d{3}Z$/, "Z"),
+ comment:
+ scan.mode === "demo"
+ ? "Illustrative demo fixture. Not a live assessment."
+ : "Resolved snapshot; review scan warnings for coverage limits.",
+ },
+ documentDescribes: scan.graph.nodes.filter((n) => n.kind === "service").map((n) => spdx(n.id)),
+ packages: scan.graph.nodes.map((n) => ({
+ SPDXID: spdx(n.id),
+ name: n.name,
+ versionInfo: n.version,
+ downloadLocation: "NOASSERTION",
+ filesAnalyzed: false,
+ licenseConcluded: "NOASSERTION",
+ licenseDeclared: n.license || "NOASSERTION",
+ copyrightText: "NOASSERTION",
+ externalRefs: [
+ { referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: n.purl },
+ ],
+ sourceInfo: n.provenance
+ .map((p) => `${p.source}; ${p.url || "uploaded/fixture"}; retrieved ${p.retrievedAt}`)
+ .join("\n"),
+ })),
+ relationships: [
+ ...scan.graph.nodes
+ .filter((n) => n.kind === "service")
+ .map((n) => ({
+ spdxElementId: "SPDXRef-DOCUMENT",
+ relationshipType: "DESCRIBES",
+ relatedSpdxElement: spdx(n.id),
+ })),
+ ...scan.graph.edges.map((e) => ({
+ spdxElementId: spdx(e.from),
+ relationshipType: "DEPENDS_ON",
+ relatedSpdxElement: spdx(e.to),
+ })),
+ ],
+ };
}
diff --git a/src/server/store.ts b/src/server/store.ts
index bb8ef3d..17f5166 100644
--- a/src/server/store.ts
+++ b/src/server/store.ts
@@ -1,26 +1,77 @@
-import { DatabaseSync } from 'node:sqlite';
-import { mkdirSync } from 'node:fs';
-import { dirname } from 'node:path';
-import type { Scan, ScanInput } from '../shared/types.js';
+import { DatabaseSync } from "node:sqlite";
+import { mkdirSync } from "node:fs";
+import { dirname } from "node:path";
+import type { Scan, ScanInput } from "../shared/types.js";
export class Store {
db: DatabaseSync;
- constructor(path = process.env.DB_PATH || 'data/rippleguard.sqlite') {
- if(path !== ':memory:') mkdirSync(dirname(path), {recursive:true});
+ constructor(path = process.env.DB_PATH || "data/rippleguard.sqlite") {
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
this.db = new DatabaseSync(path);
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS migrations (version INTEGER PRIMARY KEY);
CREATE TABLE IF NOT EXISTS scans (id TEXT PRIMARY KEY, json TEXT NOT NULL, input TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS repository_files (scan_id TEXT NOT NULL, path TEXT NOT NULL, json TEXT NOT NULL, PRIMARY KEY(scan_id,path));
CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, created INTEGER NOT NULL);
INSERT OR IGNORE INTO migrations VALUES(1);`);
}
- save(scan: Scan, input?: ScanInput) { this.db.prepare('INSERT INTO scans VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET json=excluded.json').run(scan.id, JSON.stringify(scan), JSON.stringify(input || {})); }
- get(id: string): Scan | undefined { const row = this.db.prepare('SELECT json FROM scans WHERE id=?').get(id); return row ? JSON.parse(row.json as string) : undefined; }
- input(id: string): ScanInput { return JSON.parse(this.db.prepare('SELECT input FROM scans WHERE id=?').get(id)!.input as string); }
- list(): Scan[] {return this.db.prepare('SELECT json FROM scans ORDER BY rowid DESC LIMIT 100').all().map(r => JSON.parse(r.json as string));}
- pending(): Scan[] {return this.db.prepare("SELECT json FROM scans WHERE json_extract(json,'$.status') IN ('queued','scanning','enriching')").all().map(r => JSON.parse(r.json as string));}
- cacheGet(key:string): string | undefined { return this.db.prepare('SELECT value FROM cache WHERE key=? AND expires>?').get(key, Date.now())?.value as string | undefined; }
- cachePut(key:string,value:string,ttl:number) { this.db.prepare('INSERT OR REPLACE INTO cache VALUES(?,?,?)').run(key,value,Date.now()+ttl); }
- dedupe(id:string): boolean { this.db.prepare('DELETE FROM deliveries WHERE created').run(Date.now()-7*86400000); return this.db.prepare('INSERT OR IGNORE INTO deliveries VALUES(?,?)').run(id,Date.now()).changes === 1; }
- close() {this.db.close();}
+ save(scan: Scan, input?: ScanInput) {
+ this.db
+ .prepare("INSERT INTO scans VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET json=excluded.json")
+ .run(scan.id, JSON.stringify(scan), JSON.stringify(input || {}));
+ }
+ get(id: string): Scan | undefined {
+ const row = this.db.prepare("SELECT json FROM scans WHERE id=?").get(id);
+ return row ? JSON.parse(row.json as string) : undefined;
+ }
+ input(id: string): ScanInput {
+ return JSON.parse(
+ this.db.prepare("SELECT input FROM scans WHERE id=?").get(id)!.input as string,
+ );
+ }
+ list(): Scan[] {
+ return this.db
+ .prepare("SELECT json FROM scans ORDER BY rowid DESC LIMIT 100")
+ .all()
+ .map((r) => JSON.parse(r.json as string));
+ }
+ pending(): Scan[] {
+ return this.db
+ .prepare(
+ "SELECT json FROM scans WHERE json_extract(json,'$.status') IN ('queued','discovering','scanning','enriching')",
+ )
+ .all()
+ .map((r) => JSON.parse(r.json as string));
+ }
+ cacheGet(key: string): string | undefined {
+ return this.db.prepare("SELECT value FROM cache WHERE key=? AND expires>?").get(key, Date.now())
+ ?.value as string | undefined;
+ }
+ cachePut(key: string, value: string, ttl: number) {
+ this.db.prepare("INSERT OR REPLACE INTO cache VALUES(?,?,?)").run(key, value, Date.now() + ttl);
+ }
+ dedupe(id: string): boolean {
+ this.db.prepare("DELETE FROM deliveries WHERE created").run(Date.now() - 7 * 86400000);
+ return (
+ this.db.prepare("INSERT OR IGNORE INTO deliveries VALUES(?,?)").run(id, Date.now())
+ .changes === 1
+ );
+ }
+ saveFiles(id: string, files: import("./discovery.js").RepositoryFile[]) {
+ this.db
+ .prepare("INSERT OR REPLACE INTO repository_files VALUES(?,?,?)")
+ .run(id, "__tree__", JSON.stringify(files));
+ }
+ files(id: string): import("./discovery.js").RepositoryFile[] {
+ const row = this.db
+ .prepare("SELECT json FROM repository_files WHERE scan_id=? AND path=?")
+ .get(id, "__tree__");
+ return row ? JSON.parse(row.json as string) : [];
+ }
+ updateInput(id: string, input: ScanInput) {
+ this.db.prepare("UPDATE scans SET input=? WHERE id=?").run(JSON.stringify(input), id);
+ }
+ close() {
+ this.db.close();
+ }
}
diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts
index 1acb5a3..41b3331 100644
--- a/src/server/telemetry.ts
+++ b/src/server/telemetry.ts
@@ -1,7 +1,15 @@
-import { NodeSDK } from '@opentelemetry/sdk-node';
-import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
-import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
-import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
-import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
-export const telemetry=process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? new NodeSDK({traceExporter:new OTLPTraceExporter(),metricReader:new PeriodicExportingMetricReader({exporter:new OTLPMetricExporter()}),instrumentations:[getNodeAutoInstrumentations({'@opentelemetry/instrumentation-fs':{enabled:false}})]}):undefined;
+import { NodeSDK } from "@opentelemetry/sdk-node";
+import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
+import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
+import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
+import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
+export const telemetry = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
+ ? new NodeSDK({
+ traceExporter: new OTLPTraceExporter(),
+ metricReader: new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter() }),
+ instrumentations: [
+ getNodeAutoInstrumentations({ "@opentelemetry/instrumentation-fs": { enabled: false } }),
+ ],
+ })
+ : undefined;
telemetry?.start();
diff --git a/src/server/transport.ts b/src/server/transport.ts
index 8393fa3..cc0af47 100644
--- a/src/server/transport.ts
+++ b/src/server/transport.ts
@@ -1,55 +1,167 @@
-import { createHash } from 'node:crypto';
-import { setTimeout as delay } from 'node:timers/promises';
-import { metrics, trace } from '@opentelemetry/api';
-import type { Store } from './store.js';
-import type { ConnectorHealth, Provenance } from '../shared/types.js';
-const hosts = new Set(['registry.npmjs.org','pypi.org','repo.maven.apache.org','services.nvd.nist.gov','cveawg.mitre.org','ossindex.sonatype.org','api.github.com','api.osv.dev']);
-const meter=metrics.getMeter('rippleguard');
-const latency=meter.createHistogram('connector_latency',{unit:'ms'});
-const failures=meter.createCounter('connector_errors');
-export interface SourceResponse { data:T; provenance:Provenance }
-export interface HttpSource { get(source:string,url:string,options?:{method?:string;body?:unknown;headers?:Record;ttl?:number;interval?:number}):Promise> }
+import { createHash } from "node:crypto";
+import { setTimeout as delay } from "node:timers/promises";
+import { metrics, trace } from "@opentelemetry/api";
+import type { Store } from "./store.js";
+import type { ConnectorHealth, Provenance } from "../shared/types.js";
+const hosts = new Set([
+ "registry.npmjs.org",
+ "pypi.org",
+ "repo.maven.apache.org",
+ "services.nvd.nist.gov",
+ "cveawg.mitre.org",
+ "ossindex.sonatype.org",
+ "api.github.com",
+ "api.osv.dev",
+ "raw.githubusercontent.com",
+]);
+const meter = metrics.getMeter("rippleguard");
+const latency = meter.createHistogram("connector_latency", { unit: "ms" });
+const failures = meter.createCounter("connector_errors");
+export interface SourceResponse {
+ data: T;
+ provenance: Provenance;
+}
+export interface HttpSource {
+ get(
+ source: string,
+ url: string,
+ options?: {
+ method?: string;
+ body?: unknown;
+ headers?: Record;
+ ttl?: number;
+ interval?: number;
+ },
+ ): Promise>;
+}
export class Transport implements HttpSource {
- health = new Map();
- private next = new Map();
- constructor(private store:Store, private fetcher:typeof fetch=fetch) {}
- async get(source:string,url:string,options:{method?:string;body?:unknown;headers?:Record;ttl?:number;interval?:number}={}):Promise> {
- const parsed=new URL(url);
- if(parsed.protocol!=='https:' || !hosts.has(parsed.hostname) || parsed.port || parsed.username || parsed.password) throw new Error('Connector URL is not allowlisted.');
- const key=createHash('sha256').update(JSON.stringify([source,url,options.method,options.body,options.headers])).digest('hex');
- const cached=this.store.cacheGet(key);
- if(cached) {this.health.set(source,{name:source,status:'ok',message:'Cached source response',checkedAt:new Date().toISOString()});return JSON.parse(cached);}
- const started=Date.now();const span=trace.getTracer('rippleguard').startSpan('connector.'+source);
+ health = new Map();
+ private next = new Map();
+ constructor(
+ private store: Store,
+ private fetcher: typeof fetch = fetch,
+ ) {}
+ async get(
+ source: string,
+ url: string,
+ options: {
+ method?: string;
+ body?: unknown;
+ headers?: Record;
+ ttl?: number;
+ interval?: number;
+ } = {},
+ ): Promise> {
+ const parsed = new URL(url);
+ if (
+ parsed.protocol !== "https:" ||
+ !hosts.has(parsed.hostname) ||
+ parsed.port ||
+ parsed.username ||
+ parsed.password
+ )
+ throw new Error("Connector URL is not allowlisted.");
+ const key = createHash("sha256")
+ .update(JSON.stringify([source, url, options.method, options.body, options.headers]))
+ .digest("hex");
+ const cached = this.store.cacheGet(key);
+ if (cached) {
+ this.health.set(source, {
+ name: source,
+ status: "ok",
+ message: "Cached source response",
+ checkedAt: new Date().toISOString(),
+ });
+ return JSON.parse(cached);
+ }
+ const started = Date.now();
+ const span = trace.getTracer("rippleguard").startSpan("connector." + source);
try {
- for(let attempt=0;attempt<3;attempt++) {
- const slot=Math.max(Date.now(),this.next.get(parsed.hostname)||0);
- this.next.set(parsed.hostname,slot+(options.interval ?? 250));
- await delay(Math.max(0,slot-Date.now()));
- let response:Response;
- try {response=await this.fetcher(url,{method:options.method||'GET',headers:{'User-Agent':'RippleGuard/1.0','Accept':'application/json',...(options.body?{'Content-Type':'application/json'}:{}),...options.headers},body:options.body?JSON.stringify(options.body):undefined,redirect:'error',signal:AbortSignal.timeout(10_000)});}
- catch {if(attempt<2){await delay(400*2**attempt);continue;} throw new Error('Source timed out or could not be reached.');}
- if((response.status===429 || response.status>=500) && attempt<2) {
- failures.add(1,{source,status:String(response.status)});
- const retry=response.headers.get('retry-after');const seconds=Number(retry);
- const wait=retry ? Number.isFinite(seconds)?seconds*1000:Date.parse(retry)-Date.now():400*2**attempt;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const slot = Math.max(Date.now(), this.next.get(parsed.hostname) || 0);
+ this.next.set(parsed.hostname, slot + (options.interval ?? 250));
+ await delay(Math.max(0, slot - Date.now()));
+ let response: Response;
+ try {
+ response = await this.fetcher(url, {
+ method: options.method || "GET",
+ headers: {
+ "User-Agent": "RippleGuard/1.0",
+ Accept: "application/json",
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
+ ...options.headers,
+ },
+ body: options.body ? JSON.stringify(options.body) : undefined,
+ redirect: "error",
+ signal: AbortSignal.timeout(8_000),
+ });
+ } catch {
+ if (attempt < 2) {
+ await delay(400 * 2 ** attempt);
+ continue;
+ }
+ throw new Error("Source timed out or could not be reached.");
+ }
+ if ((response.status === 429 || response.status >= 500) && attempt < 2) {
+ failures.add(1, { source, status: String(response.status) });
+ const retry = response.headers.get("retry-after");
+ const seconds = Number(retry);
+ const wait = retry
+ ? Number.isFinite(seconds)
+ ? seconds * 1000
+ : Date.parse(retry) - Date.now()
+ : 400 * 2 ** attempt;
await response.body?.cancel();
- if(wait>30_000) throw new Error('Source rate limit exceeded; try again later.');
- await delay(Math.max(250,wait||1000));continue;
+ if (wait > 30_000) throw new Error("Source rate limit exceeded; try again later.");
+ await delay(Math.max(250, wait || 1000));
+ continue;
+ }
+ if (!response.ok) {
+ await response.body?.cancel();
+ throw new Error(`Source returned HTTP ${response.status}.`);
+ }
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error("Empty source response.");
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ while (true) {
+ const result = await reader.read();
+ if (result.done) break;
+ size += result.value.length;
+ if (size > 8_000_000) {
+ await reader.cancel();
+ throw new Error("Source response exceeds 8 MB.");
+ }
+ chunks.push(result.value);
}
- if(!response.ok) {await response.body?.cancel();throw new Error(`Source returned HTTP ${response.status}.`);}
- const reader=response.body?.getReader();if(!reader) throw new Error('Empty source response.');
- const chunks:Uint8Array[]=[];let size=0;
- while(true){const result=await reader.read();if(result.done)break;size+=result.value.length;if(size>8_000_000){await reader.cancel();throw new Error('Source response exceeds 8 MB.');}chunks.push(result.value);}
- const raw=Buffer.concat(chunks).toString('utf8');
- const data=(response.headers.get('content-type')?.includes('json')?JSON.parse(raw):raw) as T;
- const result={data,provenance:{source,url,retrievedAt:new Date().toISOString()}};
- if(options.ttl!==0) this.store.cachePut(key,JSON.stringify(result),options.ttl??3_600_000);
- this.health.set(source,{name:source,status:'ok',message:'Source responded',checkedAt:result.provenance.retrievedAt});
+ const raw = Buffer.concat(chunks).toString("utf8");
+ const data = (
+ response.headers.get("content-type")?.includes("json") ? JSON.parse(raw) : raw
+ ) as T;
+ const result = { data, provenance: { source, url, retrievedAt: new Date().toISOString() } };
+ if (options.ttl !== 0)
+ this.store.cachePut(key, JSON.stringify(result), options.ttl ?? 3_600_000);
+ this.health.set(source, {
+ name: source,
+ status: "ok",
+ message: "Source responded",
+ checkedAt: result.provenance.retrievedAt,
+ });
return result;
}
- throw new Error('Source retry budget exhausted.');
- } catch(error) {
- failures.add(1,{source});this.health.set(source,{name:source,status:'degraded',message:error instanceof Error?error.message:'Connector failed',checkedAt:new Date().toISOString()});throw error;
- } finally {latency.record(Date.now()-started,{source});span.end();}
+ throw new Error("Source retry budget exhausted.");
+ } catch (error) {
+ failures.add(1, { source });
+ this.health.set(source, {
+ name: source,
+ status: "degraded",
+ message: error instanceof Error ? error.message : "Connector failed",
+ checkedAt: new Date().toISOString(),
+ });
+ throw error;
+ } finally {
+ latency.record(Date.now() - started, { source });
+ span.end();
+ }
}
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 4dafb9e..4d85f95 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -1,11 +1,151 @@
-export type Ecosystem = 'npm' | 'pypi' | 'maven';
-export interface Provenance { source: string; url: string; retrievedAt: string; fixture?: boolean }
-export interface Advisory { id: string; aliases: string[]; summary: string; cvss?: number; exploited?: boolean; fixed?: string; provenance: Provenance[] }
-export interface PackageNode { id: string; name: string; version: string; ecosystem: Ecosystem; purl: string; kind: 'package' | 'service'; scope: 'runtime' | 'development'; license?: string; depth: number | null; advisories: Advisory[]; provenance: Provenance[]; coverage: 'unchecked' | 'checked' | 'partial' | 'fixture' }
-export interface Edge { from: string; to: string }
-export interface Graph { nodes: PackageNode[]; edges: Edge[]; warnings: string[] }
-export interface Risk { nodeId: string; score: number; level: 'critical' | 'high' | 'medium' | 'low'; factors: {severity: number; exploit: number; exposure: number; blastRadius: number}; ancestors: number; services: string[]; advisories: number; coverage: PackageNode['coverage'] }
-export interface Simulation { nodeId: string; affected: string[]; services: string[]; edges: Edge[]; impact: number; paths: string[][] }
-export interface ConnectorHealth { name: string; status: 'ready' | 'ok' | 'degraded' | 'disabled' | 'fixture'; message: string; checkedAt?: string }
-export interface Scan { id: string; status: 'queued' | 'scanning' | 'enriching' | 'completed' | 'failed'; name: string; ref: string; commit?: string; mode: 'demo' | 'github' | 'manifest'; createdAt: string; completedAt?: string; error?: string; graph?: Graph; risks?: Risk[]; connectors: ConnectorHealth[] }
-export interface ScanInput { mode: 'demo' | 'github' | 'manifest'; repository?: string; ref?: string; filename?: string; content?: string; installationId?: number }
+export type Ecosystem = "npm" | "pypi" | "maven";
+export interface Provenance {
+ source: string;
+ url: string;
+ retrievedAt: string;
+ fixture?: boolean;
+}
+export interface Advisory {
+ id: string;
+ aliases: string[];
+ summary: string;
+ cvss?: number;
+ exploited?: boolean;
+ fixed?: string;
+ severity?: "critical" | "high" | "medium" | "low";
+ cvssVector?: string;
+ affected?: string[];
+ provenance: Provenance[];
+}
+export interface PackageNode {
+ id: string;
+ name: string;
+ version: string;
+ ecosystem: Ecosystem;
+ purl: string;
+ kind: "package" | "service";
+ scope: "runtime" | "development" | "unknown";
+ projectId?: string;
+ path?: string;
+ relationship?: "known" | "listed";
+ license?: string;
+ depth: number | null;
+ advisories: Advisory[];
+ provenance: Provenance[];
+ coverage: "unchecked" | "checked" | "partial" | "fixture";
+}
+export interface Edge {
+ from: string;
+ to: string;
+ projectId?: string;
+ scope?: "runtime" | "development" | "unknown";
+ relationship?: "known" | "listed";
+}
+export interface Graph {
+ nodes: PackageNode[];
+ edges: Edge[];
+ warnings: string[];
+}
+export interface Risk {
+ nodeId: string;
+ score: number;
+ level: "critical" | "high" | "medium" | "low";
+ factors: { severity: number; exploit: number; exposure: number; blastRadius: number };
+ ancestors: number;
+ services: string[];
+ advisories: number;
+ coverage: PackageNode["coverage"];
+ reasons?: string[];
+ pathCount?: number;
+ pathsTruncated?: boolean;
+}
+export interface Simulation {
+ nodeId: string;
+ affected: string[];
+ services: string[];
+ edges: Edge[];
+ impact: number;
+ paths: string[][];
+ pathCount?: number;
+ pathsTruncated?: boolean;
+ totalServices?: number;
+ serviceImpact?: number;
+ distances?: Record;
+ hypothetical?: boolean;
+ seedIds?: string[];
+ relationshipUncertain?: boolean;
+}
+export interface ConnectorHealth {
+ name: string;
+ status: "ready" | "ok" | "degraded" | "disabled" | "fixture";
+ message: string;
+ checkedAt?: string;
+}
+export interface Scan {
+ id: string;
+ status: "queued" | "discovering" | "mapped" | "scanning" | "enriching" | "completed" | "failed";
+ name: string;
+ ref: string;
+ commit?: string;
+ mode: "demo" | "github" | "manifest";
+ createdAt: string;
+ completedAt?: string;
+ error?: string;
+ graph?: Graph;
+ risks?: Risk[];
+ connectors: ConnectorHealth[];
+ repositoryMap?: RepositoryMap;
+ progress?: ScanProgress;
+ analysisRequested?: boolean;
+}
+export interface ScanInput {
+ mode: "demo" | "github" | "manifest";
+ repository?: string;
+ ref?: string;
+ filename?: string;
+ content?: string;
+ installationId?: number;
+}
+
+export interface Project {
+ id: string;
+ name: string;
+ path: string;
+ ecosystem: Ecosystem;
+ manifest?: string;
+ lockfile?: string;
+ files: string[];
+ selected: boolean;
+ role: "application" | "workspace" | "tooling" | "project";
+ resolution: "resolved" | "partial" | "unsupported" | "failed";
+ notes: string[];
+ packageCount?: number;
+ analyzed?: boolean;
+}
+export interface RepositoryMap {
+ repository: string;
+ ref: string;
+ commit: string;
+ projects: Project[];
+ warnings: string[];
+ fileCount: number;
+}
+export interface ProjectSelection {
+ id: string;
+ name: string;
+}
+export interface ScanProgress {
+ stage:
+ | "discover"
+ | "map"
+ | "extract"
+ | "resolve"
+ | "vulnerabilities"
+ | "risk"
+ | "ripple"
+ | "done";
+ message: string;
+ current?: number;
+ total?: number;
+ completed: string[];
+}
diff --git a/tests/api.test.ts b/tests/api.test.ts
index c8067bb..dd2ee19 100644
--- a/tests/api.test.ts
+++ b/tests/api.test.ts
@@ -1,35 +1,173 @@
-import {test} from 'node:test';
-import assert from 'node:assert/strict';
-import {createHmac} from 'node:crypto';
-import {createApp,verifySignature} from '../src/server/app.js';
-import {Store} from '../src/server/store.js';
-process.env.NODE_ENV='test';
-test('demo API completes, simulates, exports and rejects invalid/missing data',async()=>{
- const {app}=await createApp(new Store(':memory:'));
- try{
- const created=await app.inject({method:'POST',url:'/api/v1/scans',payload:{mode:'demo'}});assert.equal(created.statusCode,202);const id=created.json().id;
- const result=await app.inject(`/api/v1/scans/${id}`);assert.equal(result.json().status,'completed');const node=result.json().risks[0].nodeId;
- assert.equal((await app.inject(`/api/v1/scans/${id}/graph`)).statusCode,200);
- assert.equal((await app.inject({method:'POST',url:`/api/v1/scans/${id}/simulate`,payload:{nodeId:node}})).json().services.length,2);
- const exportResult=await app.inject(`/api/v1/scans/${id}/sbom`);assert.equal(exportResult.json().spdxVersion,'SPDX-2.3');assert(exportResult.headers['content-disposition']?.includes('.spdx.json'));
- assert.equal((await app.inject('/api/v1/scans/missing')).statusCode,404);
- assert.equal((await app.inject({method:'POST',url:'/api/v1/scans',payload:{mode:'github',repository:'http://localhost/'}})).statusCode,400);
- assert.equal((await app.inject({method:'POST',url:'/api/v1/scans',payload:{mode:'invalid'}})).statusCode,400);
- assert.equal((await app.inject({method:'POST',url:'/api/v1/scans',headers:{origin:'https://evil.test'},payload:{mode:'demo'}})).statusCode,403);
- }finally{await app.close();}
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createHmac } from "node:crypto";
+import { createApp, verifySignature } from "../src/server/app.js";
+import { Store } from "../src/server/store.js";
+process.env.NODE_ENV = "test";
+test("demo API completes, simulates, exports and rejects invalid/missing data", async () => {
+ const { app } = await createApp(new Store(":memory:"));
+ try {
+ const created = await app.inject({
+ method: "POST",
+ url: "/api/v1/scans",
+ payload: { mode: "demo" },
+ });
+ assert.equal(created.statusCode, 202);
+ const id = created.json().id;
+ const mapped = await app.inject(`/api/v1/scans/${id}`);
+ assert.equal(mapped.json().status, "mapped");
+ const analyze = await app.inject({
+ method: "POST",
+ url: `/api/v1/scans/${id}/analyze`,
+ payload: {
+ projects: mapped
+ .json()
+ .repositoryMap.projects.map((p: any) => ({ id: p.id, name: p.name })),
+ },
+ });
+ assert.equal(analyze.statusCode, 202);
+ const result = await app.inject(`/api/v1/scans/${id}`);
+ assert.equal(result.json().status, "completed");
+ const node = result.json().risks[0].nodeId;
+ assert.equal((await app.inject(`/api/v1/scans/${id}/graph`)).statusCode, 200);
+ assert.equal(
+ (
+ await app.inject({
+ method: "POST",
+ url: `/api/v1/scans/${id}/simulate`,
+ payload: { nodeId: node },
+ })
+ ).json().services.length,
+ 3,
+ );
+ const exportResult = await app.inject(`/api/v1/scans/${id}/sbom`);
+ assert.equal(exportResult.json().spdxVersion, "SPDX-2.3");
+ assert(exportResult.headers["content-disposition"]?.includes(".spdx.json"));
+ assert.equal((await app.inject("/api/v1/scans/missing")).statusCode, 404);
+ assert.equal(
+ (
+ await app.inject({
+ method: "POST",
+ url: "/api/v1/scans",
+ payload: { mode: "github", repository: "http://localhost/" },
+ })
+ ).statusCode,
+ 400,
+ );
+ assert.equal(
+ (await app.inject({ method: "POST", url: "/api/v1/scans", payload: { mode: "invalid" } }))
+ .statusCode,
+ 400,
+ );
+ assert.equal(
+ (
+ await app.inject({
+ method: "POST",
+ url: "/api/v1/scans",
+ headers: { origin: "https://evil.test" },
+ payload: { mode: "demo" },
+ })
+ ).statusCode,
+ 403,
+ );
+ } finally {
+ await app.close();
+ }
});
-test('webhook validates exact raw bytes and rejects malformed signatures',async()=>{
- const secret='test-webhook-secret';const body=Buffer.from('{"action": "ping"}');const signature='sha256='+createHmac('sha256',secret).update(body).digest('hex');
- assert(verifySignature(body,signature,secret));assert(!verifySignature(Buffer.from('{"action":"ping"}'),signature,secret));assert(!verifySignature(body,'sha256=bad',secret));
- process.env.GITHUB_WEBHOOK_SECRET=secret;
- const {app}=await createApp(new Store(':memory:'));
- try{assert.equal((await app.inject({method:'POST',url:'/api/v1/webhooks/github',payload:body,headers:{'content-type':'application/json','x-hub-signature-256':signature,'x-github-delivery':'test-1','x-github-event':'ping'}})).statusCode,200);assert.equal((await app.inject({method:'POST',url:'/api/v1/webhooks/github',payload:{},headers:{'x-hub-signature-256':'sha256=bad'}})).statusCode,401);}finally{await app.close();delete process.env.GITHUB_WEBHOOK_SECRET;}
+test("webhook validates exact raw bytes and rejects malformed signatures", async () => {
+ const secret = "test-webhook-secret";
+ const body = Buffer.from('{"action": "ping"}');
+ const signature = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
+ assert(verifySignature(body, signature, secret));
+ assert(!verifySignature(Buffer.from('{"action":"ping"}'), signature, secret));
+ assert(!verifySignature(body, "sha256=bad", secret));
+ process.env.GITHUB_WEBHOOK_SECRET = secret;
+ const { app } = await createApp(new Store(":memory:"));
+ try {
+ assert.equal(
+ (
+ await app.inject({
+ method: "POST",
+ url: "/api/v1/webhooks/github",
+ payload: body,
+ headers: {
+ "content-type": "application/json",
+ "x-hub-signature-256": signature,
+ "x-github-delivery": "test-1",
+ "x-github-event": "ping",
+ },
+ })
+ ).statusCode,
+ 200,
+ );
+ assert.equal(
+ (
+ await app.inject({
+ method: "POST",
+ url: "/api/v1/webhooks/github",
+ payload: {},
+ headers: { "x-hub-signature-256": "sha256=bad" },
+ })
+ ).statusCode,
+ 401,
+ );
+ } finally {
+ await app.close();
+ delete process.env.GITHUB_WEBHOOK_SECRET;
+ }
});
-test('API bearer auth gates scans but health reveals no secrets',async()=>{
- process.env.API_TOKEN='test-token-at-least-thirty-two-characters';const {app}=await createApp(new Store(':memory:'));
- try{assert.equal((await app.inject('/api/v1/scans')).statusCode,401);assert.equal((await app.inject({url:'/api/v1/scans',headers:{authorization:'Bearer '+process.env.API_TOKEN}})).statusCode,200);const health=await app.inject('/api/v1/health');assert.equal(health.json().authRequired,true);assert(!health.body.includes(process.env.API_TOKEN));}finally{await app.close();delete process.env.API_TOKEN;}
+test("API bearer auth gates scans but health reveals no secrets", async () => {
+ process.env.API_TOKEN = "test-token-at-least-thirty-two-characters";
+ const { app } = await createApp(new Store(":memory:"));
+ try {
+ assert.equal((await app.inject("/api/v1/scans")).statusCode, 401);
+ assert.equal(
+ (
+ await app.inject({
+ url: "/api/v1/scans",
+ headers: { authorization: "Bearer " + process.env.API_TOKEN },
+ })
+ ).statusCode,
+ 200,
+ );
+ const health = await app.inject("/api/v1/health");
+ assert.equal(health.json().authRequired, true);
+ assert(!health.body.includes(process.env.API_TOKEN));
+ } finally {
+ await app.close();
+ delete process.env.API_TOKEN;
+ }
});
-test('SQLite persists scans, caches and delivery deduplication across reopen',async()=>{
- const {mkdtempSync,rmSync}=await import('node:fs');const {tmpdir}=await import('node:os');const {join}=await import('node:path');const dir=mkdtempSync(join(tmpdir(),'ripple-test-'));const path=join(dir,'db.sqlite');
- try{let db=new Store(path);db.save({id:'saved',status:'queued',name:'demo',mode:'demo',ref:'HEAD',createdAt:new Date().toISOString(),connectors:[]},{mode:'demo'});db.cachePut('test','value',60000);assert(db.dedupe('delivery'));db.close();db=new Store(path);assert.equal(db.get('saved')?.status,'queued');assert.equal(db.cacheGet('test'),'value');assert(!db.dedupe('delivery'));assert.equal(db.pending().length,1);db.close();}finally{rmSync(dir,{recursive:true,force:true});}
+test("SQLite persists scans, caches and delivery deduplication across reopen", async () => {
+ const { mkdtempSync, rmSync } = await import("node:fs");
+ const { tmpdir } = await import("node:os");
+ const { join } = await import("node:path");
+ const dir = mkdtempSync(join(tmpdir(), "ripple-test-"));
+ const path = join(dir, "db.sqlite");
+ try {
+ let db = new Store(path);
+ db.save(
+ {
+ id: "saved",
+ status: "queued",
+ name: "demo",
+ mode: "demo",
+ ref: "HEAD",
+ createdAt: new Date().toISOString(),
+ connectors: [],
+ },
+ { mode: "demo" },
+ );
+ db.cachePut("test", "value", 60000);
+ assert(db.dedupe("delivery"));
+ db.close();
+ db = new Store(path);
+ assert.equal(db.get("saved")?.status, "queued");
+ assert.equal(db.cacheGet("test"), "value");
+ assert(!db.dedupe("delivery"));
+ assert.equal(db.pending().length, 1);
+ db.close();
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
});
diff --git a/tests/core.test.ts b/tests/core.test.ts
index 640ed80..fd21ba9 100644
--- a/tests/core.test.ts
+++ b/tests/core.test.ts
@@ -1,61 +1,216 @@
-import {test} from 'node:test';
-import assert from 'node:assert/strict';
-import {node,finalize,risks,simulate,purl} from '../src/server/graph.js';
-import {demoGraph} from '../src/server/demo.js';
-import {parseManifest} from '../src/server/parsers.js';
-import {canonicalRepository,mergeAdvisories,Vulnerabilities} from '../src/server/connectors.js';
-import {Store} from '../src/server/store.js';
-import {Transport} from '../src/server/transport.js';
-import {sbom} from '../src/server/sbom.js';
-import type {Scan} from '../src/shared/types.js';
-const origin={source:'test',url:'https://example.com',retrievedAt:'2026-09-10T00:00:00Z'};
-test('cycle-safe reverse reachability excludes self and finds shortest service path',()=>{
- const a=node('npm','app','1.0.0','service'),b=node('npm','b','1.0.0'),c=node('npm','c','1.0.0'),d=node('npm','unrelated','1.0.0');
- const g=finalize({nodes:[a,b,c,d],edges:[{from:a.id,to:b.id},{from:b.id,to:c.id},{from:c.id,to:b.id}],warnings:[]});
- assert.deepEqual(simulate(g,c.id).affected.sort(),[a.id,b.id].sort());assert.deepEqual(simulate(g,c.id).paths,[[a.id,b.id,c.id]]);assert.equal(c.depth,2);assert.equal(d.depth,null);assert.equal(simulate(g,c.id).impact,67);
-});
-test('risk factors are deterministic, bounded, and unknown data stays explicit',()=>{
- const g=demoGraph();assert.deepEqual(risks(g),risks(structuredClone(g)));assert(risks(g).every(r=>r.score>=0&&r.score<=100));
- const n=node('npm','unknown','1.0.0');const r=risks(finalize({nodes:[n],edges:[],warnings:[]}))[0];assert.equal(r.score,0);assert.equal(r.coverage,'unchecked');
-});
-test('PURLs preserve scope and normalize Python identity',()=>{assert.equal(purl('pypi','Some_Package.Name','1.0.0'),'pkg:pypi/some-package-name@1.0.0');assert.equal(purl('maven','org.example:artifact','1.0'),'pkg:maven/org.example/artifact@1.0');assert.equal(purl('npm','@scope/pkg','1.0.0'),'pkg:npm/%40scope/pkg@1.0.0');});
-test('npm nested versions resolve nearest installed ancestor, not a matching name elsewhere',()=>{
- const graph=parseManifest('package-lock.json',JSON.stringify({name:'app',lockfileVersion:3,packages:{'':{version:'1.0.0',dependencies:{a:'1.0.0',b:'1.0.0'}},'node_modules/a':{version:'1.0.0',dependencies:{shared:'1.0.0'}},'node_modules/b':{version:'1.0.0',dependencies:{shared:'2.0.0'}},'node_modules/shared':{version:'1.0.0'},'node_modules/b/node_modules/shared':{version:'2.0.0'}}}));
- const b=graph.nodes.find(n=>n.name==='b')!;const shared2=graph.nodes.find(n=>n.name==='shared'&&n.version==='2.0.0')!;assert(graph.edges.some(e=>e.from===b.id&&e.to===shared2.id));assert.equal(shared2.depth,2);
-});
-test('Python pins accepted, ranges and source execution directives rejected',()=>{
- const graph=parseManifest('requirements.txt','requests==2.28.0\nurllib3==1.26.5');assert.equal(graph.nodes.length,3);assert(graph.warnings[0].includes('Flat'));
- for(const text of ['requests>=2','-r secrets.txt','https://example.com/x','thing @ git+https://example.com/repo'])assert.throws(()=>parseManifest('requirements.txt',text));
-});
-test('Maven resolved tree preserves transitive edges, POM rejects XML entities',()=>{
- const tree={groupId:'app',artifactId:'service',version:'1.0.0',children:[{groupId:'org.example',artifactId:'lib',version:'2.0',children:[{groupId:'org.example',artifactId:'child',version:'3.0'}]}]};
- const graph=parseManifest('dependency-tree.json',JSON.stringify(tree));assert.equal(graph.nodes[2].depth,2);
- assert.throws(()=>parseManifest('pom.xml',']> '));
- const pom=parseManifest('pom.xml','demo app 1.0 org.example lib 2.0 ');assert.equal(pom.nodes.length,2);assert(pom.warnings.some(w=>w.includes('direct')));
-});
-test('canonical GitHub identifiers prevent arbitrary server-side fetch targets',()=>{
- assert.equal(canonicalRepository('https://github.com/org/repo.git'),'org/repo');assert.equal(canonicalRepository('org/repo'),'org/repo');
- for(const input of ['http://localhost:3000/x','https://github.com.evil.test/a/b','https://github.com/a/b?token=x','https://user:pass@github.com/a/b','a/..'])assert.throws(()=>canonicalRepository(input));
-});
-test('advisory aliases deduplicate without dropping source provenance',()=>{
- const result=mergeAdvisories([{id:'GHSA-123',aliases:['CVE-2021-1234'],summary:'a',provenance:[origin]},{id:'CVE-2021-1234',aliases:[],summary:'b',cvss:9.8,provenance:[origin]}]);assert.equal(result.length,1);assert.equal(result[0].cvss,9.8);assert.equal(result[0].provenance.length,2);
-});
-test('connector cache records source and timestamp; allowlist blocks arbitrary URLs',async()=>{
- const store=new Store(':memory:');let calls=0;const http=new Transport(store,async()=>{calls++;return new Response(JSON.stringify({ok:true}),{headers:{'content-type':'application/json'}});});
- const a=await http.get('npm','https://registry.npmjs.org/a/1.0.0');const b=await http.get('npm','https://registry.npmjs.org/a/1.0.0');assert.deepEqual(a,b);assert.equal(calls,1);assert(a.provenance.retrievedAt);await assert.rejects(http.get('bad','https://127.0.0.1/private'));await assert.rejects(http.get('bad','https://registry.npmjs.org:8443/a'));store.close();
-});
-test('NVD enrichment uses mapped CVE IDs and extracts numeric primary CVSS',async()=>{
- const urls:string[]=[];const client=new Vulnerabilities({get:async(_source,url)=>{urls.push(url);return {data:{vulnerabilities:[{cve:{id:'CVE-2021-1234',metrics:{cvssMetricV31:[{type:'Primary',cvssData:{baseScore:9.8}}]}}}]} as any,provenance:origin};}});
- assert.equal((await client.nvd('CVE-2021-1234')).cvss,9.8);assert(urls[0].includes('cveId=CVE-2021-1234'));await assert.rejects(client.nvd('lodash'));
-});
-test('SPDX preserves every graph edge and exact package version',()=>{
- const graph=demoGraph();const scan:Scan={id:'test-scan',name:'demo',mode:'demo',ref:'HEAD',status:'completed',createdAt:origin.retrievedAt,completedAt:origin.retrievedAt,connectors:[],graph,risks:risks(graph)};
- const result=sbom(scan);assert.equal(result.spdxVersion,'SPDX-2.3');assert.equal(result.packages.length,graph.nodes.length);assert.equal(result.relationships.filter(r=>r.relationshipType==='DEPENDS_ON').length,graph.edges.length);assert(result.packages.every(p=>p.externalRefs[0].referenceLocator.startsWith('pkg:')));assert.equal(result.documentDescribes.length,4);
-});
-test('SPDX export conforms to the official 2.3 JSON schema',async()=>{
- const {default:Ajv}=await import('ajv');const {default:addFormats}=await import('ajv-formats');const {readFileSync}=await import('node:fs');
- const validator=new Ajv({strict:false,allErrors:true});addFormats(validator);
- const validate=validator.compile(JSON.parse(readFileSync('docs/schemas/spdx-2.3.schema.json','utf8')));
- const graph=demoGraph();const scan:Scan={id:'schema-test',name:'demo',mode:'demo',ref:'HEAD',status:'completed',createdAt:origin.retrievedAt,completedAt:origin.retrievedAt,connectors:[],graph};
- assert(validate(sbom(scan)),JSON.stringify(validate.errors));
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { node, finalize, risks, simulate, purl } from "../src/server/graph.js";
+import { demoGraph } from "../src/server/demo.js";
+import { parseManifest } from "../src/server/parsers.js";
+import { canonicalRepository, mergeAdvisories, Vulnerabilities } from "../src/server/connectors.js";
+import { Store } from "../src/server/store.js";
+import { Transport } from "../src/server/transport.js";
+import { sbom } from "../src/server/sbom.js";
+import type { Scan } from "../src/shared/types.js";
+const origin = { source: "test", url: "https://example.com", retrievedAt: "2026-09-10T00:00:00Z" };
+test("cycle-safe reverse reachability excludes self and finds shortest service path", () => {
+ const a = node("npm", "app", "1.0.0", "service"),
+ b = node("npm", "b", "1.0.0"),
+ c = node("npm", "c", "1.0.0"),
+ d = node("npm", "unrelated", "1.0.0");
+ const g = finalize({
+ nodes: [a, b, c, d],
+ edges: [
+ { from: a.id, to: b.id },
+ { from: b.id, to: c.id },
+ { from: c.id, to: b.id },
+ ],
+ warnings: [],
+ });
+ assert.deepEqual(simulate(g, c.id).affected.sort(), [a.id, b.id].sort());
+ assert.deepEqual(simulate(g, c.id).paths, [[a.id, b.id, c.id]]);
+ assert.equal(c.depth, 2);
+ assert.equal(d.depth, null);
+ assert.equal(simulate(g, c.id).impact, 67);
+});
+test("risk factors are deterministic, bounded, and unknown data stays explicit", () => {
+ const g = demoGraph();
+ assert.deepEqual(risks(g), risks(structuredClone(g)));
+ assert(risks(g).every((r) => r.score >= 0 && r.score <= 100));
+ const n = node("npm", "unknown", "1.0.0");
+ const r = risks(finalize({ nodes: [n], edges: [], warnings: [] }))[0];
+ assert.equal(r.score, 0);
+ assert.equal(r.coverage, "unchecked");
+});
+test("PURLs preserve scope and normalize Python identity", () => {
+ assert.equal(purl("pypi", "Some_Package.Name", "1.0.0"), "pkg:pypi/some-package-name@1.0.0");
+ assert.equal(purl("maven", "org.example:artifact", "1.0"), "pkg:maven/org.example/artifact@1.0");
+ assert.equal(purl("npm", "@scope/pkg", "1.0.0"), "pkg:npm/%40scope/pkg@1.0.0");
+});
+test("npm nested versions resolve nearest installed ancestor, not a matching name elsewhere", () => {
+ const graph = parseManifest(
+ "package-lock.json",
+ JSON.stringify({
+ name: "app",
+ lockfileVersion: 3,
+ packages: {
+ "": { version: "1.0.0", dependencies: { a: "1.0.0", b: "1.0.0" } },
+ "node_modules/a": { version: "1.0.0", dependencies: { shared: "1.0.0" } },
+ "node_modules/b": { version: "1.0.0", dependencies: { shared: "2.0.0" } },
+ "node_modules/shared": { version: "1.0.0" },
+ "node_modules/b/node_modules/shared": { version: "2.0.0" },
+ },
+ }),
+ );
+ const b = graph.nodes.find((n) => n.name === "b")!;
+ const shared2 = graph.nodes.find((n) => n.name === "shared" && n.version === "2.0.0")!;
+ assert(graph.edges.some((e) => e.from === b.id && e.to === shared2.id));
+ assert.equal(shared2.depth, 2);
+});
+test("Python pins accepted, ranges and source execution directives rejected", () => {
+ const graph = parseManifest("requirements.txt", "requests==2.28.0\nurllib3==1.26.5");
+ assert.equal(graph.nodes.length, 3);
+ assert(graph.warnings[0].includes("Flat"));
+ for (const text of [
+ "requests>=2",
+ "-r secrets.txt",
+ "https://example.com/x",
+ "thing @ git+https://example.com/repo",
+ ]) {
+ const partial = parseManifest("requirements.txt", text);
+ assert.equal(partial.nodes.filter((n) => n.kind === "package").length, 0);
+ assert(partial.warnings.length > 0);
+ }
+});
+test("Maven resolved tree preserves transitive edges, POM rejects XML entities", () => {
+ const tree = {
+ groupId: "app",
+ artifactId: "service",
+ version: "1.0.0",
+ children: [
+ {
+ groupId: "org.example",
+ artifactId: "lib",
+ version: "2.0",
+ children: [{ groupId: "org.example", artifactId: "child", version: "3.0" }],
+ },
+ ],
+ };
+ const graph = parseManifest("dependency-tree.json", JSON.stringify(tree));
+ assert.equal(graph.nodes[2].depth, 2);
+ assert.throws(() =>
+ parseManifest("pom.xml", ']> '),
+ );
+ const pom = parseManifest(
+ "pom.xml",
+ "demo app 1.0 org.example lib 2.0 ",
+ );
+ assert.equal(pom.nodes.length, 2);
+ assert(pom.warnings.some((w) => w.includes("direct")));
+});
+test("canonical GitHub identifiers prevent arbitrary server-side fetch targets", () => {
+ assert.equal(canonicalRepository("https://github.com/org/repo.git"), "org/repo");
+ assert.equal(canonicalRepository("org/repo"), "org/repo");
+ for (const input of [
+ "http://localhost:3000/x",
+ "https://github.com.evil.test/a/b",
+ "https://github.com/a/b?token=x",
+ "https://user:pass@github.com/a/b",
+ "a/..",
+ ])
+ assert.throws(() => canonicalRepository(input));
+});
+test("advisory aliases deduplicate without dropping source provenance", () => {
+ const result = mergeAdvisories([
+ { id: "GHSA-123", aliases: ["CVE-2021-1234"], summary: "a", provenance: [origin] },
+ { id: "CVE-2021-1234", aliases: [], summary: "b", cvss: 9.8, provenance: [origin] },
+ ]);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].cvss, 9.8);
+ assert.equal(result[0].provenance.length, 2);
+});
+test("connector cache records source and timestamp; allowlist blocks arbitrary URLs", async () => {
+ const store = new Store(":memory:");
+ let calls = 0;
+ const http = new Transport(store, async () => {
+ calls++;
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: { "content-type": "application/json" },
+ });
+ });
+ const a = await http.get("npm", "https://registry.npmjs.org/a/1.0.0");
+ const b = await http.get("npm", "https://registry.npmjs.org/a/1.0.0");
+ assert.deepEqual(a, b);
+ assert.equal(calls, 1);
+ assert(a.provenance.retrievedAt);
+ await assert.rejects(http.get("bad", "https://127.0.0.1/private"));
+ await assert.rejects(http.get("bad", "https://registry.npmjs.org:8443/a"));
+ store.close();
+});
+test("NVD enrichment uses mapped CVE IDs and extracts numeric primary CVSS", async () => {
+ const urls: string[] = [];
+ const client = new Vulnerabilities({
+ get: async (_source, url) => {
+ urls.push(url);
+ return {
+ data: {
+ vulnerabilities: [
+ {
+ cve: {
+ id: "CVE-2021-1234",
+ metrics: { cvssMetricV31: [{ type: "Primary", cvssData: { baseScore: 9.8 } }] },
+ },
+ },
+ ],
+ } as any,
+ provenance: origin,
+ };
+ },
+ });
+ assert.equal((await client.nvd("CVE-2021-1234")).cvss, 9.8);
+ assert(urls[0].includes("cveId=CVE-2021-1234"));
+ await assert.rejects(client.nvd("lodash"));
+});
+test("SPDX preserves every graph edge and exact package version", () => {
+ const graph = demoGraph();
+ const scan: Scan = {
+ id: "test-scan",
+ name: "demo",
+ mode: "demo",
+ ref: "HEAD",
+ status: "completed",
+ createdAt: origin.retrievedAt,
+ completedAt: origin.retrievedAt,
+ connectors: [],
+ graph,
+ risks: risks(graph),
+ };
+ const result = sbom(scan);
+ assert.equal(result.spdxVersion, "SPDX-2.3");
+ assert.equal(result.packages.length, graph.nodes.length);
+ assert.equal(
+ result.relationships.filter((r) => r.relationshipType === "DEPENDS_ON").length,
+ graph.edges.length,
+ );
+ assert(result.packages.every((p) => p.externalRefs[0].referenceLocator.startsWith("pkg:")));
+ assert.equal(result.documentDescribes.length, 4);
+});
+test("SPDX export conforms to the official 2.3 JSON schema", async () => {
+ const { default: Ajv } = await import("ajv");
+ const { default: addFormats } = await import("ajv-formats");
+ const { readFileSync } = await import("node:fs");
+ const validator = new Ajv({ strict: false, allErrors: true });
+ addFormats(validator);
+ const validate = validator.compile(
+ JSON.parse(readFileSync("docs/schemas/spdx-2.3.schema.json", "utf8")),
+ );
+ const graph = demoGraph();
+ const scan: Scan = {
+ id: "schema-test",
+ name: "demo",
+ mode: "demo",
+ ref: "HEAD",
+ status: "completed",
+ createdAt: origin.retrievedAt,
+ completedAt: origin.retrievedAt,
+ connectors: [],
+ graph,
+ };
+ assert(validate(sbom(scan)), JSON.stringify(validate.errors));
});
diff --git a/tests/discovery.test.ts b/tests/discovery.test.ts
new file mode 100644
index 0000000..8520d48
--- /dev/null
+++ b/tests/discovery.test.ts
@@ -0,0 +1,263 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { discoverProjects, type SourceFile } from "../src/server/discovery.js";
+import { resolveProject } from "../src/server/resolve-project.js";
+import { finalize, risks, simulate } from "../src/server/graph.js";
+import { osvAdvisory, cvssFromVector } from "../src/server/advisories.js";
+import { GitHub, enrichGraph } from "../src/server/connectors.js";
+import { Store } from "../src/server/store.js";
+import { Jobs } from "../src/server/jobs.js";
+import type { Project } from "../src/shared/types.js";
+const origin = {
+ source: "test fixture",
+ url: "https://example.com/fixture",
+ retrievedAt: "2026-09-13T00:00:00Z",
+};
+function file(filename: string, data: unknown): SourceFile {
+ return { filename, content: JSON.stringify(data), provenance: origin };
+}
+function project(path = ".", id = "app"): Project {
+ return {
+ id,
+ name: id,
+ path,
+ ecosystem: "npm",
+ manifest: (path === "." ? "" : path + "/") + "package.json",
+ lockfile: "package-lock.json",
+ files: [],
+ selected: true,
+ role: "project",
+ resolution: "resolved",
+ notes: [],
+ };
+}
+const lock = {
+ lockfileVersion: 3,
+ name: "root",
+ packages: {
+ "": { name: "root", version: "1.0.0", workspaces: ["apps/*", "packages/*"] },
+ "apps/a": { name: "app-a", version: "1.0.0", dependencies: { shared: "1.0.0" } },
+ "apps/b": { name: "app-b", version: "1.0.0", dependencies: { shared: "1.0.0" } },
+ "packages/shared": { name: "shared", version: "1.0.0", dependencies: { deep: "2.0.0" } },
+ "node_modules/shared": { link: true, resolved: "packages/shared" },
+ "node_modules/deep": { version: "2.0.0", dependencies: { leaf: "1.0.0" } },
+ "node_modules/leaf": { version: "1.0.0" },
+ },
+};
+test("25 manifest pairs group into 25 projects without a manifest-count failure", () => {
+ const files = Array.from({ length: 25 }, (_, i) => [
+ `apps/p${i}/package.json`,
+ `apps/p${i}/package-lock.json`,
+ ])
+ .flat()
+ .map((path) => ({ path, sha: path }));
+ const map = discoverProjects(files, "org/repo", "main", "abc");
+ assert.equal(map.projects.length, 25);
+ assert.equal(map.fileCount, 50);
+ assert(map.projects.every((p) => p.manifest && p.lockfile));
+});
+test("discovery distinguishes ecosystems, companion files, tooling, and empty repositories", () => {
+ const names = [
+ "package.json",
+ "package-lock.json",
+ "pnpm-lock.yaml",
+ "api/pyproject.toml",
+ "api/uv.lock",
+ "payments/pom.xml",
+ "payments/dependency-tree.json",
+ "tests/fixture/package.json",
+ "node_modules/fake/package.json",
+ ];
+ const map = discoverProjects(
+ names.map((path) => ({ path, sha: "" })),
+ "org/repo",
+ "main",
+ "abc",
+ );
+ assert.equal(map.projects.length, 4);
+ assert.equal(map.projects.find((p) => p.path === ".")?.lockfile, "package-lock.json");
+ assert.equal(map.projects.find((p) => p.path === "tests/fixture")?.selected, false);
+ assert.equal(
+ discoverProjects([{ path: "README.md", sha: "" }], "o/r", "main", "a").projects.length,
+ 0,
+ );
+});
+test("npm workspaces resolve symlinks and preserve shared indirect dependency paths", () => {
+ const source = file("package-lock.json", lock);
+ const a = resolveProject(project("apps/a", "a"), [source]);
+ const b = resolveProject(project("apps/b", "b"), [source]);
+ const graph = finalize({
+ nodes: [...a.nodes, ...b.nodes],
+ edges: [...a.edges, ...b.edges],
+ warnings: [],
+ });
+ const leaf = graph.nodes.find((n) => n.name === "leaf")!;
+ assert.equal(leaf.depth, 3);
+ const impact = simulate(graph, leaf.id);
+ assert.equal(impact.services.length, 2);
+ assert.equal(impact.pathCount, 2);
+ assert(impact.paths.every((p) => p.length === 4));
+ assert.equal(graph.nodes.filter((n) => n.name === "leaf").length, 1);
+});
+test("separate installed instances do not invent cross-project paths", () => {
+ const make = (dep: string) => ({
+ lockfileVersion: 3,
+ packages: {
+ "": { version: "1.0.0", dependencies: { shared: "1.0.0" } },
+ "node_modules/shared": { version: "1.0.0", dependencies: { [dep]: "1.0.0" } },
+ ["node_modules/" + dep]: { version: "1.0.0" },
+ },
+ });
+ const pA = { ...project(".", "a"), lockfile: "a/package-lock.json", path: "a" },
+ pB = { ...project(".", "b"), lockfile: "b/package-lock.json", path: "b" };
+ const a = resolveProject(pA, [file(pA.lockfile, make("leaf-a"))]),
+ b = resolveProject(pB, [file(pB.lockfile, make("leaf-b"))]);
+ const graph = finalize({
+ nodes: [...a.nodes, ...b.nodes],
+ edges: [...a.edges, ...b.edges],
+ warnings: [],
+ });
+ assert.deepEqual(simulate(graph, graph.nodes.find((n) => n.name === "leaf-a")!.id).services, [
+ "service-a",
+ ]);
+ assert.equal(
+ simulate(graph, graph.nodes.find((n) => n.name === "shared")!.id).services.length,
+ 2,
+ );
+});
+test("runtime and development usage propagates through dependencies", () => {
+ const data = {
+ lockfileVersion: 3,
+ packages: {
+ "": { version: "1.0.0", dependencies: { prod: "1.0.0" }, devDependencies: { tool: "1.0.0" } },
+ "node_modules/prod": { version: "1.0.0" },
+ "node_modules/tool": { version: "1.0.0", dev: true, dependencies: { helper: "1.0.0" } },
+ "node_modules/helper": { version: "1.0.0", dev: true },
+ },
+ };
+ const g = resolveProject(project(), [file("package-lock.json", data)]);
+ assert.equal(g.nodes.find((n) => n.name === "prod")?.scope, "runtime");
+ assert.equal(g.nodes.find((n) => n.name === "helper")?.scope, "development");
+ assert.equal(g.nodes.find((n) => n.name === "helper")?.depth, 2);
+});
+test("OSV CVSS is computed from the source vector; unrelated package fixes never attach", () => {
+ assert.equal(cvssFromVector("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"), 9.8);
+ assert.equal(cvssFromVector("made-up"), undefined);
+ const n = resolveProject(project(), [
+ file("package-lock.json", {
+ lockfileVersion: 3,
+ packages: {
+ "": { version: "1.0.0", dependencies: { a: "1.0.0" } },
+ "node_modules/a": { version: "1.0.0" },
+ },
+ }),
+ ]).nodes.find((n) => n.name === "a")!;
+ const record = {
+ id: "TEST-1",
+ severity: [{ type: "CVSS_V3", score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }],
+ affected: [
+ {
+ package: { name: "a", ecosystem: "npm" },
+ ranges: [{ type: "SEMVER", events: [{ introduced: "0" }, { fixed: "1.1.0" }] }],
+ },
+ {
+ package: { name: "other", ecosystem: "npm" },
+ ranges: [{ events: [{ fixed: "999.0.0" }] }],
+ },
+ ],
+ };
+ const advisory = osvAdvisory(record, n, origin)!;
+ assert.equal(advisory.cvss, 9.8);
+ assert.equal(advisory.fixed, "1.1.0");
+ assert.equal(osvAdvisory({ ...record, affected: [record.affected[1]] }, n, origin), undefined);
+});
+test("source failure leaves packages partially checked, with no fabricated findings", async () => {
+ const graph = resolveProject(project(), [
+ file("package-lock.json", {
+ lockfileVersion: 3,
+ packages: {
+ "": { version: "1.0.0", dependencies: { a: "1.0.0" } },
+ "node_modules/a": { version: "1.0.0" },
+ },
+ }),
+ ]);
+ await enrichGraph(
+ graph,
+ {
+ get: async () => {
+ throw new Error("HTTP 429");
+ },
+ },
+ () => {},
+ );
+ assert.equal(graph.nodes.find((n) => n.name === "a")!.coverage, "partial");
+ assert(graph.warnings.some((w) => w.includes("incomplete")));
+ assert.equal(graph.nodes.flatMap((n) => n.advisories).length, 0);
+});
+test("map selection is persisted, renaming does not change relations, and unknown IDs are rejected", async () => {
+ const store = new Store(":memory:");
+ const jobs = new Jobs(store);
+ const created = jobs.create({ mode: "demo" });
+ await jobs.drain();
+ const scan = store.get(created.id)!;
+ assert.equal(scan.status, "mapped");
+ const p = scan.repositoryMap!.projects[0];
+ assert.throws(() => jobs.analyze(scan.id, [{ id: "missing", name: "fake" }]));
+ jobs.analyze(scan.id, [{ id: p.id, name: "My storefront" }]);
+ await jobs.drain();
+ const done = store.get(scan.id)!;
+ assert.equal(done.status, "completed");
+ assert.equal(done.graph!.nodes.filter((n) => n.kind === "service").length, 1);
+ assert.equal(done.graph!.nodes.find((n) => n.kind === "service")?.name, "My storefront");
+ await jobs.close();
+ store.close();
+});
+test("Github discovery reads more than 20 manifests using commit-pinned public content", async () => {
+ const sha = "a".repeat(40);
+ const tree = Array.from({ length: 26 }, (_, i) => ({
+ path: `apps/p${i}/package.json`,
+ type: "blob",
+ sha: String(i),
+ }));
+ const requests: string[] = [];
+ const github = new GitHub({
+ get: async (_name, url) => {
+ requests.push(url);
+ const data = url.includes("raw.githubusercontent.com")
+ ? JSON.stringify({ name: "project", dependencies: {} })
+ : url.endsWith("/o/r")
+ ? { default_branch: "main" }
+ : url.includes("/commits/")
+ ? { sha }
+ : url.includes("/trees/")
+ ? { tree }
+ : {};
+ return { data: data as any, provenance: origin };
+ },
+ });
+ const result = await github.discover("o/r", "HEAD");
+ assert.equal(result.map.projects.length, 26);
+ assert.equal(result.map.ref, "main");
+ assert.equal(result.map.commit, sha);
+ assert.equal(requests.filter((u) => u.includes("/" + sha + "/apps/")).length, 26);
+});
+test("large graphs are retained instead of failing at 2000 nodes", () => {
+ const g = resolveProject(project(), [
+ file("package-lock.json", {
+ lockfileVersion: 3,
+ packages: {
+ "": {
+ version: "1.0.0",
+ dependencies: Object.fromEntries(
+ Array.from({ length: 2100 }, (_, i) => ["p" + i, "1.0.0"]),
+ ),
+ },
+ ...Object.fromEntries(
+ Array.from({ length: 2100 }, (_, i) => ["node_modules/p" + i, { version: "1.0.0" }]),
+ ),
+ },
+ }),
+ ]);
+ assert.equal(g.nodes.length, 2101);
+ assert.deepEqual(risks(g), risks(g));
+});
From 4167cbfa12f33bc934a6fe2aee97a4f6480cce34 Mon Sep 17 00:00:00 2001
From: Jagpreet Singh
Date: Mon, 14 Sep 2026 02:55:32 +0530
Subject: [PATCH 2/5] Make repository confirmation and ripple investigation the
primary journey
---
DESIGN.md | 4 +-
PRODUCT.md | 2 +-
playwright.config.ts | 35 +-
src/server/app.ts | 32 +-
src/server/graph.ts | 7 +-
src/server/jobs.ts | 10 +-
src/web/components/DependencyGraph.tsx | 296 ++++++-
src/web/components/PackageDetails.tsx | 202 +++++
src/web/components/RepositoryMap.tsx | 134 +++
src/web/components/ScanProgress.tsx | 45 +
src/web/components/ui/hero-section-6.tsx | 12 +-
src/web/journey.css | 5 +
src/web/main.tsx | 1020 ++++++++++++++++++++--
tests/browser/landing.spec.ts | 62 +-
tests/browser/workspace.spec.ts | 88 +-
tests/discovery.test.ts | 12 +
16 files changed, 1790 insertions(+), 176 deletions(-)
create mode 100644 src/web/components/PackageDetails.tsx
create mode 100644 src/web/components/RepositoryMap.tsx
create mode 100644 src/web/components/ScanProgress.tsx
create mode 100644 src/web/journey.css
diff --git a/DESIGN.md b/DESIGN.md
index 0d50ba6..77497ac 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -4,6 +4,6 @@ Light technical workspace for extended daytime investigations. Graph is the main
Warm rust identifies the selected dependency and propagation. Deep green-gray represents application services; risk colors have text labels. White surfaces, pale gray canvas, restrained rules. Geist for interface, Geist Mono only for versions/PURLs/measurements.
-Persistent navigation, compact repository snapshot strip, four summary measures, graph with adjacent inspector, then a ranked table. Narrow screens stack the inspector and retain a scrollable graph with an equivalent accessible inventory. Every graph node supports keyboard selection. Reduced motion disables propagation animation.
+Compact navigation, repository discovery and project confirmation, visible scan stages, four summary measures, top priority, focused dependency graph with adjacent inspector, then a ranked table. Narrow screens stack the inspector and retain a scrollable graph with an equivalent accessible inventory. Every graph node supports keyboard selection. Reduced motion disables propagation animation.
-Assumed from the supplied graph-first engineering brief while the optional audience clarification remains unanswered. No separate visual reference was supplied.
+Preserves the existing identity and prioritizes the repository-to-ripple journey from the current developer and hackathon brief.
diff --git a/PRODUCT.md b/PRODUCT.md
index 2051949..6249288 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -5,7 +5,7 @@ web
## Stack
Delegated by the supplied specification. React/TypeScript, Fastify, Node 24 SQLite. A single container serves the API and built UI; replaceable connector/cache boundaries.
## Users
-Assumption pending optional clarification: engineers tracing vulnerable dependencies to affected application services.
+Developers tracing vulnerable dependencies to applications and services; hackathon viewers following a two-minute repository-to-ripple demonstration.
## Product Purpose
Map dependency graphs, rank deterministic risk, simulate reverse dependency impact, and export SPDX 2.3 JSON.
## Capabilities and Constraints
diff --git a/playwright.config.ts b/playwright.config.ts
index 662fa4b..f0f7421 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,2 +1,33 @@
-import {defineConfig,devices} from '@playwright/test';
-export default defineConfig({testDir:'./tests/browser',timeout:30000,fullyParallel:false,reporter:'list',use:{baseURL:'http://127.0.0.1:3000',trace:'retain-on-failure',launchOptions:process.env.PW_CHROME?{channel:'chrome'}:{}},webServer:{command:'npm start',url:'http://127.0.0.1:3000/api/v1/health',reuseExistingServer:!process.env.CI,timeout:30000},projects:[{name:'desktop',use:{...devices['Desktop Chrome'],viewport:{width:1440,height:1000}}},{name:'mobile',use:{...devices['iPhone 13'],defaultBrowserType:'chromium'}}]});
+import { defineConfig, devices } from "@playwright/test";
+const port = Number(process.env.E2E_PORT || 3100);
+export default defineConfig({
+ testDir: "./tests/browser",
+ timeout: 30000,
+ fullyParallel: false,
+ workers: 2,
+ reporter: "list",
+ use: {
+ baseURL: `http://127.0.0.1:${port}`,
+ trace: "retain-on-failure",
+ launchOptions: process.env.PW_CHROME ? { channel: "chrome" } : {},
+ },
+ webServer: {
+ command: "npm start",
+ env: {
+ PORT: String(port),
+ DB_PATH: "data/browser-tests.sqlite",
+ NODE_ENV: "test",
+ API_TOKEN: "",
+ },
+ url: `http://127.0.0.1:${port}/api/v1/health`,
+ reuseExistingServer: false,
+ timeout: 30000,
+ },
+ projects: [
+ {
+ name: "desktop",
+ use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 1000 } },
+ },
+ { name: "mobile", use: { ...devices["iPhone 13"], defaultBrowserType: "chromium" } },
+ ],
+});
diff --git a/src/server/app.ts b/src/server/app.ts
index 603418c..4a4f66e 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -71,7 +71,11 @@ export async function createApp(store = new Store(), startJobs = true) {
},
},
});
- await app.register(rateLimit, { max: 120, timeWindow: "1 minute" });
+ await app.register(rateLimit, {
+ max: 300,
+ timeWindow: "1 minute",
+ allowList: (request) => !request.url.startsWith("/api/"),
+ });
app.addHook("onRequest", async (req, reply) => {
if (
!req.url.startsWith("/api/v1/") ||
@@ -99,15 +103,13 @@ export async function createApp(store = new Store(), startJobs = true) {
const validation = error instanceof z.ZodError;
const status = validation ? 400 : err.statusCode || 500;
if (status >= 500) req.log.error({ err }, "Request failed");
- reply
- .code(status)
- .send({
- error: validation
- ? "Invalid request. Check the repository, ref or manifest fields."
- : status >= 500
- ? "Request failed. Check server logs and try again."
- : err.message,
- });
+ reply.code(status).send({
+ error: validation
+ ? "Invalid request. Check the repository, ref or manifest fields."
+ : status >= 500
+ ? "Request failed. Check server logs and try again."
+ : err.message,
+ });
});
app.get("/api/v1/health", async () => ({
status: "ok",
@@ -116,12 +118,10 @@ export async function createApp(store = new Store(), startJobs = true) {
authRequired: Boolean(process.env.API_TOKEN),
}));
app.get("/api/v1/scans", async () =>
- store
- .list()
- .map(({ graph, risks, ...scan }) => ({
- ...scan,
- packages: graph?.nodes.filter((n) => n.kind === "package").length || 0,
- })),
+ store.list().map(({ graph, risks, ...scan }) => ({
+ ...scan,
+ packages: graph?.nodes.filter((n) => n.kind === "package").length || 0,
+ })),
);
app.post(
"/api/v1/scans",
diff --git a/src/server/graph.ts b/src/server/graph.ts
index 7f80a1c..1469aaa 100644
--- a/src/server/graph.ts
+++ b/src/server/graph.ts
@@ -107,8 +107,11 @@ export function finalize(graph: Graph): Graph {
function indexGraph(graph: Graph) {
const parents = new Map();
const byPurl = new Map();
- graph.edges.forEach((e) => parents.set(e.to, [...(parents.get(e.to) || []), e]));
- graph.nodes.forEach((n) => byPurl.set(n.purl, [...(byPurl.get(n.purl) || []), n.id]));
+ for (const e of new Map(graph.edges.map((e) => [e.from + ":" + e.to, e])).values())
+ parents.set(e.to, [...(parents.get(e.to) || []), e]);
+ graph.nodes
+ .filter((n) => n.kind === "package")
+ .forEach((n) => byPurl.set(n.purl, [...(byPurl.get(n.purl) || []), n.id]));
return {
parents,
byPurl,
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 65e5a73..03b293c 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -52,7 +52,8 @@ export class Jobs {
progress: { stage: "discover", message: "Reading repository structure", completed: [] },
};
this.store.save(scan, input);
- this.kick();
+ if (input.mode === "demo") void this.run(scan);
+ else this.kick();
return scan;
}
analyze(id: string, selections: ProjectSelection[]) {
@@ -75,7 +76,8 @@ export class Jobs {
scan.status = "queued";
this.stage(scan, "extract", "Waiting to extract selected projects");
this.store.save(scan);
- this.kick();
+ if (scan.mode === "demo") void this.run(scan);
+ else this.kick();
return scan;
}
kick() {
@@ -135,11 +137,13 @@ export class Jobs {
this.store.saveFiles(scan.id, files);
} else if (input.mode === "demo") {
const graph = demoGraph();
+ scan.ref = "demo";
+ scan.commit = "offline-fixture";
scan.repositoryMap = {
repository: scan.name,
ref: "demo",
commit: "offline-fixture",
- fileCount: 8,
+ fileCount: 7,
warnings: ["Synthetic demo security data. No external APIs are used."],
projects: graph.nodes
.filter((n) => n.kind === "service")
diff --git a/src/web/components/DependencyGraph.tsx b/src/web/components/DependencyGraph.tsx
index 26d71c5..78e1829 100644
--- a/src/web/components/DependencyGraph.tsx
+++ b/src/web/components/DependencyGraph.tsx
@@ -1,36 +1,262 @@
-import { useMemo, useState } from 'react';
-import { Minus, Plus, Maximize2, MousePointer2 } from 'lucide-react';
-import type { Graph, Risk, Simulation } from '../../shared/types';
-import { Button } from './ui/Button';
-export function DependencyGraph({graph,risks,selected,onSelect,simulation}:{graph:Graph;risks:Risk[];selected:string;onSelect:(id:string)=>void;simulation:Simulation|null}) {
- const [zoom,setZoom]=useState(1);const [focus,setFocus]=useState(false);
- const positions=useMemo(()=>{
- const groups=new Map();
- graph.nodes.forEach(n=>{const col=n.kind==='service'?0:Math.min(n.depth??4,4);groups.set(col,[...(groups.get(col)||[]),n]);});
- const width=Math.max(930,(Math.max(...groups.keys())+1)*215);
- const height=Math.max(570,Math.max(...[...groups.values()].map(g=>g.length))*69+95);
- const pos=new Map();
- groups.forEach((nodes,col)=>nodes.forEach((n,i)=>pos.set(n.id,{x:40+col*215,y:88+(i+0.5)*(height-130)/nodes.length})));
- return {pos,width,height};
- },[graph]);
- const riskMap=new Map(risks.map(r=>[r.nodeId,r]));
- const affected=new Set(simulation?[simulation.nodeId,...simulation.affected]:[]);
- const neighbors=new Set([selected,...graph.edges.filter(e=>e.from===selected||e.to===selected).flatMap(e=>[e.from,e.to])]);
- return
-
-
-
1?`${zoom*700}px`:'650px'}} role="group" aria-label="Dependency map. Arrows point from a service or package to its dependency.">
-
-
- {['APPLICATIONS','DIRECT DEPENDENCIES','TRANSITIVE · DEPTH 2','TRANSITIVE · DEPTH 3','DEPTH 4+'].map((s,i)=>i*215+40{s})}
- {graph.edges.map(e=>{const a=positions.pos.get(e.from)!,b=positions.pos.get(e.to)!;const active=simulation?affected.has(e.from)&&affected.has(e.to):e.from===selected||e.to===selected;const muted=(simulation&&!active)||(focus&&(!neighbors.has(e.from)||!neighbors.has(e.to)));const start=a.x+155,end=b.x-5;return ;})}
- {graph.nodes.map(n=>{const p=positions.pos.get(n.id)!,risk=riskMap.get(n.id);const active=selected===n.id;const service=n.kind==='service';const muted=(simulation&&!affected.has(n.id))||(focus&&!neighbors.has(n.id));const color=risk?.level==='critical'?'#b5372e':risk?.level==='high'?'#c35b22':risk?.level==='medium'?'#9d780e':'#489684';const label=n.name.includes(':')?n.name.split(':').pop()!:n.name;return onSelect(n.id)} onKeyDown={e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();onSelect(n.id);}}} transform={`translate(${p.x},${p.y-22})`} opacity={muted?0.25:1}>
- {active&& }
-
- {label.length>19?label.slice(0,17)+'…':label} {service?'APPLICATION':n.version}
- ;})}
-
-
-
Critical High Medium Low
Select to inspect setZoom(v=>Math.max(0.7,v-0.15))}> {Math.round(zoom*100)}% setZoom(v=>Math.min(2,v+0.15))}> setZoom(1)}>
-
;
+import { useMemo, useState } from "react";
+import { Minus, Plus, Maximize2 } from "lucide-react";
+import type { Graph, Risk, Simulation } from "../../shared/types";
+import { Button } from "./ui/Button";
+export function DependencyGraph({
+ graph,
+ risks,
+ selected,
+ onSelect,
+ simulation,
+ paths,
+}: {
+ graph: Graph;
+ risks: Risk[];
+ selected: string;
+ onSelect: (id: string) => void;
+ simulation: Simulation | null;
+ paths?: Simulation | null;
+}) {
+ const [zoom, setZoom] = useState(1);
+ const [overview, setOverview] = useState(false);
+ const layout = useMemo(() => {
+ const trace = simulation || paths;
+ const focus = new Set([selected, ...(trace?.seedIds || []), ...(trace?.affected || [])]);
+ let nodes = overview && !simulation ? graph.nodes : graph.nodes.filter((n) => focus.has(n.id));
+ if (!nodes.length) nodes = graph.nodes;
+ // Bound rendering, never the underlying analysis. The table retains every package.
+ const selectedFirst = [...nodes].sort(
+ (a, b) =>
+ Number(b.id === selected) - Number(a.id === selected) ||
+ Number(b.kind === "service") - Number(a.kind === "service") ||
+ (trace?.distances?.[a.id] ?? 99) - (trace?.distances?.[b.id] ?? 99),
+ );
+ const shown = selectedFirst.slice(0, 70);
+ const included = new Set(shown.map((n) => n.id));
+ const rawEdges = (overview && !simulation ? graph.edges : trace?.edges || graph.edges).filter(
+ (e) => included.has(e.from) && included.has(e.to),
+ );
+ const edges = [...new Map(rawEdges.map((e) => [e.from + ":" + e.to, e])).values()];
+ const depth = new Map();
+ shown.filter((n) => n.kind === "service").forEach((n) => depth.set(n.id, 0));
+ // Use path positions to make the selected dependency the common right-hand destination.
+ for (const path of trace?.paths || [])
+ for (let i = 0; i < path.length; i++)
+ depth.set(path[i], Math.max(depth.get(path[i]) || 0, i));
+ const max = Math.min(8, Math.max(1, ...depth.values()));
+ const groups = new Map();
+ shown.forEach((n) => {
+ const col =
+ n.kind === "service"
+ ? 0
+ : !overview && trace?.seedIds?.includes(n.id)
+ ? max
+ : Math.min(8, depth.get(n.id) ?? n.depth ?? 1);
+ groups.set(col, [...(groups.get(col) || []), n]);
+ });
+ const columns = [...groups.keys()].sort((a, b) => a - b);
+ const height = Math.max(
+ 380,
+ Math.max(1, ...[...groups.values()].map((ns) => ns.length)) * 78 + 80,
+ );
+ const width = Math.max(880, columns.length * 240 + 40);
+ const positions = new Map();
+ columns.forEach((col, i) =>
+ groups
+ .get(col)!
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .forEach((n, j, ns) =>
+ positions.set(n.id, {
+ x: 25 + (i * (width - 235)) / Math.max(1, columns.length - 1),
+ y: 70 + ((j + 0.5) * (height - 100)) / ns.length,
+ }),
+ ),
+ );
+ return {
+ nodes: shown,
+ edges,
+ positions,
+ width,
+ height,
+ hidden: graph.nodes.length - shown.length,
+ clipped: nodes.length > 70,
+ };
+ }, [graph, selected, simulation, paths, overview]);
+ const riskByPurl = new Map(
+ risks.map((r) => [graph.nodes.find((n) => n.id === r.nodeId)?.purl, r]),
+ );
+ const affected = new Set([
+ selected,
+ ...(simulation?.seedIds || []),
+ ...(simulation?.affected || []),
+ ]);
+ return (
+
+
+
+
{simulation ? "Follow the ripple" : "Why is this package here?"}
+
+ {simulation
+ ? "Risk travels back through dependent packages to your applications."
+ : "Each line means “depends on”. Read application → package."}
+
+
+
+ setOverview(e.target.checked)}
+ disabled={!!simulation}
+ />
+ Show overview
+
+
+
+
1 ? `${zoom * 800}px` : "800px" }}
+ role="group"
+ aria-label="Dependency paths to selected package"
+ >
+
+
+
+
+
+
+
+
+
+ {layout.edges.map((e) => {
+ const a = layout.positions.get(e.from)!,
+ b = layout.positions.get(e.to)!;
+ const active = !!simulation && affected.has(e.from) && affected.has(e.to);
+ return (
+
+ );
+ })}
+ {layout.nodes.map((n) => {
+ const p = layout.positions.get(n.id)!;
+ const service = n.kind === "service";
+ const chosen = n.id === selected || paths?.seedIds?.includes(n.id);
+ const risk = riskByPurl.get(n.purl);
+ const isAffected = !!simulation && affected.has(n.id);
+ const title = n.name.includes(":") ? n.name.split(":").pop()! : n.name;
+ return (
+ onSelect(n.id)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect(n.id);
+ }
+ }}
+ className={`graph-node ${isAffected ? "reached" : ""}`}
+ style={{ animationDelay: `${(simulation?.distances?.[n.id] || 0) * 0.3}s` }}
+ >
+
+ {n.name} {n.version}
+
+ {chosen && (
+
+ )}
+
+
+
+ {title.length > 22 ? title.slice(0, 20) + "…" : title}
+
+
+ {service
+ ? isAffected
+ ? "AFFECTED APPLICATION"
+ : "APPLICATION / PROJECT"
+ : `${n.version}${n.advisories.length ? " · " + (risk?.level || "known") + " priority" : ""}`}
+
+
+ );
+ })}
+
+
+
+
+
+
+ Application
+
+
+
+ Known vulnerability
+
+
+
+ Selected
+
+
+
+
setZoom((z) => Math.max(0.7, z - 0.15))}>
+
+
+
{Math.round(zoom * 100)}%
+
setZoom((z) => Math.min(2, z + 0.15))}>
+
+
+
setZoom(1)}>
+
+
+
+
+ {layout.hidden > 0 && (
+
+ Showing {layout.nodes.length} of {graph.nodes.length} graph nodes
+ {layout.clipped ? " (display limit)" : " on the selected dependency paths"}. All package
+ versions remain available below.
+
+ )}
+
+ );
}
diff --git a/src/web/components/PackageDetails.tsx b/src/web/components/PackageDetails.tsx
new file mode 100644
index 0000000..76f893b
--- /dev/null
+++ b/src/web/components/PackageDetails.tsx
@@ -0,0 +1,202 @@
+import { ArrowRight, Radio, ExternalLink, Box } from "lucide-react";
+import type { Graph, PackageNode, Risk, Simulation } from "../../shared/types";
+import { Button } from "./ui/Button";
+export function PackageDetails({
+ node,
+ risk,
+ graph,
+ paths,
+ onTrace,
+ busy,
+}: {
+ node: PackageNode;
+ risk?: Risk;
+ graph: Graph;
+ paths: Simulation | null;
+ onTrace: () => void;
+ busy: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
{node.name}
+
+ {node.version} · {node.ecosystem}
+
+
+
+ {risk && (
+
+ Ripple Priority
+
+ {risk.score}
+ /100
+
+ {risk.level} priority
+
+ )}
+
+ {risk?.reasons?.map((reason) => (
+
{reason}
+ ))}
+
+ {node.kind === "package" && (
+ <>
+
+
+ {node.advisories.length ? "Trace Ripple" : "Trace what-if impact"}
+
+
+
+ {node.advisories.length
+ ? "Show applications that depend on this package."
+ : "Hypothetical compromise; no vulnerability is asserted."}
+
+ >
+ )}
+
+ How did it enter the application?
+ {!paths ? (
+ Finding dependency paths…
+ ) : paths.services.length ? (
+ <>
+
+ Used by {paths.services.length} of {paths.totalServices} mapped projects through{" "}
+ {paths.pathsTruncated ? "at least " : ""}
+ {paths.pathCount} paths.
+
+ {paths.paths.slice(0, 5).map((path, i) => (
+
+ {path.map((id, j) => (
+
+ {j > 0 && } {graph.nodes.find((n) => n.id === id)?.name}
+
+ ))}
+
+ ))}
+ {paths.paths.length > 5 && (
+ + {paths.paths.length - 5} more paths in the impact view.
+ )}
+ {paths.relationshipUncertain && (
+
+ Some root links mean “listed in lockfile”; exact application ancestry is not fully
+ known.
+
+ )}
+ >
+ ) : (
+ No application path was recorded for this package.
+ )}
+
+
+ {node.advisories.length ? "Known vulnerabilities" : "Vulnerability coverage"}
+ {!node.advisories.length ? (
+
+ {node.coverage === "checked"
+ ? "No known vulnerabilities were found in this package by the sources successfully checked."
+ : node.coverage === "fixture"
+ ? "No advisory in the synthetic demo fixture."
+ : "This package was not fully checked. No security conclusion can be drawn."}
+
+ ) : (
+ node.advisories.map((a) => (
+
+ {a.id}
+ {a.summary}
+
+
+ Severity:{" "}
+ {a.cvss !== undefined
+ ? a.cvss >= 9
+ ? "Critical"
+ : a.cvss >= 7
+ ? "High"
+ : a.cvss >= 4
+ ? "Medium"
+ : "Low"
+ : a.severity || "not supplied"}
+
+ CVSS: {a.cvss ?? "not available"}
+
+ Exploitation:{" "}
+ {a.exploited
+ ? "known, source reported"
+ : "no evidence available in this snapshot"}
+
+
+ Fix: {a.fixed ? `source lists ${a.fixed}` : "no verified fixed version available"}
+
+
+ {a.aliases.length > 0 && {a.aliases.join(" · ")} }
+ {!!a.affected?.length && (
+
+ Affected version evidence
+ {a.affected?.map((text, i) => (
+ {text}
+ ))}
+
+ )}
+ {a.provenance.map((p, i) =>
+ /^https:\/\//.test(p.url) ? (
+
+ {p.source}
+
+
+ ) : (
+ {p.source}
+ ),
+ )}
+
+ Evidence retrieved {new Date(a.provenance[0]?.retrievedAt).toLocaleDateString()}
+
+
+ ))
+ )}
+
+ {risk && (
+
+ How the score is calculated
+ {Object.entries(risk.factors).map(([name, value]) => (
+
+
+ {
+ {
+ severity: "CVSS severity × 6",
+ exploit: "Known exploitation",
+ exposure: "Depth and runtime exposure",
+ blastRadius: "Affected projects and ancestors",
+ }[name]
+ }
+
+ +{value}
+
+ ))}
+
+ Deterministic priority, not an exploitation probability. Missing evidence may lower the
+ score.
+
+
+ )}
+
+ Package identity & source files
+ {node.purl}
+ {node.provenance.map((p, i) => (
+
+ {/^https:\/\//.test(p.url) ? (
+
+ {p.source}
+
+
+ ) : (
+ p.source
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/src/web/components/RepositoryMap.tsx b/src/web/components/RepositoryMap.tsx
new file mode 100644
index 0000000..ae55794
--- /dev/null
+++ b/src/web/components/RepositoryMap.tsx
@@ -0,0 +1,134 @@
+import { useState } from "react";
+import { FolderGit2, FileCode2, ArrowRight, AlertTriangle } from "lucide-react";
+import type { RepositoryMap as Map, ProjectSelection } from "../../shared/types";
+import { Button } from "./ui/Button";
+export function RepositoryMap({
+ map,
+ busy,
+ onAnalyze,
+}: {
+ map: Map;
+ busy: boolean;
+ onAnalyze: (selection: ProjectSelection[]) => void;
+}) {
+ const [projects, setProjects] = useState(map.projects);
+ const selected = projects.filter((p) => p.selected);
+ return (
+
+
+
+
+
+ We found {projects.length} {projects.length === 1 ? "project" : "projects"} in your
+ repository.
+
+
+ Choose what to analyze. Names are display labels; dependency relationships and security
+ findings come from the source files.
+
+
+
+
+
+ {selected.length} of {projects.length} selected · {map.fileCount} dependency files grouped
+
+
+ setProjects((ps) => ps.map((p) => ({ ...p, selected: !ps.every((p) => p.selected) })))
+ }
+ >
+ {projects.every((p) => p.selected) ? "Deselect all" : "Select all"}
+
+
+
+ {projects.map((project) => (
+
+
+ setProjects((ps) =>
+ ps.map((p) => (p.id === project.id ? { ...p, selected: e.target.checked } : p)),
+ )
+ }
+ />
+
+
+ Display name for {project.path}
+
+
+ setProjects((ps) =>
+ ps.map((p) => (p.id === project.id ? { ...p, name: e.target.value } : p)),
+ )
+ }
+ />
+
{project.path === "." ? "Repository root" : project.path}
+
+
+ Manifest: {project.manifest || "not present"}
+ {project.lockfile && Lockfile: {project.lockfile} }
+
+ {project.notes.map((note, i) => (
+
+ {note}
+
+ ))}
+
+
+
+ {project.ecosystem === "pypi"
+ ? "Python"
+ : project.ecosystem === "maven"
+ ? "Maven"
+ : "npm"}
+
+ {project.role}
+
+ {project.resolution === "resolved" ? "Lockfile available" : "Partial resolution"}
+
+
+
+ ))}
+
+ {!projects.length && (
+
+
+
No supported dependency files
+
Try a repository with package.json, a Python dependency file, or pom.xml.
+
+ )}
+ {map.warnings.map((w, i) => (
+
+
+ {w}
+
+ ))}
+ {projects.length > 0 && (
+
+
+ {selected.length} selected {selected.length === 1 ? "project" : "projects"} will be
+ checked at the pinned commit.
+
+
!p.name.trim())}
+ onClick={() => onAnalyze(selected.map(({ id, name }) => ({ id, name })))}
+ >
+ Analyze repository
+
+
+ )}
+
+ );
+}
diff --git a/src/web/components/ScanProgress.tsx b/src/web/components/ScanProgress.tsx
new file mode 100644
index 0000000..f3011de
--- /dev/null
+++ b/src/web/components/ScanProgress.tsx
@@ -0,0 +1,45 @@
+import { Check, LoaderCircle } from "lucide-react";
+import type { Scan } from "../../shared/types";
+const stages = [
+ ["discover", "Repository understood"],
+ ["map", "Projects confirmed"],
+ ["extract", "Dependencies extracted"],
+ ["resolve", "Relationships resolved"],
+ ["vulnerabilities", "Known vulnerabilities checked"],
+ ["risk", "Ripple Priority calculated"],
+ ["ripple", "Impact paths prepared"],
+] as const;
+export function ScanProgress({ scan }: { scan: Scan }) {
+ const progress = scan.progress;
+ return (
+
+ {progress?.message || "Preparing your scan"}
+ {scan.name} · Your source files are read, never executed.
+ {progress?.total !== undefined && (
+
+ {progress.current || 0} / {progress.total}
+
+ )}
+
+ {stages.map(([id, label]) => {
+ const done = progress?.completed.includes(id) || scan.status === "completed";
+ const current = progress?.stage === id;
+ return (
+
+ {done ? (
+
+ ) : current ? (
+
+ ) : (
+
+ )}
+ {label}
+ {current && In progress }
+
+ );
+ })}
+
+ Partial results stay visible if a source or project cannot be fully analyzed.
+
+ );
+}
diff --git a/src/web/components/ui/hero-section-6.tsx b/src/web/components/ui/hero-section-6.tsx
index d3684e3..5c285a2 100644
--- a/src/web/components/ui/hero-section-6.tsx
+++ b/src/web/components/ui/hero-section-6.tsx
@@ -131,13 +131,7 @@ const Iphone15Pro: React.FC = ({
);
};
-const navItems = [
- { name: 'Product', href: '/app' },
- { name: 'Dependencies', href: '/app#inventory' },
- { name: 'Scan history', href: '/app#scans' },
- { name: 'Connectors', href: '/app#connectors' },
- { name: 'Methodology', href: '/app#methodology' },
-];
+const navItems = [{ name: 'Scan repository', href: '/app#new' }, { name: 'Explore demo', href: '/app#demo' }];
export default function HeroSection6() {
const textVariants: Variants = {
@@ -179,7 +173,7 @@ export default function HeroSection6() {
Docs
-
+
Open app
@@ -212,7 +206,7 @@ export default function HeroSection6() {
every service you ship
-
+
Explore the demo
diff --git a/src/web/journey.css b/src/web/journey.css
new file mode 100644
index 0000000..afb33f0
--- /dev/null
+++ b/src/web/journey.css
@@ -0,0 +1,5 @@
+.journey-app{min-height:100vh;background:#f7f8fa}.journey-header{height:72px;display:flex;align-items:center;gap:40px;padding:0 38px;background:#fff;border-bottom:1px solid #dfe6eb}.journey-header .brand{height:auto;padding:0;font-size:22px;color:#243c3d;letter-spacing:-.6px}.journey-header .brand svg{color:#b95833}.journey-header nav{display:flex;align-items:center;gap:24px}.journey-header nav button{background:none;border:0;padding:12px 0;color:#71828c;font-size:12px}.journey-header nav button.active{color:#a7492c}.journey-header>.button{margin-left:auto}.journey-main{max-width:1500px;padding:30px 38px 0}.journey-title{margin-bottom:24px}.journey-title h1{font-size:30px;letter-spacing:-1px;font-weight:550;line-height:1.25}.journey-title>p{margin-top:8px;font-size:12px;color:#657884}.commit-id{display:block;margin-top:6px;font:10px var(--mono);color:#657b87;overflow-wrap:anywhere}.journey-app .repository-strip{padding:14px 18px}.journey-app .repository-strip>.status-badge{margin-left:auto}.journey-app .repo-identity{min-width:0}.journey-app .scan-layout{margin:25px auto;max-width:1030px}.journey-app .scan-notes h2{font-size:32px;line-height:1.3}.journey-app .scan-notes .demo-choice{margin-top:25px}.repository-map{margin-top:28px}.map-intro{display:flex;align-items:flex-start;gap:16px;padding-bottom:18px;color:#385951}.map-intro h2{font-size:21px;font-weight:550;letter-spacing:-.5px;color:#263e49}.map-intro p{max-width:78ch;color:#647a86;margin-top:7px;font-size:12px}.map-actions{display:flex;justify-content:space-between;align-items:center;margin:6px 0 12px;color:#607782;font-size:12px}.project-list{background:white;border:1px solid #dae3e9;border-radius:10px;overflow:hidden}.project-row{display:flex;align-items:flex-start;gap:17px;padding:22px;border-bottom:1px solid #e7ecef}.project-row:last-child{border:0}.project-row:not(.included){background:#f8fafb}.project-row>input{margin-top:9px;width:16px;height:16px;accent-color:#345b50;flex-shrink:0}.project-main{flex:1;min-width:0}.project-name{font-size:16px;color:#2c424d;font-weight:550;border:1px solid transparent;border-radius:4px;padding:4px 6px;margin-left:-7px;background:transparent;max-width:100%;width:360px}.project-name:hover,.project-name:focus{border-color:#d8e2e7;background:white}.project-main>code{display:block;font:11px var(--mono);color:#667f8b;margin:4px 0 10px;overflow-wrap:anywhere}.project-files{display:flex;align-items:center;gap:8px 15px;flex-wrap:wrap;font-size:11px;color:#6e808c}.project-files span{overflow-wrap:anywhere;max-width:100%}.project-note{color:#6d7c86;font-size:11px;margin-top:8px;max-width:85ch}.project-tags{display:grid;gap:8px;text-align:right;white-space:nowrap;color:#73868e;font-size:10px;flex-shrink:0}.ecosystem-label{color:#425e69;font-size:12px;font-weight:600}.resolution-good{color:#37765b}.resolution-partial{color:#92632e}.map-confirm{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:22px;padding:18px 0;border-top:1px solid #dfe7eb}.map-confirm p{color:#627c89;font-size:12px}.map-confirm .primary{min-height:41px;padding:10px 20px;font-size:12px}.coverage-callout{margin:18px 0;display:block;padding:13px 16px;border:1px solid #e7d8be;border-radius:7px;background:#fffaf0;color:#805d2c;font-size:11px;line-height:1.6}.coverage-callout>summary{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:500}.coverage-callout p{margin-top:10px}.coverage-callout>svg{vertical-align:middle;margin-right:8px}.journey-progress{max-width:700px;margin:42px auto;padding:25px 35px;background:white;border:1px solid #e0e7ec;border-radius:10px}.journey-progress h2{font-size:22px;font-weight:500;letter-spacing:-.5px}.journey-progress p{font-size:12px;color:#6b7e88;margin-top:10px}.journey-progress ol{list-style:none;padding:0;margin:25px 0}.journey-progress li{display:flex;align-items:center;gap:12px;padding:13px 0;border-bottom:1px solid #eef1f3;color:#7a8b95;font-size:13px}.journey-progress li.current{color:#a54a2d}.journey-progress li.done{color:#3d7964}.journey-progress small{margin-left:auto;font-size:10px}.stage-dot{width:8px;height:8px;border:1px solid #aebdc6;border-radius:50%;margin:0 4px}.work-count{font:20px var(--mono);color:#a15530;margin-top:20px}.result-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:15px;padding:22px 0;color:#617683;font-size:11px}.result-summary strong{font-size:21px;font-weight:550;color:#304955;margin-right:5px;font-variant-numeric:tabular-nums}.top-risk{display:flex;align-items:center;justify-content:space-between;gap:28px;padding:22px 25px;background:#fff;border:1px solid #e0dbd4;border-radius:10px;margin:18px 0 24px}.top-risk h2{font-size:23px;font-weight:560;letter-spacing:-.5px;overflow-wrap:anywhere}.top-version{font-size:11px;color:#74818a;margin:6px 0 13px}.reason-chips{display:flex;flex-wrap:wrap;gap:7px}.reason-chips>span{font-size:10px;border:1px solid #e7dfd7;border-radius:4px;color:#8c6042;background:#fcf8f4;padding:5px 8px}.top-priority{text-align:right;min-width:180px}.top-priority>span{font-size:10px;color:#7e7569}.top-priority>strong{font-size:31px;display:block;font-weight:560;letter-spacing:-1px;color:#aa492d}.top-priority small{font-size:13px;letter-spacing:0;color:#7c8990;font-weight:400;margin-left:4px}.top-priority .ripple-button{margin:8px 0 0;width:100%;justify-content:center;font-size:12px}.top-priority .ripple-button svg:last-child{margin-left:0}.journey-app .analysis-toolbar{margin:22px 0 12px}.journey-app .analysis-toolbar>span{font-size:10px;color:#6e838c}.journey-app .analysis-layout{grid-template-columns:minmax(0,1fr) 315px;gap:20px}.journey-app .graph-title{padding:19px}.journey-app .graph-title h2{font-size:16px}.journey-app .graph-title p{font-size:11px}.journey-app .graph-scroll{min-height:370px;max-height:560px;align-items:flex-start}.journey-app .node-name{font-size:12px}.journey-app .node-version{font-size:8px}.journey-app .toggle{display:flex;font-size:10px}.journey-app .legend>span{font-size:10px;color:#5c727d}.legend-root{width:11px;height:9px;background:#2b4442;border-radius:2px}.legend-selection{height:10px;width:10px;border:2px solid #b34c2d;border-radius:3px}.journey-app .graph-tools>span{font-size:10px;color:#5e7480}.graph-context{border-top:1px solid #e0e8ec;padding:10px 15px;color:#627682;font-size:10px;background:#fff}.ripple-workspace{scroll-margin-top:20px}.ripple-impact{display:flex;align-items:center;gap:17px;background:#fcece1;border:1px solid #e6c5ae;border-radius:8px;padding:19px 22px;margin-bottom:16px;color:#9e4727}.ripple-impact h2{font-size:20px;font-weight:550;letter-spacing:-.4px}.ripple-impact p{font-size:12px;margin-top:5px}.ripple-impact small{display:block;font-size:10px;line-height:1.5;margin-top:7px;color:#885838}.ripple-impact>.button{margin-left:auto;border:0;background:transparent}.graph-node.reached{animation:node-reached .7s ease-out both}@keyframes node-reached{0%{opacity:.35}100%{opacity:1}}.ripple-edge{animation:flow 1s linear infinite}@keyframes flow{to{stroke-dashoffset:22}}.journey-app .package-details{display:block}.journey-app .package-title h2{font-size:17px}.journey-app .package-title>div>span{font-size:11px}.journey-app .package-risk{padding-bottom:11px}.detail-reasons{padding:0 18px 15px}.detail-reasons p{font-size:11px;color:#647984;margin-top:4px}.detail-reasons p::before{content:'·';margin-right:7px;color:#b76240}.journey-app .inspector-section h3{font-size:12px}.journey-app .inspector-section>p{font-size:11px;line-height:1.6}.detail-path{font-size:10px;color:#55716c;line-height:1.8;padding:10px 0;border-bottom:1px solid #e8eeee}.detail-path span{display:inline}.detail-path svg{vertical-align:middle;margin:0 3px;color:#81998f}.advisory-facts{display:grid;gap:6px;font-size:10px;color:#526c79;margin:9px 0}.journey-app .advisory>strong{font-size:11px}.journey-app .advisory p{font-size:11px}.journey-app .advisory a{font-size:10px}.journey-app .advisory>small{font-size:9px;overflow-wrap:anywhere}.journey-app .provenance{font-size:10px}.journey-app .provenance .factor{font-size:10px}.journey-app .ripple-paths{padding:18px;background:#fffdf9}.journey-app .ripple-paths h2{font-size:14px}.path-step{display:inline-flex;align-items:center;gap:8px}.journey-app .path button{font-size:10px}.journey-app .table-heading h2{font-size:17px}.journey-app th{font-size:11px}.journey-app td{font-size:11px;padding-top:15px;padding-bottom:15px}.journey-app .package-link strong{font-size:12px}.journey-app .package-link small{font-size:10px}.coverage-label{display:block;font-size:9px;color:#687e88;margin-top:3px}.no-findings{padding:20px;background:#fff;border:1px solid #dce5e9;border-radius:8px}.no-findings h2{font-size:20px;font-weight:500}.no-findings p{font-size:12px;color:#697f89;margin-top:9px}.project-outcomes{margin-top:25px;color:#667d88;font-size:11px;line-height:1.7}.project-outcomes summary{cursor:pointer}.project-outcomes p{margin:10px 0}.journey-app .page-footer{font-size:10px}.journey-app .page-footer>div{display:flex;gap:18px}.journey-app .page-footer button{font-size:10px}.partial-text{color:#946432!important}.journey-app .history-list{min-height:350px}.journey-app .history-row{border:1px solid #e3e9ed;margin-bottom:8px;border-radius:7px}.journey-app .view-tabs button{font-size:12px}.journey-app .table-filters>select{padding:8px 10px;border:1px solid #d9e3e9;border-radius:6px;background:white;color:#48616e}
+@media(max-width:1100px){.journey-header{padding:0 24px}.journey-main{padding:25px 24px 0}.journey-app .analysis-layout{grid-template-columns:minmax(0,1fr) 285px;gap:13px}.result-summary{grid-template-columns:1fr 1fr;gap:13px}.journey-app .toggle{display:none}.journey-app .legend>span{font-size:9px}.journey-app .graph-footer{flex-wrap:wrap}}
+@media(max-width:760px){.journey-header{height:64px;padding:0 17px;gap:20px}.journey-header .brand{font-size:18px;gap:6px}.journey-header .brand svg{width:20px}.journey-header nav{gap:15px}.journey-header nav button{font-size:10px}.journey-header>.button{padding:8px;font-size:0;gap:0}.journey-main{padding:23px 17px 0}.journey-title h1{font-size:25px}.journey-title>p{font-size:11px;line-height:1.8}.commit-id{font-size:9px}.journey-app .repository-strip>.status-badge{margin-left:0}.journey-app .repo-identity{min-width:220px}.map-intro{gap:10px}.map-intro h2{font-size:20px}.map-intro p{font-size:11px}.map-intro>svg{flex-shrink:0;width:22px}.map-actions{font-size:10px;gap:10px}.project-row{padding:15px 12px;gap:10px;flex-wrap:wrap}.project-main{min-width:200px}.project-name{font-size:14px;width:100%}.project-tags{margin-left:27px;display:flex;flex-wrap:wrap;gap:8px;font-size:9px;text-align:left}.project-files{font-size:10px;display:grid;grid-template-columns:15px 1fr;gap:6px}.project-files span:last-child{grid-column:2}.project-note{font-size:10px}.map-confirm{flex-direction:column;align-items:stretch;gap:12px}.map-confirm p{font-size:11px}.top-risk{padding:18px;gap:16px;align-items:flex-start;flex-direction:column}.top-risk h2{font-size:21px}.top-priority{display:flex;align-items:center;width:100%;min-width:0;gap:9px;text-align:left}.top-priority>span{width:50px;flex-shrink:0}.top-priority>strong{font-size:27px;white-space:nowrap}.top-priority .ripple-button{width:auto;margin:0 0 0 auto;font-size:11px}.reason-chips>span{font-size:9px}.result-summary{padding:17px 0;gap:12px;font-size:10px;line-height:1.7}.result-summary strong{font-size:19px}.coverage-callout{font-size:10px;padding:11px}.journey-app .analysis-layout{grid-template-columns:1fr}.journey-app .analysis-toolbar>span{display:none}.journey-app .graph-title h2{font-size:15px}.journey-app .graph-title{padding:15px}.journey-app .graph-title p{font-size:10px}.journey-app .graph-scroll{min-height:360px}.journey-app .package-details{display:block}.journey-app .ripple-button{grid-column:auto;width:calc(100% - 36px);margin-left:18px;margin-right:18px}.journey-app .top-priority .ripple-button{width:auto;margin:0 0 0 auto}.journey-app .package-risk{padding:0 18px 13px}.ripple-impact{padding:15px;gap:12px;align-items:flex-start}.ripple-impact h2{font-size:17px;line-height:1.4}.ripple-impact p{font-size:11px}.ripple-impact small{font-size:9px}.ripple-impact>svg{width:20px;flex-shrink:0;margin-top:3px}.ripple-impact>.button{padding:0;min-height:20px}.journey-app .page-footer{flex-direction:column;gap:15px}.journey-app .page-footer>span{max-width:none}.journey-app .page-footer>div{gap:20px}.journey-app .page-footer button{font-size:9px}.journey-app .table-heading h2{font-size:16px}.journey-app .table-heading .button{font-size:10px}.journey-progress{padding:22px;margin:26px auto}.journey-progress h2{font-size:20px}.journey-progress li{font-size:11px}.journey-progress p{font-size:11px}.journey-app .scan-notes h2{font-size:27px}}
+.journey-app .map-column,.journey-app .package-details,.journey-app .graph-shell{min-width:0;max-width:100%}.journey-app .graph-scroll{width:100%;overflow:auto}.journey-app .package-details{max-height:950px;overflow:auto}.journey-app .graph-node.reached{animation:node-reached .7s ease-out backwards}.map-confirm{position:sticky;bottom:0;background:#f7f8faf5;z-index:2}
+@media(max-width:760px){.journey-app .analysis-layout{grid-template-columns:minmax(0,1fr)}.journey-app .package-details{max-height:none;overflow:visible}.map-confirm{padding:13px 0}.map-confirm p{display:none}.journey-app .graph-scroll .dependency-svg{min-width:800px!important}.journey-app .ripple-paths{overflow-wrap:anywhere}.journey-app .graph-footer .legend{flex-wrap:wrap}}
diff --git a/src/web/main.tsx b/src/web/main.tsx
index 702a8fe..d939ce4 100644
--- a/src/web/main.tsx
+++ b/src/web/main.tsx
@@ -1,59 +1,963 @@
-import React, { useEffect, useMemo, useState } from 'react';
-import {createRoot} from 'react-dom/client';
-import { Activity, ArrowDownToLine, ArrowRight, ArrowUpRight, Check, CheckCircle2, ChevronDown, ChevronRight, CircleHelp, Clock3, FileCode2, GitBranch, GitFork as Github, Layers3, LoaderCircle, Network, Plus, Radio, Search, ShieldCheck, SlidersHorizontal, Unplug, X, AlertTriangle, Box, RotateCcw, KeyRound, ExternalLink } from 'lucide-react';
-import '@fontsource-variable/geist';
-import '@fontsource-variable/geist-mono';
-import './styles.css';
-import './landing.css';
-import HeroSection6 from './components/ui/hero-section-6';
-import { Button } from './components/ui/Button';
-import { DependencyGraph } from './components/DependencyGraph';
-import type { Scan, ScanInput, Simulation } from '../shared/types';
-type View='overview'|'inventory'|'scans'|'connectors'|'methodology'|'new';
-let accessToken='';
-const VIEWS=['overview','inventory','scans','connectors','methodology','new'] as const;
-// Views are addressable as /app# so the landing page can link straight to one.
-function viewFromHash():View{const hash=window.location.hash.slice(1);return (VIEWS as readonly string[]).includes(hash)?hash as View:'overview';}
-async function api(path:string,body?:unknown):Promise{const response=await fetch('/api/v1'+path,{headers:{...(body?{'Content-Type':'application/json'}:{}),...(accessToken?{Authorization:'Bearer '+accessToken}:{})},method:body?'POST':'GET',body:body?JSON.stringify(body):undefined});const value=await response.json();if(!response.ok)throw new Error(value.error||'Request failed.');return value;}
-const safeUrl=(url:string)=>/^https:\/\//i.test(url)?url:undefined;
-function App(){
- const [scan,setScan]=useState(null);const [selected,setSelected]=useState('');const [simulation,setSimulation]=useState(null);const [view,setView]=useState(viewFromHash());const [error,setError]=useState('');const [busy,setBusy]=useState(false);const [history,setHistory]=useState([]);const [query,setQuery]=useState('');const [filter,setFilter]=useState('all');const [mode,setMode]=useState<'github'|'manifest'>('github');const [repository,setRepository]=useState('');const [ref,setRef]=useState('HEAD');const [filename,setFilename]=useState('package-lock.json');const [content,setContent]=useState('');const [auth,setAuth]=useState(false);const [token,setToken]=useState('');const [notice,setNotice]=useState('');const [menu,setMenu]=useState(false);
- async function load(id:string){const data=await api('/scans/'+id);setScan(data);if(data.graph){setSelected(prev=>data.graph!.nodes.some(n=>n.id===prev)?prev:data.risks?.[0]?.nodeId||data.graph!.nodes[0].id);}return data;}
- async function start(input:ScanInput,keepView=false){setBusy(true);setError('');setSimulation(null);try{const created=await api('/scans',input);localStorage.setItem('rippleguard-scan',created.id);setScan(created);if(!keepView)setView('overview');await load(created.id);}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
- async function initialize(){try{const health=await api<{authRequired:boolean}>('/health');if(health.authRequired&&!accessToken){setAuth(true);return;}setAuth(false);const last=localStorage.getItem('rippleguard-scan');if(last){try{await load(last);return;}catch{localStorage.removeItem('rippleguard-scan');}}await start({mode:'demo'},true);}catch(e){setError((e as Error).message);}}
- useEffect(()=>{void initialize();},[]);
- useEffect(()=>{const onHash=()=>setView(viewFromHash());window.addEventListener('hashchange',onHash);return()=>window.removeEventListener('hashchange',onHash);},[]);
- useEffect(()=>{if(!scan||['completed','failed'].includes(scan.status))return;const timer=setTimeout(()=>load(scan.id).catch(e=>setError(e.message)),1100);return()=>clearTimeout(timer);},[scan]);
- useEffect(()=>{if(view==='scans')api('/scans').then(setHistory).catch(e=>setError(e.message));},[view]);
- useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
- const graph=scan?.graph;const risks=scan?.risks||[];const selectedNode=graph?.nodes.find(n=>n.id===selected);const selectedRisk=risks.find(r=>r.nodeId===selected);const packages=graph?.nodes.filter(n=>n.kind==='package')||[];const vulnerable=packages.filter(n=>n.advisories.length).length;const maxRisk=risks[0]?.score||0;
- const rows=useMemo(()=>risks.filter(r=>{const n=graph?.nodes.find(n=>n.id===r.nodeId);return n&&n.name.toLowerCase().includes(query.toLowerCase())&&(filter==='all'||filter==='vulnerable'&&n.advisories.length>0||n.ecosystem===filter);}),[risks,graph,query,filter]);
- async function ripple(){if(!scan||!selected)return;setBusy(true);try{setSimulation(await api(`/scans/${scan.id}/simulate`,{nodeId:selected}));setView('overview');}catch(e){setError((e as Error).message);}finally{setBusy(false);}}
- async function exportSbom(){if(!scan)return;try{const data=await api(`/scans/${scan.id}/sbom`);const url=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download=`rippleguard-${scan.id}.spdx.json`;a.click();URL.revokeObjectURL(url);setNotice('SPDX 2.3 SBOM exported.');}catch(e){setError((e as Error).message);}}
- const pick=(id:string)=>{setSelected(id);setSimulation(null);};
- const nav=(target:View)=>{setView(target);setMenu(false);setError('');window.history.replaceState(null,'',target==='overview'?'/app':'/app#'+target);};
- const sidebar=<>{e.preventDefault();nav('overview');}}> RippleGuardv1 W My workspaceDependency intelligence
WORKSPACE
{([{id:'overview',icon:Network,label:'Overview'},{id:'inventory',icon:Layers3,label:'Dependencies'},{id:'scans',icon:Clock3,label:'Scan history'},{id:'connectors',icon:Unplug,label:'Connectors'}] as const).map(item=>nav(item.id)}>{item.label}{item.id==='inventory'&&packages.length>0&&{packages.length} } )} Evidence over guesswork. Source-backed facts. Explainable risk. Every time.
nav('methodology')}>How scoring works nav('methodology')}>Documentation JG Local workspaceSingle-tenant instance
>;
- return setMenu(!menu)}> Workspace {({overview:'Overview',inventory:'Dependencies',scans:'Scan history',connectors:'Connectors',methodology:'Documentation',new:'New scan'})[view]}
-
{view==='overview'?'See the risk. Trace the ripple.':view==='inventory'?'Every dependency, accounted for.':view==='scans'?'Your scan history.':view==='connectors'?'Connected to the source.':view==='new'?'Start with your source code.':'Risk you can explain.'} {view==='overview'?'Understand how a single dependency can affect your entire application.':view==='inventory'?'Prioritize packages with source-backed findings and transparent risk factors.':view==='scans'?'Revisit a snapshot, inspect its findings, and compare your investigations.':view==='connectors'?'Independent sources, explicit coverage, and no invented security facts.':view==='new'?'Import a GitHub repository or an exact dependency snapshot.':'The model, the evidence, and the limits behind every result.'}
{view!=='new'&&
nav('new')}>New scan }
- {error&&{error} {setError('');void initialize();}}>Retry setError('')}> }
- {auth?:
- view==='new'?A clearer picture starts here. RippleGuard reads dependency metadata. It never executes code from the repository you scan.
Exact package versions and PURLs Source-backed vulnerability findings Explainable blast-radius analysis SPDX 2.3 export Just exploring? Try an illustrative commerce application with npm, Python, and Maven packages.
void start({mode:'demo'})} disabled={busy}>Explore demo :
- view==='methodology'?From a package to the services it reaches Edges point from a dependent to its dependency. Ripple simulation traverses those edges in reverse, finds every reachable ancestor, and shows one shortest dependency path per affected application. Cycles are visited once.
Risk = severity + exploit signal + exposure + blast radius
Severity · 0–60 Highest known CVSS base score × 6. Missing CVSS contributes zero but remains unknown, never evidence of safety. Exploit signal · 0–15 15 only when an explicit source fact reports exploitation. Live V1 connectors currently do not supply this signal. Exposure · 0–10 10 ÷ shortest dependency depth, halved for development-only packages. Unreachable packages contribute zero. Blast radius · 0–15 15 × affected ancestors ÷ all other graph nodes. A topological contribution, not proof of exploitable code. Coverage is part of the result npm lockfile v2/v3 imports preserve resolved dependency relationships. Maven supports resolved dependency-tree JSON and a limited direct-only POM import. Python requirements and Pipfile.lock imports are flat snapshots; the importer does not infer transitive ancestry or evaluate environment markers.
OSV matches exact package identities to advisories. NVD adds CVSS for mapped CVE identifiers; name searches never establish applicability. Unavailable sources produce warnings. Unknown data can make the score a lower bound.
Use scores to prioritize investigation Critical: 80–100; high: 60–79; medium: 35–59; low: 0–34. A low score is not a security guarantee. Graph impact does not establish runtime reachability or exploitability. The demo uses labeled synthetic advisories.
SPDX 2.3 specification :
- view==='scans'?
Saved snapshots {history.length} scans {history.length?history.map(item=>{setSimulation(null);await load(item.id);localStorage.setItem('rippleguard-scan',item.id);nav('overview');}}>{item.name} {new Date(item.createdAt).toLocaleString()} · {item.ref} {item.status} ):No scans yet Start a scan to save your first dependency snapshot.
nav('new')}>Start a scan } :
- view==='connectors'?
Source health {scan?.mode==='demo'?'Demo mode · live sources not queried':'Status from the selected scan'} {(scan?.connectors||[]).map(c=>)}Health describes the latest request in this scan, not a continuous availability check. A disabled or unqueried source does not imply complete coverage.
:
- !scan?Opening your workspace Loading the latest dependency snapshot.
:
- scan.status==='failed'?This scan could not finish {scan.error}
nav('new')}>Try another input void start({mode:'demo'})}>Explore demo :
- scan.status!=='completed'?{scan.status==='queued'?'Your scan is queued':scan.status==='scanning'?'Resolving the dependency graph':'Checking the sources'} {scan.name}
{['queued','scanning','enriching','completed'].map(s=>{s} )} Live enrichment respects source rate limits. You can revisit this scan from history.
:
- <>{scan.name} {scan.commit?scan.commit.slice(0,7):scan.ref}· {scan.mode==='demo'?'Illustrative demo dataset':new Date(scan.createdAt).toLocaleString()}
{scan.mode==='demo'?:} {scan.mode==='demo'?'Demo snapshot':'Scan complete'} void exportSbom()}>Export SBOM
- Highest package risk =60?'risk-text':''}>{maxRisk}/100 {risks[0]?.level||'Low'}
Total dependencies {packages.length}{packages.filter(n=>n.depth===1).length} direct · {packages.filter(n=>(n.depth||0)>1).length} transitive
Packages with findings {vulnerable}of {packages.length} dependencies
Application services {graph?.nodes.filter(n=>n.kind==='service').length}in this dependency graph
- {view==='inventory'?:<>Dependency graph nav('inventory')}>Package inventory
{scan.mode==='demo'?'Deterministic demo':'Snapshot analysis'}{simulation&&
Ripple active: {selectedNode?.name} {simulation.affected.length} affected ancestors · {simulation.services.length} services · {simulation.impact}% graph impact
setSimulation(null)} aria-label="Clear simulation"> }{graph&&
} {selectedNode?<>PACKAGE INSPECTOR
{selectedNode.name} {selectedNode.ecosystem} · {selectedNode.version} RippleGuard score {selectedRisk?.score??'—'}/100 {selectedRisk&&{selectedRisk.level} risk }
Dependency depth {selectedNode.depth===0?'Application':selectedNode.depth===null?'Unreachable':`${selectedNode.depth} ${selectedNode.depth===1?'· Direct':'· Transitive'}`}
Known findings {selectedNode.advisories.length}
Dependent ancestors {selectedRisk?.ancestors??0}
Coverage {selectedNode.coverage==='fixture'?'Demo fixture':selectedNode.coverage}
void ripple()}>{simulation?'Run ripple again':'Simulate ripple'} Trace how this package connects to your application services.
Score breakdown {selectedRisk?Object.entries(selectedRisk.factors).map(([key,value])=>
{({severity:'Vulnerability severity',exploit:'Known exploitation',exposure:'Dependency exposure',blastRadius:'Blast radius'})[key]} +{value}
):
Application nodes are not scored.
}
Findings & sources {selectedNode.advisories.length} {selectedNode.advisories.length?selectedNode.advisories.map(a=>
{a.id}{a.cvss===undefined?'CVSS unknown':`CVSS ${a.cvss}`} {a.summary}
{a.fixed&&
Source lists fixes: {a.fixed} }{a.provenance.filter(p=>safeUrl(p.url)).slice(0,3).map((p,i)=>
{p.source} )}
):
{selectedNode.coverage==='checked'?'No advisories returned by queried sources.':'No findings in this snapshot. Coverage may be incomplete.'}
}
Package identity & provenance {selectedNode.purl}{selectedNode.provenance.map((p,i)=>{safeUrl(p.url)?{p.source} :p.source}{new Date(p.retrievedAt).toLocaleString()}
)} >:Select a dependency to inspect it.
} {simulation&&Affected application paths {simulation.paths.length?simulation.paths.map((path,i)=>
{path.map((id,j)=>
{j>0&&}pick(id)}>{graph?.nodes.find(n=>n.id===id)?.name} )}
):
No application service is reachable from this node in the recorded graph.
}
}>}
- {graph?.warnings.length||0} coverage notes{scan.mode==='demo'?' · Demo values are illustrative':''} {graph?.warnings.map((w,i)=>{w}
)} >}
- RippleGuard · Dependency intelligence, without the guesswork. nav('methodology')}>Scoring methodology
- {notice&&
{notice}
}
;
- function renderTable(full:boolean){return <>{full?'Package inventory':'Where to look first'} {full?rows.length:'Highest risk'} {full?'Search, inspect, and trace any resolved dependency.':'Packages ranked by vulnerability signals and their reach in your graph.'}
{!full&&
nav('inventory')}>View all dependencies }
{full&& setQuery(e.target.value)}/>setFilter(e.target.value)}>All packages With findings npm PyPI Maven
}Package Risk score Findings Depth Affected ancestors Inspect package {(full?rows:risks.slice(0,5)).map(r=>{const n=graph!.nodes.find(n=>n.id===r.nodeId)!;return {pick(n.id);nav('overview');}}>{n.ecosystem==='npm'?'n':n.ecosystem==='pypi'?'py':'m'} {n.name} {n.version} · {n.ecosystem} {r.score} {r.level} {n.advisories.length} {scan?.mode==='demo'?'demo':'advisories'} {n.depth??'—'} {n.depth===1?'direct':'transitive'} {r.ancestors} {pick(n.id);nav('overview');}}> ;})}
{full&&!rows.length&&
No matching dependencies Try a different package name or clear the filter.
{setQuery('');setFilter('all');}}>Clear filters }
>;}
+import { useEffect, useMemo, useState } from "react";
+import { createRoot } from "react-dom/client";
+import {
+ ArrowDownToLine,
+ ArrowRight,
+ ArrowUpRight,
+ Check,
+ ChevronRight,
+ Clock3,
+ FileCode2,
+ GitBranch,
+ Layers3,
+ LoaderCircle,
+ Network,
+ Plus,
+ Radio,
+ Search,
+ X,
+ AlertTriangle,
+ KeyRound,
+} from "lucide-react";
+import "@fontsource-variable/geist";
+import "@fontsource-variable/geist-mono";
+import "./styles.css";
+import "./landing.css";
+import "./journey.css";
+import HeroSection6 from "./components/ui/hero-section-6";
+import { Button } from "./components/ui/Button";
+import { DependencyGraph } from "./components/DependencyGraph";
+import { RepositoryMap } from "./components/RepositoryMap";
+import { ScanProgress } from "./components/ScanProgress";
+import { PackageDetails } from "./components/PackageDetails";
+import type { Scan, ScanInput, Simulation, ProjectSelection } from "../shared/types";
+type View = "overview" | "inventory" | "scans" | "connectors" | "methodology" | "new";
+let accessToken = "";
+function initialView(): View {
+ const hash = location.hash.slice(1);
+ return ["inventory", "scans", "connectors", "methodology", "new"].includes(hash)
+ ? (hash as View)
+ : "overview";
}
-// The marketing landing page owns `/`; the workspace lives under `/app`.
-// Plain anchors drive navigation, so a full page load is the transition and no
-// client-side router is needed. The server's SPA fallback serves index.html for both.
-const isWorkspace = window.location.pathname.startsWith('/app');
-createRoot(document.getElementById('root')!).render(isWorkspace ? : );
+async function api(path: string, body?: unknown): Promise {
+ const response = await fetch("/api/v1" + path, {
+ headers: {
+ ...(body ? { "Content-Type": "application/json" } : {}),
+ ...(accessToken ? { Authorization: "Bearer " + accessToken } : {}),
+ },
+ method: body ? "POST" : "GET",
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "The request could not finish. Please try again.");
+ return data;
+}
+function App() {
+ const [scan, setScan] = useState(null),
+ [selected, setSelected] = useState(""),
+ [paths, setPaths] = useState(null),
+ [ripple, setRipple] = useState(false),
+ [view, setView] = useState(initialView),
+ [error, setError] = useState(""),
+ [busy, setBusy] = useState(false),
+ [history, setHistory] = useState([]),
+ [query, setQuery] = useState(""),
+ [filter, setFilter] = useState("all"),
+ [mode, setMode] = useState<"github" | "manifest">("github"),
+ [repository, setRepository] = useState(""),
+ [ref, setRef] = useState("HEAD"),
+ [filename, setFilename] = useState("package-lock.json"),
+ [content, setContent] = useState(""),
+ [auth, setAuth] = useState(false),
+ [token, setToken] = useState(""),
+ [notice, setNotice] = useState("");
+ const nav = (v: View) => {
+ setView(v);
+ setError("");
+ window.history.replaceState(null, "", v === "overview" ? "/app" : "/app#" + v);
+ };
+ function receive(data: Scan) {
+ setScan(data);
+ if (data.status === "completed")
+ setSelected((prev) =>
+ data.graph?.nodes.some((n) => n.id === prev)
+ ? prev
+ : data.risks?.[0]?.nodeId || data.graph?.nodes[0]?.id || "",
+ );
+ }
+ async function load(id: string) {
+ const data = await api("/scans/" + id);
+ receive(data);
+ return data;
+ }
+ async function start(input: ScanInput) {
+ setBusy(true);
+ setError("");
+ setRipple(false);
+ setSelected("");
+ setPaths(null);
+ try {
+ const created = await api("/scans", input);
+ localStorage.setItem("rippleguard-scan", created.id);
+ receive(created);
+ nav("overview");
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ async function initialize() {
+ try {
+ const health = await api<{ authRequired: boolean }>("/health");
+ if (health.authRequired && !accessToken) {
+ setAuth(true);
+ return;
+ }
+ if (accessToken) await api("/scans");
+ setAuth(false);
+ if (location.hash === "#demo") {
+ await start({ mode: "demo" });
+ return;
+ }
+ const last = localStorage.getItem("rippleguard-scan");
+ if (last) {
+ try {
+ await load(last);
+ return;
+ } catch {
+ localStorage.removeItem("rippleguard-scan");
+ }
+ }
+ if (initialView() === "overview") nav("new");
+ } catch (e) {
+ setAuth(true);
+ setError((e as Error).message);
+ }
+ }
+ useEffect(() => {
+ void initialize();
+ }, []);
+ useEffect(() => {
+ const handler = () => setView(initialView());
+ window.addEventListener("hashchange", handler);
+ return () => window.removeEventListener("hashchange", handler);
+ }, []);
+ useEffect(() => {
+ if (!scan || ["completed", "mapped", "failed"].includes(scan.status)) return;
+ let active = true;
+ const timer = setTimeout(
+ () =>
+ api("/scans/" + scan.id)
+ .then((data) => {
+ if (active) receive(data);
+ })
+ .catch((e) => {
+ if (active) setError(e.message);
+ }),
+ 1000,
+ );
+ return () => {
+ active = false;
+ clearTimeout(timer);
+ };
+ }, [scan]);
+ useEffect(() => {
+ if (view === "scans")
+ api("/scans")
+ .then(setHistory)
+ .catch((e) => setError(e.message));
+ }, [view]);
+ useEffect(() => {
+ setPaths(null);
+ setRipple(false);
+ if (!scan || scan.status !== "completed" || !selected) return;
+ let active = true;
+ api(`/scans/${scan.id}/simulate`, { nodeId: selected })
+ .then((p) => {
+ if (active) setPaths(p);
+ })
+ .catch((e) => {
+ if (active) setError(e.message);
+ });
+ return () => {
+ active = false;
+ };
+ }, [scan?.id, scan?.status, selected]);
+ useEffect(() => {
+ if (!notice) return;
+ const id = setTimeout(() => setNotice(""), 4000);
+ return () => clearTimeout(id);
+ }, [notice]);
+ async function analyze(projects: ProjectSelection[]) {
+ setBusy(true);
+ setError("");
+ try {
+ receive(await api(`/scans/${scan!.id}/analyze`, { projects }));
+ } catch (e) {
+ setError((e as Error).message);
+ } finally {
+ setBusy(false);
+ }
+ }
+ async function exportSbom() {
+ try {
+ const data = await api(`/scans/${scan!.id}/sbom`);
+ const url = URL.createObjectURL(
+ new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }),
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `rippleguard-${scan!.id}.spdx.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ setNotice("SPDX 2.3 SBOM exported.");
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ }
+ const graph = scan?.graph;
+ const risks = scan?.risks || [];
+ const node = graph?.nodes.find((n) => n.id === selected);
+ const risk = risks.find((r) => graph?.nodes.find((n) => n.id === r.nodeId)?.purl === node?.purl);
+ const packages = useMemo(
+ () => [
+ ...new Map(
+ (graph?.nodes || []).filter((n) => n.kind === "package").map((n) => [n.purl, n]),
+ ).values(),
+ ],
+ [graph],
+ );
+ const checked = packages.filter((n) => ["checked", "fixture"].includes(n.coverage)).length;
+ const findingCount = new Set(packages.flatMap((n) => n.advisories.map((a) => a.id))).size;
+ const top = risks[0];
+ const topNode = graph?.nodes.find((n) => n.id === top?.nodeId);
+ const rows = risks.filter((r) => {
+ const n = graph?.nodes.find((n) => n.id === r.nodeId);
+ return (
+ n &&
+ n.name.toLowerCase().includes(query.toLowerCase()) &&
+ (filter === "all" ||
+ (filter === "vulnerable" && n.advisories.length > 0) ||
+ n.ecosystem === filter)
+ );
+ });
+ function pick(id: string) {
+ setSelected(id);
+ nav("overview");
+ }
+ function trace() {
+ if (paths) {
+ setRipple(true);
+ setView("overview");
+ document
+ .getElementById("ripple-workspace")
+ ?.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ }
+ return (
+
+
+
+
+ RippleGuard
+
+
+ nav("overview")}>
+ Investigation
+
+ nav("scans")}>
+ History
+
+
+ nav("new")}>
+
+ New scan
+
+
+
+
+
+ {view === "new"
+ ? "Start with your repository."
+ : view === "scans"
+ ? "Recent investigations."
+ : view === "methodology"
+ ? "Risk you can explain."
+ : view === "connectors"
+ ? "Source coverage."
+ : scan?.status === "mapped"
+ ? "Your repository, understood."
+ : scan?.status === "completed"
+ ? "See the risk. Trace the ripple."
+ : "Understanding your repository."}
+
+
+ {view === "new"
+ ? "Find the dependency that matters. See every application it connects to."
+ : scan?.status === "mapped"
+ ? "Confirm the projects below before we check their dependencies."
+ : "Repository → Projects → Dependencies → Known vulnerabilities → Impact"}
+
+
+ {error && (
+
+
+
{error}
+
{
+ setError("");
+ if (scan) void load(scan.id);
+ }}
+ >
+ Retry
+
+
setError("")}>
+
+
+
+ )}
+ {auth ? (
+ {
+ e.preventDefault();
+ accessToken = token;
+ void initialize();
+ }}
+ >
+
+ Connect to your workspace
+
+ Server access token
+ setToken(e.target.value)}
+ required
+ />
+
+ Connect
+ The token stays in this tab’s memory.
+
+ ) : view === "new" ? (
+
+ ) : view === "scans" ? (
+
+ {history.length ? (
+ history.map((item) => (
+ {
+ setSelected("");
+ load(item.id)
+ .then(() => {
+ localStorage.setItem("rippleguard-scan", item.id);
+ nav("overview");
+ })
+ .catch((e) => setError(e.message));
+ }}
+ >
+
+
+ {item.name}
+
+ {item.ref} · {new Date(item.createdAt).toLocaleString()}
+
+
+
+ {item.status === "mapped" ? "Awaiting selection" : item.status}
+
+
+
+ ))
+ ) : (
+
+
No scans yet
+ nav("new")}>Map a repository
+
+ )}
+
+ ) : view === "methodology" ? (
+
+ From a package to the services it reaches
+
+ A dependency edge means one package depends on another. Ripple follows these edges
+ backwards, starting from every installed instance of the selected package version.
+ This reveals affected applications, not proof that vulnerable code executes.
+
+
+ Priority = severity + exploitation + exposure + application reach
+
+
+ CVSS × 6 contributes up to 60 points. Confirmed exploitation contributes 15. Exposure
+ contributes 10 divided by known shortest depth, halved for development-only use.
+ Application reach contributes up to 12 points for the fraction of mapped projects
+ affected, plus up to 3 for affected ancestors.
+
+
+ Known vulnerabilities rank before packages without findings. Missing CVSS contributes
+ no severity points and is labeled unscored. This deterministic score is not an
+ exploitation probability.
+
+ What the paths mean
+
+ Solid edges are dependency relationships recorded in a supported snapshot. Dashed
+ edges mean a package was listed in a flat or conditional Python snapshot;
+ direct/indirect membership is not claimed. npm workspace installations stay distinct
+ from unrelated projects. Selected package versions trace across all their installed
+ instances.
+
+
+ Up to 200 simple paths are enumerated under a bounded search, with at least one
+ shortest path retained for every affected project. A capped result is explicitly
+ labeled “at least”. Large graphs render a focused subset while the inventory retains
+ all package versions.
+
+ Coverage is part of the answer
+
+ OSV supplies exact-package advisory matches and CVSS vectors. NVD can fill missing
+ numeric CVSS. CISA’s Known Exploited Vulnerabilities catalog supplies positive
+ exploitation evidence. No EPSS probability is currently queried. Source failures,
+ skipped versions and incomplete relationships remain visible.
+
+
+ ) : view === "connectors" ? (
+
+ {(scan?.connectors || []).map((c) => (
+
+
+
{c.name}
+
{c.message}
+
+
+ {c.status}
+
+
+ ))}
+
+ Status from this snapshot. Ready means not queried; it does not mean full coverage.
+
+
+ ) : !scan ? (
+
+
Map a repository to begin
+ nav("new")}>Start an investigation
+
+ ) : (
+ <>
+
+
+
+ {scan.name}
+ Ref: {scan.ref}
+ {scan.commit && Commit: {scan.commit}}
+
+
+ {scan.mode === "demo"
+ ? "Demo snapshot"
+ : scan.status === "completed"
+ ? "Repository analyzed"
+ : scan.status === "mapped"
+ ? "Repository Map"
+ : "Analysis in progress"}
+
+
+ {scan.status === "mapped" && scan.repositoryMap ? (
+ void analyze(selection)}
+ />
+ ) : scan.status === "failed" ? (
+
+
+
We couldn’t complete this step
+
{scan.error}
+
nav("new")}>Try another repository
+
void start({ mode: "demo" })}>Use offline demo
+
+ ) : scan.status !== "completed" ? (
+
+ ) : (
+ <>
+
+
+ {checked} of {packages.length} package versions checked
+
+
+ {findingCount} known {scan.mode === "demo" ? "demo " : ""}
+ advisories
+
+
+ {risks.filter((r) => r.advisories && r.score >= 60).length} {" "}
+ high-priority dependencies
+
+
+ {graph?.nodes.filter((n) => n.kind === "service").length || 0} {" "}
+ projects mapped
+
+
+ {(checked < packages.length ||
+ graph?.warnings.length ||
+ scan.repositoryMap?.projects.some((p) => p.resolution !== "resolved")) && (
+
+
+ {" "}
+ {scan.mode === "demo"
+ ? "Synthetic demo security data"
+ : checked < packages.length
+ ? "Vulnerability coverage is incomplete"
+ : "Dependency coverage notes"}{" "}
+ · Review what was and wasn’t checked
+
+ {graph?.warnings.map((w, i) => (
+ {w}
+ ))}
+
+ )}
+ {view === "inventory" ? (
+
+ ) : (
+ <>
+ {topNode && top?.advisories > 0 ? (
+
+
+
Start here: {topNode.name}
+
+ {topNode.version} · {topNode.ecosystem} · {top.advisories} known{" "}
+ {scan.mode === "demo" ? "demo " : ""}findings
+
+
+ {top.reasons
+ ?.filter((r) => !r.startsWith("Used at"))
+ .slice(0, 4)
+ .map((reason) => (
+ {reason}
+ ))}
+
+
+
+ Ripple Priority
+
+ {top.score}
+ /100
+
+ {
+ if (selected !== topNode.id) {
+ pick(topNode.id);
+ } else trace();
+ }}
+ disabled={selected === topNode.id && !paths}
+ >
+
+ {selected === topNode.id ? "Trace Ripple" : "Inspect top dependency"}
+
+
+
+ ) : (
+
+
+ {checked > 0
+ ? "No known vulnerabilities found in the packages successfully checked."
+ : "No packages could be fully checked."}
+
+
+ {packages.length
+ ? "Select any package to explore a hypothetical compromise. This does not establish that the repository is secure."
+ : "Review project coverage notes for unresolved versions or unsupported dependency formats."}
+
+
+ )}
+
+
+
+
+ Dependency paths
+
+ nav("inventory")}>
+
+ All packages
+
+
+
+ {scan.mode === "demo"
+ ? "Offline demo · synthetic advisories"
+ : "Source-backed snapshot"}
+
+
+
+ {ripple && paths && (
+
+
+
+
+ {paths.hypothetical ? "What-if impact" : "Ripple traced"}: affects{" "}
+ {paths.services.length} of {paths.totalServices} projects
+
+
+ {paths.pathsTruncated ? "At least " : ""}
+ {paths.pathCount} dependency paths · {paths.affected.length} dependent
+ nodes · {paths.serviceImpact}% of mapped projects
+
+
+ These applications depend on the package through recorded paths. Code
+ execution is not analyzed.
+
+
+
setRipple(false)}>
+
+
+
+ )}
+
+
+ {graph && node && (
+
+ )}
+ {paths && (
+
+
+ {ripple
+ ? "Affected application paths"
+ : "Dependency paths into this package"}
+
+ {paths.relationshipUncertain && (
+
+ Dashed root links indicate listed packages, not confirmed direct
+ dependencies.
+
+ )}
+ {paths.paths.slice(0, 12).map((path, i) => (
+
+ {path.map((id, j) => (
+
+ {j > 0 && }
+ pick(id)}>
+ {graph?.nodes.find((n) => n.id === id)?.name}
+
+
+ ))}
+
+ ))}
+ {paths.paths.length > 12 && (
+
+ Showing 12 of {paths.pathCount}
+ {paths.pathsTruncated ? "+" : ""} paths. The graph includes the
+ affected ancestors.
+
+ )}
+ {!paths.paths.length && (
+
No application path was recorded for this node.
+ )}
+
+ )}
+
+ {node && graph && (
+
+ )}
+
+
+
+ >
+ )}
+ {scan.repositoryMap && (
+
+ Analysis coverage by project
+ {scan.repositoryMap.projects
+ .filter((p) => p.selected)
+ .map((p) => (
+
+ {p.name} · {p.resolution} · {p.packageCount ?? "Demo"}{" "}
+ packages{p.notes.length ? ` — ${p.notes.join(" ")}` : ""}
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+ {notice && (
+
+
+ {notice}
+
+ )}
+
+ );
+ function table(full: boolean) {
+ return (
+ <>
+
+
+
{full ? "All package versions" : "Dependencies worth investigating"}
+
+ {full
+ ? "Every resolved package version, including unscored and unchecked packages."
+ : "Known findings first, followed by deterministic Ripple Priority."}
+
+
+ {!full && (
+
nav("inventory")}>
+ View all dependencies
+
+ )}
+
+ {full && (
+
+
+
+ setQuery(e.target.value)}
+ />
+
+ setFilter(e.target.value)}
+ >
+ All packages
+ Known findings
+ npm
+ Python
+ Maven
+
+
+ )}
+
+
+
+
+ Package / installed version
+ Ripple Priority
+ Known findings
+ How it enters
+ Projects affected
+
+
+
+
+ {(full ? rows : risks.slice(0, 6)).map((r) => {
+ const n = graph!.nodes.find((n) => n.id === r.nodeId)!;
+ return (
+
+
+ pick(n.id)}>
+
+ {n.name}
+
+ {n.version} · {n.ecosystem}
+
+
+
+
+
+ {r.score}
+ {r.level}
+
+
+ {n.advisories.length}
+
+ {n.coverage === "checked"
+ ? "Checked"
+ : n.coverage === "fixture"
+ ? "Demo data"
+ : "Incomplete"}
+
+
+
+ {n.depth === null
+ ? "Relationship unknown"
+ : n.depth === 1
+ ? "Direct dependency"
+ : `Hidden ${n.depth} levels deep`}
+
+
+ {r.services.length} of{" "}
+ {graph!.nodes.filter((n) => n.kind === "service").length}
+
+
+ pick(n.id)}
+ >
+
+
+
+
+ );
+ })}
+
+
+ {!rows.length && full && (
+
+
No matching packages.
+
{
+ setQuery("");
+ setFilter("all");
+ }}
+ >
+ Clear filters
+
+
+ )}
+
+ >
+ );
+ }
+}
+createRoot(document.getElementById("root")!).render(
+ location.pathname === "/app" ? : ,
+);
diff --git a/tests/browser/landing.spec.ts b/tests/browser/landing.spec.ts
index 0b3a847..3c09a70 100644
--- a/tests/browser/landing.spec.ts
+++ b/tests/browser/landing.spec.ts
@@ -1,30 +1,48 @@
-import {test,expect} from '@playwright/test';
-test('landing page renders, scrolls and routes into the workspace',async({page},testInfo)=>{
- const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));
- await page.goto('/');
- await expect(page.getByRole('heading',{name:/See the risk/})).toBeVisible();
+import { test, expect } from "@playwright/test";
+test("landing page renders, scrolls and routes into the workspace", async ({ page }, testInfo) => {
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: /See the risk/ })).toBeVisible();
await expect(page.getByText(/Source-backed dependency intelligence/)).toBeVisible();
// Tailwind utilities must win over the dashboard stylesheet's unlayered element rules.
- await expect(page.getByRole('button',{name:'Open app',exact:true})).toHaveCSS('color','rgb(255, 255, 255)');
- expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy();
+ await expect(page.getByRole("button", { name: "Open app", exact: true })).toHaveCSS(
+ "color",
+ "rgb(255, 255, 255)",
+ );
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBeTruthy();
// The hero must not be pinned to one viewport on desktop, and no CTA may be inert.
- if(testInfo.project.name==='desktop'){
- expect(await page.evaluate(()=>document.documentElement.scrollHeight>window.innerHeight+4)).toBeTruthy();
- await page.evaluate(()=>window.scrollTo(0,document.body.scrollHeight));
- expect(await page.evaluate(()=>window.scrollY)).toBeGreaterThan(0);
+ if (testInfo.project.name === "desktop") {
+ expect(
+ await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight + 4),
+ ).toBeTruthy();
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
}
- expect(await page.evaluate(()=>[...document.querySelectorAll('a')].filter(a=>!a.getAttribute('href')||a.getAttribute('href')==='#').length)).toBe(0);
- await page.screenshot({path:`test-results/${testInfo.project.name}-landing.png`,fullPage:true});
- await page.getByRole('button',{name:'Open app',exact:true}).click();
- await expect(page.getByText('Demo snapshot',{exact:true})).toBeVisible();
+ expect(
+ await page.evaluate(
+ () =>
+ [...document.querySelectorAll("a")].filter(
+ (a) => !a.getAttribute("href") || a.getAttribute("href") === "#",
+ ).length,
+ ),
+ ).toBe(0);
+ await page.screenshot({
+ path: `test-results/${testInfo.project.name}-landing.png`,
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "Open app", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Start with your repository." })).toBeVisible();
expect(errors).toEqual([]);
});
-test('hero CTAs reach the demo and the methodology write-up',async({page})=>{
- await page.goto('/');
- await page.getByRole('button',{name:'How scoring works'}).click();
+test("hero CTAs reach the demo and the methodology write-up", async ({ page }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "How scoring works" }).click();
await expect(page).toHaveURL(/#methodology$/);
- await expect(page.getByText('From a package to the services it reaches')).toBeVisible();
- await page.goto('/');
- await page.getByRole('button',{name:'Explore the demo'}).click();
- await expect(page.getByText('Demo snapshot',{exact:true})).toBeVisible();
+ await expect(page.getByText("From a package to the services it reaches")).toBeVisible();
+ await page.goto("/");
+ await page.getByRole("button", { name: "Explore the demo" }).click();
+ await expect(page.getByRole("heading", { name: "Your repository, understood." })).toBeVisible();
});
diff --git a/tests/browser/workspace.spec.ts b/tests/browser/workspace.spec.ts
index c96a651..d293821 100644
--- a/tests/browser/workspace.spec.ts
+++ b/tests/browser/workspace.spec.ts
@@ -1,29 +1,65 @@
-import {test,expect} from '@playwright/test';
-test('demo investigation, filtering, export and responsive layout',async({page},testInfo)=>{
- const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));
- await page.goto('/app');await expect(page.getByText('Demo snapshot',{exact:true})).toBeVisible();
- await page.screenshot({path:`test-results/${testInfo.project.name}-overview.png`,fullPage:true});
- expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy();
- await page.getByRole('button',{name:'Simulate ripple',exact:true}).click();
- await expect(page.getByText('Affected application paths',{exact:true})).toBeVisible();
- await expect(page.getByText(/affected ancestors · 2 services/)).toBeVisible();
- await page.screenshot({path:`test-results/${testInfo.project.name}-ripple.png`,fullPage:true});
- await page.getByRole('button',{name:'Clear simulation'}).click();
- await page.getByRole('button',{name:'View all dependencies'}).click();
- await page.getByRole('textbox',{name:'Search dependencies'}).fill('lodash');
- await expect(page.locator('tbody tr')).toHaveCount(1);
- await page.getByRole('button',{name:'Inspect lodash',exact:true}).click();
- await expect(page.getByRole('heading',{name:'lodash',exact:true})).toBeVisible();
- await page.getByRole('button',{name:'Simulate ripple',exact:true}).click();
- await expect(page.getByText(/3 affected ancestors · 3 services/)).toBeVisible();
- const download=page.waitForEvent('download');await page.getByRole('button',{name:'Export SBOM'}).click();expect((await download).suggestedFilename()).toMatch(/\.spdx\.json$/);
- await page.reload();await expect(page.getByText('Demo snapshot',{exact:true})).toBeVisible();
+import { test, expect } from "@playwright/test";
+async function demo(page: import("@playwright/test").Page) {
+ await page.goto("/app#demo");
+ await expect(page.getByRole("heading", { name: "Your repository, understood." })).toBeVisible();
+ await expect(page.getByText("4 of 4 selected")).toBeVisible();
+ await page.getByRole("button", { name: "Analyze repository", exact: true }).click();
+ await expect(page.getByRole("heading", { name: /Start here:/ })).toBeVisible();
+}
+test("complete offline map, priority, path, ripple, inventory, export and reload journey", async ({
+ page,
+}, info) => {
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await demo(page);
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ ).toBeTruthy();
+ await page.screenshot({ path: `test-results/${info.project.name}-overview.png`, fullPage: true });
+ await page.getByRole("button", { name: "Trace Ripple", exact: true }).first().click();
+ await expect(
+ page.getByRole("heading", { name: /Ripple traced: affects 3 of 4 projects/ }),
+ ).toBeVisible();
+ await expect(page.getByText(/4 dependency paths ·/)).toBeVisible();
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ ).toBeTruthy();
+ await page.screenshot({ path: `test-results/${info.project.name}-ripple.png`, fullPage: true });
+ await page.getByRole("button", { name: "Clear simulation" }).click();
+ await page.getByRole("button", { name: "View all dependencies" }).click();
+ await page.getByRole("textbox", { name: "Search dependencies" }).fill("lodash");
+ await expect(page.locator("tbody tr")).toHaveCount(1);
+ await page.getByRole("button", { name: "Inspect lodash", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "lodash", exact: true })).toBeVisible();
+ const download = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Export SBOM" }).click();
+ expect((await download).suggestedFilename()).toMatch(/\.spdx\.json$/);
+ await page.reload();
+ await expect(page.getByRole("heading", { name: /Start here:/ })).toBeVisible();
expect(errors).toEqual([]);
});
-test('scan form invalid repository recovery',async({page})=>{
- await page.goto('/app');await expect(page.getByText('Demo snapshot',{exact:true})).toBeVisible();
- await page.getByRole('button',{name:'New scan',exact:true}).click();
- await page.getByLabel('Repository URL').fill('https://localhost/private');
- await page.getByRole('button',{name:'Start scan',exact:true}).click();
- await expect(page.getByRole('alert')).toContainText('canonical GitHub');
+test("repository map allows selection and display rename", async ({ page }, info) => {
+ await page.goto("/app#demo");
+ await expect(page.getByRole("button", { name: "Analyze repository" })).toBeVisible();
+ await page.getByRole("button", { name: "Deselect all" }).click();
+ await expect(page.getByRole("button", { name: "Analyze repository" })).toBeDisabled();
+ await page.getByRole("checkbox", { name: "Analyze storefront", exact: true }).check();
+ await page
+ .getByRole("textbox", { name: "Display name for apps/storefront", exact: true })
+ .fill("My shop");
+ await page.screenshot({
+ path: `test-results/${info.project.name}-repository-map.png`,
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "Analyze repository" }).click();
+ await expect(page.getByText("projects mapped", { exact: false })).toContainText("1");
+ await page.getByRole("button", { name: "Trace Ripple", exact: true }).first().click();
+ await expect(page.getByRole("heading", { name: /affects 1 of 1 projects/ })).toBeVisible();
+});
+test("invalid repository can be corrected without losing the form", async ({ page }) => {
+ await page.goto("/app#new");
+ await page.getByLabel("Repository URL").fill("https://localhost/private");
+ await page.getByRole("button", { name: "Map repository" }).click();
+ await expect(page.getByRole("alert")).toContainText("canonical GitHub");
+ await expect(page.getByLabel("Repository URL")).toHaveValue("https://localhost/private");
});
diff --git a/tests/discovery.test.ts b/tests/discovery.test.ts
index 8520d48..6612f98 100644
--- a/tests/discovery.test.ts
+++ b/tests/discovery.test.ts
@@ -125,6 +125,18 @@ test("separate installed instances do not invent cross-project paths", () => {
2,
);
});
+test("duplicate project edge records do not multiply ripple paths or seed application roots", () => {
+ const graph = finalize(resolveProject(project("apps/a", "a"), [file("package-lock.json", lock)]));
+ const leaf = graph.nodes.find((n) => n.name === "leaf")!;
+ const root = graph.nodes.find((n) => n.kind === "service")!;
+ root.purl = leaf.purl;
+ graph.edges.push(...graph.edges.map((edge) => ({ ...edge, projectId: "another-context" })));
+ const result = simulate(graph, leaf.id);
+ assert.deepEqual(result.seedIds, [leaf.id]);
+ assert.equal(result.pathCount, 1);
+ assert.equal(result.services.length, 1);
+ assert.equal(result.hypothetical, true);
+});
test("runtime and development usage propagates through dependencies", () => {
const data = {
lockfileVersion: 3,
From 6f9effd563181160f41d53eca64f6bd89b99b17c Mon Sep 17 00:00:00 2001
From: Jagpreet Singh
Date: Mon, 14 Sep 2026 02:56:47 +0530
Subject: [PATCH 3/5] Prepare real workspace demo and record end-to-end
verification
---
.env.example | 4 +-
README.md | 66 ++
biome.json | 2 +
demo-repository/README.md | 20 +
demo-repository/apps/admin/package.json | 8 +
demo-repository/apps/checkout/package.json | 8 +
demo-repository/apps/storefront/package.json | 8 +
demo-repository/package-lock.json | 615 ++++++++++++
demo-repository/package.json | 1 +
.../packages/shared-http/package.json | 1 +
docs/verification/README.md | 13 +
docs/verification/controlled-demo.json | 900 ++++++++++++++++++
docs/verification/live-browser.json | 7 +
docs/verification/live-repositories.json | 321 +++++++
scripts/verify-controlled-demo.ts | 65 ++
scripts/verify-live-browser.ts | 40 +
scripts/verify-real-repos.ts | 102 ++
tsconfig.json | 35 +-
18 files changed, 2213 insertions(+), 3 deletions(-)
create mode 100644 README.md
create mode 100644 demo-repository/README.md
create mode 100644 demo-repository/apps/admin/package.json
create mode 100644 demo-repository/apps/checkout/package.json
create mode 100644 demo-repository/apps/storefront/package.json
create mode 100644 demo-repository/package-lock.json
create mode 100644 demo-repository/package.json
create mode 100644 demo-repository/packages/shared-http/package.json
create mode 100644 docs/verification/README.md
create mode 100644 docs/verification/controlled-demo.json
create mode 100644 docs/verification/live-browser.json
create mode 100644 docs/verification/live-repositories.json
create mode 100644 scripts/verify-controlled-demo.ts
create mode 100644 scripts/verify-live-browser.ts
create mode 100644 scripts/verify-real-repos.ts
diff --git a/.env.example b/.env.example
index 20459a4..746359a 100644
--- a/.env.example
+++ b/.env.example
@@ -12,8 +12,8 @@ GITHUB_INSTALLATION_IDS=
GITHUB_ALLOWED_REPOS=
GITHUB_WEBHOOK_SECRET=
NVD_API_KEY=
-MAX_ENRICH_PACKAGES=100
-MAX_NVD_CVES=20
+MAX_ENRICH_PACKAGES=2000
+MAX_NVD_CVES=3
OSS_INDEX_EMAIL=
OSS_INDEX_TOKEN=
OTEL_SERVICE_NAME=rippleguard
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ba83b6a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# RippleGuard
+
+Understand a repository, check its dependencies, and trace how a package connects to applications. The existing React/Fastify app now follows **Scan → Repository Map → Analyze → Priority → Trace Ripple**.
+
+## Run locally
+
+Requires Node.js 24 or newer. From this directory:
+
+```sh
+npm ci
+npm run local
+```
+
+Open http://127.0.0.1:3000/app#new. Public GitHub scans need internet access; **Explore demo** works offline. No API key is required for public scans. Optional configuration is documented in `.env.example`. SQLite snapshots and source caches persist under `data/`. Keep the default loopback binding for local use; production requires an API token.
+
+## Two-minute recording
+
+1. **0:00–0:20:** Open **Explore demo** from the landing page. Say that this is the clearly labeled offline illustrative scenario.
+2. **0:20–0:40:** Show the four projects in Repository Map. Rename Storefront if desired, leave all four selected, and click **Analyze repository**.
+3. **0:40–1:00:** Point to lodash, its installed version, and the reasons for its priority: severity, source-reported exploitation, depth, and three affected projects.
+4. **1:00–1:35:** Click **Trace Ripple**. Follow the package paths back to the three affected applications; show the four paths and unaffected fourth project.
+5. **1:35–2:00:** Open the package details and explain how it entered the applications. Clarify that dependency paths describe possible impact, not proof that vulnerable code executes.
+
+For a recording using real source-backed findings, publish the prepared [controlled repository](demo-repository/README.md), paste its GitHub URL, and select the three `apps/` projects. Live verification found axios connected to all three through the shared package. Advisory counts and rankings can change as security sources update. The offline demo remains the reliable backup.
+
+## Accuracy and limits
+
+| Input | What is reconstructed |
+| --- | --- |
+| npm package-lock/shrinkwrap v2/v3 | Installed paths, nearest dependency resolution, workspace links, direct/indirect depth, and runtime/development scope. Independent installations remain distinct. |
+| npm older lockfiles | Supported by the existing parser; less workspace context than v2/v3. |
+| pnpm locks | Importer dependencies and recorded snapshot relationships; unsupported local workspace links remain explicit warnings. |
+| package.json without usable lock / Yarn | Exact manifest pins only; ranges and unavailable indirect relationships are reported as partial. Yarn lock resolution is not implemented. |
+| Python requirements / Pipfile.lock | Exact pinned packages; flat files do not establish indirect ancestry. Ranges, includes, and commands are not executed. |
+| uv / Poetry locks | Recorded packages and reconstructable edges; environment selection and application membership may remain unknown. Root links labeled “listed” do not establish direct depth. |
+| Maven dependency tree | Recorded resolved dependency relationships. |
+| Maven pom.xml | Literal versions only; parent properties, BOM-managed versions, profiles, and indirect dependencies may be unresolved. Maven is never executed. |
+
+Related files are grouped by path and ecosystem. Repository discovery has no manifest-count cutoff. Truncated GitHub trees use bounded directory traversal and disclose remaining gaps. File/transport limits, unsupported formats, and individual parser failures produce project-level coverage notes; they do not turn unchecked packages into safe ones.
+
+OSV provides package/version findings; CVSS vectors are scored deterministically. Registry sources can supplement findings; NVD enriches already-mapped CVEs only. CISA KEV adds positive known-exploitation evidence by exact CVE. Missing signals remain unknown. Fixed versions come only from matching affected-package evidence. No exploitation probability is invented.
+
+Ripple Priority is a bounded triage score, not a probability: severity up to 60 (CVSS × 6), source-reported exploitation 15, exposure up to 10 (inverse known depth, halved for development-only usage), and repository impact up to 15 (12 for affected-project proportion plus 3 for ancestor proportion). Findings rank ahead of packages without findings. Source facts, factors, and paths remain inspectable. Depth describes the recorded graph, not code execution.
+
+All occurrences of a selected package version are considered for impact, while installed-instance edges prevent false cross-project paths. Reverse traversal is cycle-safe. Path enumeration is bounded and labeled “at least” when truncated. The focused graph displays up to 70 nodes; the complete retained inventory and export remain available. Checking defaults to 2,000 unique package versions, configurable within bounds; omitted or failed checks remain visibly partial.
+
+The built-in demo contains synthetic `DEMO-*` security data and illustrative edges. The prepared real demo uses an npm-generated lockfile and live source findings. Neither is a production certification.
+
+## Verification
+
+```sh
+npm run check
+npm run test:e2e
+# Start the app first for the live repository matrix:
+node --import tsx scripts/verify-real-repos.ts
+node --import tsx scripts/verify-controlled-demo.ts
+node --import tsx scripts/verify-live-browser.ts
+```
+
+Browser tests require Playwright Chromium (`npx playwright install chromium`), or set `PW_CHROME=1` to use installed Chrome. Tests use a separate local server and database on port 3100.
+
+Recorded results: [public repository matrix](docs/verification/live-repositories.json), [controlled demo evidence](docs/verification/controlled-demo.json). These are dated observations, not assertions about future repository contents or vulnerability-source responses.
+
+The matrix covers expressjs/express (small npm), this repository (lockfile), npm/cli (478 projects / 575 files), pallets/flask (Python), spring-projects/spring-petclinic (Maven), GoogleCloudPlatform/microservices-demo (mixed language), and octocat/Hello-World (empty supported-manifest state). Coverage warnings are preserved in the report.
+
+Main implementation: `src/server/discovery.ts`, `resolve-project.ts`, `advisories.ts`, `connectors.ts`, `jobs.ts`, and `graph.ts`; shared types in `src/shared/types.ts`; journey and focused graph in `src/web/main.tsx` and `src/web/components/`. Existing authenticated APIs, export, and webhook boundaries remain in place without expanding enterprise features.
diff --git a/biome.json b/biome.json
index dc45828..effaf10 100644
--- a/biome.json
+++ b/biome.json
@@ -5,6 +5,8 @@
"src/**/*.tsx",
"tests/**/*.ts",
"scripts/**/*.mjs",
+ "scripts/**/*.ts",
+ "playwright.config.ts",
"!src/web/components/ui/hero-section-6.tsx",
"!src/web/components/ui/animated-group.tsx"
]
diff --git a/demo-repository/README.md b/demo-repository/README.md
new file mode 100644
index 0000000..f219b14
--- /dev/null
+++ b/demo-repository/README.md
@@ -0,0 +1,20 @@
+# RippleGuard recording repository
+
+Prepared real npm workspace scenario: storefront, checkout, and admin each depend on `@rippleguard/shared-http`, which depends on pinned axios and express releases. `package-lock.json` was generated by npm and records real installed dependency relationships. There is no application runtime or install script.
+
+These deliberately old dependencies are analysis inputs. Use this folder for demonstrating dependency analysis, not as an application starter.
+
+To prepare the GitHub recording:
+
+1. Copy this folder into a separate Git repository and publish it to a public GitHub repository you control. Commit all manifests and the root lockfile. It has **not** been published automatically.
+2. Paste that repository URL into RippleGuard and use the published commit SHA in the ref field for repeatable dependency input.
+3. In Repository Map, select `apps/storefront`, `apps/checkout`, and `apps/admin`. Deselect the repository root and `packages/shared-http`; the shared package will be reached through the real workspace links.
+4. Analyze, select the top vulnerable package, and click **Trace Ripple**. Inspect the source links and affected applications.
+
+Local verification from the parent RippleGuard repository:
+
+```sh
+node --import tsx scripts/verify-controlled-demo.ts
+```
+
+The recorded verification checked 53 package versions, found findings on eight packages, and traced top-ranked axios through three paths to all three applications. See `../docs/verification/controlled-demo.json` for timestamped source evidence. Findings may evolve independently of the pinned dependency graph. Use RippleGuard’s clearly labeled offline synthetic demo if external APIs are unavailable during recording.
diff --git a/demo-repository/apps/admin/package.json b/demo-repository/apps/admin/package.json
new file mode 100644
index 0000000..d9f8dd0
--- /dev/null
+++ b/demo-repository/apps/admin/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@rippleguard/admin",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@rippleguard/shared-http": "1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/demo-repository/apps/checkout/package.json b/demo-repository/apps/checkout/package.json
new file mode 100644
index 0000000..fc8a562
--- /dev/null
+++ b/demo-repository/apps/checkout/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@rippleguard/checkout",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@rippleguard/shared-http": "1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/demo-repository/apps/storefront/package.json b/demo-repository/apps/storefront/package.json
new file mode 100644
index 0000000..bae69fa
--- /dev/null
+++ b/demo-repository/apps/storefront/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@rippleguard/storefront",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@rippleguard/shared-http": "1.0.0"
+ }
+}
\ No newline at end of file
diff --git a/demo-repository/package-lock.json b/demo-repository/package-lock.json
new file mode 100644
index 0000000..76bc6dd
--- /dev/null
+++ b/demo-repository/package-lock.json
@@ -0,0 +1,615 @@
+{
+ "name": "rippleguard-recording-demo",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rippleguard-recording-demo",
+ "version": "1.0.0",
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ]
+ },
+ "apps/admin": {
+ "name": "@rippleguard/admin",
+ "version": "1.0.0",
+ "dependencies": {
+ "@rippleguard/shared-http": "1.0.0"
+ }
+ },
+ "apps/checkout": {
+ "name": "@rippleguard/checkout",
+ "version": "1.0.0",
+ "dependencies": {
+ "@rippleguard/shared-http": "1.0.0"
+ }
+ },
+ "apps/storefront": {
+ "name": "@rippleguard/storefront",
+ "version": "1.0.0",
+ "dependencies": {
+ "@rippleguard/shared-http": "1.0.0"
+ }
+ },
+ "node_modules/@rippleguard/admin": {
+ "resolved": "apps/admin",
+ "link": true
+ },
+ "node_modules/@rippleguard/checkout": {
+ "resolved": "apps/checkout",
+ "link": true
+ },
+ "node_modules/@rippleguard/shared-http": {
+ "resolved": "packages/shared-http",
+ "link": true
+ },
+ "node_modules/@rippleguard/storefront": {
+ "resolved": "apps/storefront",
+ "link": true
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "0.21.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
+ "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.10.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
+ "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.0",
+ "content-type": "~1.0.4",
+ "debug": "2.6.9",
+ "depd": "~1.1.2",
+ "http-errors": "1.7.2",
+ "iconv-lite": "0.4.24",
+ "on-finished": "~2.3.0",
+ "qs": "6.7.0",
+ "raw-body": "2.4.0",
+ "type-is": "~1.6.17"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+ "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
+ "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
+ "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+ "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
+ "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==",
+ "license": "MIT"
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.17.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
+ "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.7",
+ "array-flatten": "1.1.1",
+ "body-parser": "1.19.0",
+ "content-disposition": "0.5.3",
+ "content-type": "~1.0.4",
+ "cookie": "0.4.0",
+ "cookie-signature": "1.0.6",
+ "debug": "2.6.9",
+ "depd": "~1.1.2",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.1.2",
+ "fresh": "0.5.2",
+ "merge-descriptors": "1.0.1",
+ "methods": "~1.1.2",
+ "on-finished": "~2.3.0",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "0.1.7",
+ "proxy-addr": "~2.0.5",
+ "qs": "6.7.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.1.2",
+ "send": "0.17.1",
+ "serve-static": "1.14.1",
+ "setprototypeof": "1.1.1",
+ "statuses": "~1.5.0",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+ "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.3.0",
+ "parseurl": "~1.3.3",
+ "statuses": "~1.5.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
+ "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~1.1.2",
+ "inherits": "2.0.3",
+ "setprototypeof": "1.1.1",
+ "statuses": ">= 1.5.0 < 2",
+ "toidentifier": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+ "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==",
+ "license": "MIT"
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+ "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.7.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+ "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
+ "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.0",
+ "http-errors": "1.7.2",
+ "iconv-lite": "0.4.24",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.17.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
+ "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "~1.1.2",
+ "destroy": "~1.0.4",
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "0.5.2",
+ "http-errors": "~1.7.2",
+ "mime": "1.6.0",
+ "ms": "2.1.1",
+ "on-finished": "~2.3.0",
+ "range-parser": "~1.2.1",
+ "statuses": "~1.5.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+ "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
+ "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~1.0.2",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "0.17.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
+ "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==",
+ "license": "ISC"
+ },
+ "node_modules/statuses": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+ "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+ "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "packages/shared-http": {
+ "name": "@rippleguard/shared-http",
+ "version": "1.0.0",
+ "dependencies": {
+ "axios": "0.21.1",
+ "express": "4.17.1"
+ }
+ }
+ }
+}
diff --git a/demo-repository/package.json b/demo-repository/package.json
new file mode 100644
index 0000000..2ec00f6
--- /dev/null
+++ b/demo-repository/package.json
@@ -0,0 +1 @@
+{"name":"rippleguard-recording-demo","version":"1.0.0","private":true,"workspaces":["apps/*","packages/*"],"description":"Dependency-only research fixture; do not deploy or execute vulnerable dependencies."}
diff --git a/demo-repository/packages/shared-http/package.json b/demo-repository/packages/shared-http/package.json
new file mode 100644
index 0000000..58ef1f9
--- /dev/null
+++ b/demo-repository/packages/shared-http/package.json
@@ -0,0 +1 @@
+{"name":"@rippleguard/shared-http","version":"1.0.0","private":true,"dependencies":{"express":"4.17.1","axios":"0.21.1"}}
diff --git a/docs/verification/README.md b/docs/verification/README.md
new file mode 100644
index 0000000..d2fa4e1
--- /dev/null
+++ b/docs/verification/README.md
@@ -0,0 +1,13 @@
+# Verification record
+
+Verified September 13–14, 2026 (local timezone Asia/Kolkata; JSON timestamps use UTC).
+
+- `npm run check`: lint, typecheck, 27 unit/API tests, and production build passed.
+- `npm run test:e2e`: 10 tests passed across desktop and mobile Chromium. Covers landing entry, repository selection and rename, offline analysis, paths and ripple, mobile width, inventory, export, persistence, and invalid repository input.
+- `live-repositories.json`: seven real public GitHub repositories. Each record contains the exact analyzed commit, selected projects, coverage, advisories, and warnings. npm/cli discovered 478 projects from 575 dependency files; one selected project was analyzed. The report does not claim all 478 were analyzed.
+- `controlled-demo.json`: npm-generated workspace lockfile checked against live vulnerability sources; 53 versions checked, eight packages with findings, three application paths for top-ranked axios.
+- `live-browser.json`: real GitHub URL submitted through the browser, Repository Map confirmed, analysis completed, OSV source link verified, and ripple traced to two of six projects. No JavaScript errors or page-width overflow. This run selected all six projects; the API matrix selected five.
+
+Visual review covered desktop results, Repository Map, and mobile ripple. The observed mobile grid overflow was corrected and the full browser suite passed afterward. Screenshots and browser traces are local ignored artifacts in `test-results/`.
+
+Live source contents may change. Python conditional relationships, manifest-only npm ranges, and Maven BOM resolution were explicitly partial in the tested inputs. No “secure” conclusion is inferred from an empty finding set or unavailable source.
diff --git a/docs/verification/controlled-demo.json b/docs/verification/controlled-demo.json
new file mode 100644
index 0000000..8577ca1
--- /dev/null
+++ b/docs/verification/controlled-demo.json
@@ -0,0 +1,900 @@
+{
+ "verifiedAt": "2026-09-13T15:54:27.391Z",
+ "projects": [
+ "apps/admin",
+ "apps/checkout",
+ "apps/storefront"
+ ],
+ "packageVersions": 53,
+ "checked": 53,
+ "findings": 8,
+ "top": {
+ "name": "axios",
+ "nodeId": "c9948ba25ba56398fe11",
+ "score": 69,
+ "level": "high",
+ "factors": {
+ "severity": 51.6,
+ "exploit": 0,
+ "exposure": 5,
+ "blastRadius": 12
+ },
+ "ancestors": 4,
+ "services": [
+ "service-25a618fd55fb09c6",
+ "service-73834da44199b6b9",
+ "service-fc948145bb665052"
+ ],
+ "advisories": 24,
+ "coverage": "checked",
+ "reasons": [
+ "High severity vulnerability",
+ "Hidden 2 levels deep",
+ "Used at runtime",
+ "Used by 3 of 3 projects"
+ ]
+ },
+ "ripple": {
+ "projects": 3,
+ "paths": 3
+ },
+ "warnings": [],
+ "advisories": [
+ {
+ "name": "axios",
+ "version": "0.21.1",
+ "advisories": [
+ {
+ "id": "GHSA-3g43-6gmg-66jw",
+ "aliases": [
+ "CVE-2026-44495"
+ ],
+ "summary": "axios Vulnerable to Credential Theft and Response Hijacking via Prototype Pollution Gadget in Config Merge",
+ "cvss": 7,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:L",
+ "severity": "high",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.2",
+ "From 0.19.0 to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-3g43-6gmg-66jw",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-3p68-rc4w-qgx5",
+ "aliases": [
+ "CVE-2025-62718"
+ ],
+ "summary": "Axios has a NO_PROXY Hostname Normalization Bypass that Leads to SSRF",
+ "cvss": 6.3,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N",
+ "fixed": "0.31.0",
+ "affected": [
+ "From 1.0.0 to before 1.15.0",
+ "Earlier versions to before 0.31.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-3p68-rc4w-qgx5",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-43fc-jf86-j433",
+ "aliases": [
+ "CVE-2026-25639"
+ ],
+ "summary": "Axios is Vulnerable to Denial of Service via __proto__ Key in mergeConfig",
+ "cvss": 7.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ "severity": "high",
+ "fixed": "0.30.3",
+ "affected": [
+ "From 1.0.0 to before 1.13.5",
+ "Earlier versions to before 0.30.3"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-43fc-jf86-j433",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-5c9x-8gcm-mpgx",
+ "aliases": [
+ "CVE-2026-42034"
+ ],
+ "summary": "Axios' HTTP adapter-streamed uploads bypass maxBodyLength when maxRedirects: 0",
+ "cvss": 5.3,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-5c9x-8gcm-mpgx",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-62hf-57xw-28j9",
+ "aliases": [
+ "CVE-2026-42039"
+ ],
+ "summary": "Axios: unbounded recursion in toFormData causes DoS via deeply nested request data",
+ "cvss": 7.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-62hf-57xw-28j9",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-6chq-wfr3-2hj9",
+ "aliases": [
+ "CVE-2026-42035"
+ ],
+ "summary": "Axios: Header Injection via Prototype Pollution",
+ "cvss": 7.4,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
+ "severity": "high",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-6chq-wfr3-2hj9",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-7q8q-rj6j-mhjq",
+ "aliases": [
+ "CVE-2026-67319"
+ ],
+ "summary": "Axios: Nested axios option objects can consume polluted prototype values",
+ "cvss": 6.3,
+ "cvssVector": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N",
+ "fixed": "0.33.0",
+ "affected": [
+ "From 0.8.0 to before 0.33.0",
+ "From 1.0.0 to before 1.18.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-7q8q-rj6j-mhjq",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-898c-q2cr-xwhg",
+ "aliases": [
+ "CVE-2026-44490"
+ ],
+ "summary": "axios has DoS & Header Injection via Prototype Pollution Read-Side Gadgets in axios merge functions",
+ "cvss": 4.8,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:L",
+ "fixed": "0.32.0",
+ "affected": [
+ "From 1.0.0 to before 1.16.0",
+ "Earlier versions to before 0.32.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-898c-q2cr-xwhg",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-cph5-m8f7-6c5x",
+ "aliases": [
+ "CVE-2021-3749"
+ ],
+ "summary": "axios Inefficient Regular Expression Complexity vulnerability",
+ "cvss": 7.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ "severity": "high",
+ "fixed": "0.21.2",
+ "affected": [
+ "Earlier versions to before 0.21.2"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-cph5-m8f7-6c5x",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-fvcv-3m26-pcqx",
+ "aliases": [
+ "CVE-2026-40175"
+ ],
+ "summary": "Axios has Unrestricted Cloud Metadata Exfiltration via Header Injection Chain",
+ "cvss": 4.8,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N",
+ "fixed": "0.31.0",
+ "affected": [
+ "From 1.0.0 to before 1.15.0",
+ "Earlier versions to before 0.31.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-fvcv-3m26-pcqx",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-hfxv-24rg-xrqf",
+ "aliases": [
+ "CVE-2026-44496"
+ ],
+ "summary": "Axios: Regular Expression Denial of Service (ReDoS) via Cookie Name Injection",
+ "cvss": 7.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ "severity": "high",
+ "fixed": "0.32.0",
+ "affected": [
+ "From 1.0.0 to before 1.16.0",
+ "Earlier versions to before 0.32.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-hfxv-24rg-xrqf",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-j5f8-grm9-p9fc",
+ "aliases": [
+ "CVE-2026-44486"
+ ],
+ "summary": "Axios: Proxy-Authorization header leaks to redirect target when proxy is re-evaluated to direct connection",
+ "cvss": 7.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
+ "severity": "high",
+ "fixed": "0.32.0",
+ "affected": [
+ "From 1.0.0 to before 1.16.0",
+ "Earlier versions to before 0.32.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-j5f8-grm9-p9fc",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-jr5f-v2jv-69x6",
+ "aliases": [
+ "CVE-2025-27152"
+ ],
+ "summary": "axios Requests Vulnerable To Possible SSRF and Credential Leakage via Absolute URL",
+ "cvss": 7.7,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P",
+ "severity": "high",
+ "fixed": "0.30.0",
+ "affected": [
+ "From 1.0.0 to before 1.8.2",
+ "Earlier versions to before 0.30.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-jr5f-v2jv-69x6",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-m7pr-hjqh-92cm",
+ "aliases": [
+ "CVE-2026-42038"
+ ],
+ "summary": "Axios: no_proxy bypass via IP alias allows SSRF",
+ "cvss": 6.8,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-m7pr-hjqh-92cm",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-mmx7-hfxf-jppx",
+ "aliases": [
+ "CVE-2026-67316"
+ ],
+ "summary": "Axios: Prototype pollution gadgets can alter axios request construction",
+ "cvss": 6.3,
+ "cvssVector": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N",
+ "fixed": "0.33.0",
+ "affected": [
+ "From 1.0.0 to before 1.18.0",
+ "Earlier versions to before 0.33.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-mmx7-hfxf-jppx",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-p92q-9vqr-4j8v",
+ "aliases": [
+ "CVE-2026-44487"
+ ],
+ "summary": "Axios: Proxy-Authorization Credential Leak to Origin Server Across HTTP-to-HTTPS Redirect in Axios Node.js HTTP Adapter",
+ "cvss": 8.2,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
+ "severity": "high",
+ "fixed": "0.32.0",
+ "affected": [
+ "From 1.0.0 to before 1.16.0",
+ "Earlier versions to before 0.32.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-p92q-9vqr-4j8v",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-pf86-5x62-jrwf",
+ "aliases": [
+ "CVE-2026-42033"
+ ],
+ "summary": "Axios: Prototype Pollution Gadgets - Response Tampering, Data Exfiltration, and Request Hijacking",
+ "cvss": 7.4,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N",
+ "severity": "high",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-pf86-5x62-jrwf",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-pjwm-pj3p-43mv",
+ "aliases": [
+ "CVE-2026-44492"
+ ],
+ "summary": "axios's shouldBypassProxy does not recognize IPv4-mapped IPv6 addresses, allowing NO_PROXY bypass (incomplete fix for CVE-2025-62718)",
+ "cvss": 8.6,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N",
+ "severity": "high",
+ "fixed": "0.32.0",
+ "affected": [
+ "From 1.15.0 to before 1.16.0",
+ "Earlier versions to before 0.32.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-pjwm-pj3p-43mv",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-pmwg-cvhr-8vh7",
+ "aliases": [
+ "CVE-2026-42043"
+ ],
+ "summary": "Axios: Incomplete Fix for CVE-2025-62718 — NO_PROXY Protection Bypassed via RFC 1122 Loopback Subnet (127.0.0.0/8) in Axios 1.15.0",
+ "cvss": 7.2,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N",
+ "severity": "high",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-pmwg-cvhr-8vh7",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-vf2m-468p-8v99",
+ "aliases": [
+ "CVE-2026-42036"
+ ],
+ "summary": "Axios: HTTP adapter streamed responses bypass maxContentLength",
+ "cvss": 5.3,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-vf2m-468p-8v99",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-w9j2-pvgh-6h63",
+ "aliases": [
+ "CVE-2026-42041"
+ ],
+ "summary": "Axios: Authentication Bypass via Prototype Pollution Gadget in `validateStatus` Merge Strategy",
+ "cvss": 4.8,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-w9j2-pvgh-6h63",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-wf5p-g6vw-rhxx",
+ "aliases": [
+ "CVE-2023-45857"
+ ],
+ "summary": "Axios Cross-Site Request Forgery Vulnerability",
+ "cvss": 6.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N",
+ "fixed": "0.28.0",
+ "affected": [
+ "From 1.0.0 to before 1.6.0",
+ "From 0.8.1 to before 0.28.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-wf5p-g6vw-rhxx",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-xhjh-pmcv-23jw",
+ "aliases": [
+ "CVE-2026-42040"
+ ],
+ "summary": "Axios: Null Byte Injection via Reverse-Encoding in AxiosURLSearchParams",
+ "cvss": 3.7,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N",
+ "severity": "low",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-xhjh-pmcv-23jw",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-xx6v-rp6x-q39c",
+ "aliases": [
+ "CVE-2026-42042"
+ ],
+ "summary": "Axios: XSRF Token Cross-Origin Leakage via Prototype Pollution Gadget in `withXSRFToken` Boolean Coercion",
+ "cvss": 5.4,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N",
+ "fixed": "0.31.1",
+ "affected": [
+ "From 1.0.0 to before 1.15.1",
+ "Earlier versions to before 0.31.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-xx6v-rp6x-q39c",
+ "retrievedAt": "2026-09-13T15:54:19.888Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "express",
+ "version": "4.17.1",
+ "advisories": [
+ {
+ "id": "GHSA-qw6h-vgh9-j6wx",
+ "aliases": [
+ "CVE-2024-43796"
+ ],
+ "summary": "express vulnerable to XSS via response.redirect()",
+ "cvss": 5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L",
+ "severity": "low",
+ "fixed": "4.20.0",
+ "affected": [
+ "Earlier versions to before 4.20.0",
+ "From 5.0.0-alpha.1 to before 5.0.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-qw6h-vgh9-j6wx",
+ "retrievedAt": "2026-09-13T15:54:19.438Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-rv95-896h-c2vc",
+ "aliases": [
+ "CVE-2024-29041"
+ ],
+ "summary": "Express.js Open Redirect in malformed URLs",
+ "cvss": 6.1,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
+ "fixed": "4.19.2",
+ "affected": [
+ "Earlier versions to before 4.19.2",
+ "From 5.0.0-alpha.1 to before 5.0.0-beta.3"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-rv95-896h-c2vc",
+ "retrievedAt": "2026-09-13T15:54:19.438Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "body-parser",
+ "version": "1.19.0",
+ "advisories": [
+ {
+ "id": "GHSA-qwcr-r2fm-qrc7",
+ "aliases": [
+ "CVE-2024-45590"
+ ],
+ "summary": "body-parser vulnerable to denial of service when url encoding is enabled",
+ "cvss": 8.7,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
+ "severity": "high",
+ "fixed": "1.20.3",
+ "affected": [
+ "Earlier versions to before 1.20.3"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-qwcr-r2fm-qrc7",
+ "retrievedAt": "2026-09-13T15:54:19.788Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-v422-hmwv-36x6",
+ "aliases": [
+ "CVE-2026-12590"
+ ],
+ "summary": "body-parser vulnerable to denial of service when invalid limit value silently disables size enforcement",
+ "cvss": 3.7,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L",
+ "severity": "low",
+ "fixed": "1.20.6",
+ "affected": [
+ "Earlier versions to before 1.20.6",
+ "From 2.0.0 to before 2.3.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-v422-hmwv-36x6",
+ "retrievedAt": "2026-09-13T15:54:19.788Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "cookie",
+ "version": "0.4.0",
+ "advisories": [
+ {
+ "id": "GHSA-pxg6-pf52-xh8x",
+ "aliases": [
+ "CVE-2024-47764"
+ ],
+ "summary": "cookie accepts cookie name, path, and domain with out of bounds characters",
+ "cvss": 6.9,
+ "severity": "low",
+ "fixed": "0.7.0",
+ "affected": [
+ "Earlier versions to before 0.7.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-pxg6-pf52-xh8x",
+ "retrievedAt": "2026-09-13T15:54:19.907Z"
+ },
+ {
+ "source": "NVD",
+ "url": "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-47764",
+ "retrievedAt": "2026-09-13T15:54:27.369Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "path-to-regexp",
+ "version": "0.1.7",
+ "advisories": [
+ {
+ "id": "GHSA-37ch-88jc-xwx2",
+ "aliases": [
+ "CVE-2024-45296",
+ "CVE-2026-4867",
+ "GHSA-9wv6-86v2-598j"
+ ],
+ "summary": "path-to-regexp vulnerable to Regular Expression Denial of Service via multiple route parameters",
+ "cvss": 7.7,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ "severity": "high",
+ "fixed": "0.1.13",
+ "affected": [
+ "Earlier versions to before 0.1.13"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-37ch-88jc-xwx2",
+ "retrievedAt": "2026-09-13T15:54:20.755Z"
+ },
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-9wv6-86v2-598j",
+ "retrievedAt": "2026-09-13T15:54:20.755Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-rhx6-c78j-4q9w",
+ "aliases": [
+ "CVE-2024-52798"
+ ],
+ "summary": "path-to-regexp contains a ReDoS",
+ "cvss": 7.7,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:P",
+ "severity": "high",
+ "fixed": "0.1.12",
+ "affected": [
+ "Earlier versions to before 0.1.12"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-rhx6-c78j-4q9w",
+ "retrievedAt": "2026-09-13T15:54:20.755Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "qs",
+ "version": "6.7.0",
+ "advisories": [
+ {
+ "id": "GHSA-4mjr-xmp4-gh2g",
+ "aliases": [
+ "CVE-2026-82417"
+ ],
+ "summary": "qs: Denial of Service via Attacker Controlled isBuffer",
+ "cvss": 6.3,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
+ "fixed": "6.16.0",
+ "affected": [
+ "From 2.2.5 to before 6.16.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-4mjr-xmp4-gh2g",
+ "retrievedAt": "2026-09-13T15:54:21.055Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-6rw7-vpxm-498p",
+ "aliases": [
+ "CVE-2025-15284"
+ ],
+ "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion",
+ "cvss": 6.3,
+ "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L",
+ "fixed": "6.14.1",
+ "affected": [
+ "Earlier versions to before 6.14.1"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-6rw7-vpxm-498p",
+ "retrievedAt": "2026-09-13T15:54:21.055Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-hrpp-h998-j3pp",
+ "aliases": [
+ "CVE-2022-24999"
+ ],
+ "summary": "qs vulnerable to Prototype Pollution",
+ "cvss": 7.5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
+ "severity": "high",
+ "fixed": "6.7.3",
+ "affected": [
+ "From 6.10.0 to before 6.10.3",
+ "From 6.9.0 to before 6.9.7",
+ "From 6.8.0 to before 6.8.3",
+ "From 6.7.0 to before 6.7.3",
+ "From 6.6.0 to before 6.6.1",
+ "From 6.5.0 to before 6.5.3",
+ "From 6.4.0 to before 6.4.1",
+ "From 6.3.0 to before 6.3.3",
+ "Earlier versions to before 6.2.4"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-hrpp-h998-j3pp",
+ "retrievedAt": "2026-09-13T15:54:21.055Z"
+ }
+ ]
+ },
+ {
+ "id": "GHSA-w7fw-mjwx-w883",
+ "aliases": [
+ "CVE-2026-2391"
+ ],
+ "summary": "qs's arrayLimit bypass in comma parsing allows denial of service",
+ "cvss": 3.7,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L",
+ "severity": "low",
+ "fixed": "6.14.2",
+ "affected": [
+ "From 6.7.0 to before 6.14.2"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-w7fw-mjwx-w883",
+ "retrievedAt": "2026-09-13T15:54:21.055Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "send",
+ "version": "0.17.1",
+ "advisories": [
+ {
+ "id": "GHSA-m6fv-jmcg-4jfg",
+ "aliases": [
+ "CVE-2024-43799"
+ ],
+ "summary": "send vulnerable to template injection that can lead to XSS",
+ "cvss": 5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L",
+ "severity": "low",
+ "fixed": "0.19.0",
+ "affected": [
+ "Earlier versions to before 0.19.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-m6fv-jmcg-4jfg",
+ "retrievedAt": "2026-09-13T15:54:21.120Z"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "serve-static",
+ "version": "1.14.1",
+ "advisories": [
+ {
+ "id": "GHSA-cm22-4g7w-348p",
+ "aliases": [
+ "CVE-2024-43800"
+ ],
+ "summary": "serve-static vulnerable to template injection that can lead to XSS",
+ "cvss": 5,
+ "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L",
+ "severity": "low",
+ "fixed": "1.16.0",
+ "affected": [
+ "Earlier versions to before 1.16.0",
+ "From 2.0.0 to before 2.1.0"
+ ],
+ "provenance": [
+ {
+ "source": "OSV",
+ "url": "https://osv.dev/vulnerability/GHSA-cm22-4g7w-348p",
+ "retrievedAt": "2026-09-13T15:54:21.174Z"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/verification/live-browser.json b/docs/verification/live-browser.json
new file mode 100644
index 0000000..6601352
--- /dev/null
+++ b/docs/verification/live-browser.json
@@ -0,0 +1,7 @@
+{
+ "verifiedAt": "2026-09-13T21:25:47.885Z",
+ "repository": "GoogleCloudPlatform/microservices-demo",
+ "journey": "GitHub URL → Repository Map → Analyze → source-backed finding → Trace Ripple",
+ "impact": "Ripple traced: affects 2 of 6 projects",
+ "browserErrors": []
+}
\ No newline at end of file
diff --git a/docs/verification/live-repositories.json b/docs/verification/live-repositories.json
new file mode 100644
index 0000000..1cad86f
--- /dev/null
+++ b/docs/verification/live-repositories.json
@@ -0,0 +1,321 @@
+{
+ "testedAt": "2026-09-13T15:57:28.559Z",
+ "results": [
+ {
+ "repository": "expressjs/express",
+ "ref": "master",
+ "commit": "3ce6d0eb86e9d93529ff3191c6bb5db8ce6e72c8",
+ "scanId": "5115fa31-86c4-4507-9ad0-613c805f64cf",
+ "projects": 1,
+ "files": 1,
+ "selected": [
+ {
+ "name": "express",
+ "path": ".",
+ "ecosystem": "npm"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 8,
+ "checked": 8,
+ "advisories": [],
+ "warnings": [
+ "express: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "express: accepts: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: body-parser: ^2.3.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: content-disposition: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: content-type: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: cookie: ^0.7.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: cookie-signature: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: debug: ^4.4.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: depd: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: encodeurl: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: escape-html: ^1.0.3 is not an exact installed version; omitted from vulnerability checks.",
+ "express: etag: ^1.8.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: finalhandler: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: fresh: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: http-errors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: merge-descriptors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: mime-types: ^3.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: on-finished: ^2.4.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: once: ^1.4.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: parseurl: ^1.3.3 is not an exact installed version; omitted from vulnerability checks.",
+ "express: proxy-addr: ^2.0.7 is not an exact installed version; omitted from vulnerability checks.",
+ "express: qs: ^6.15.2 is not an exact installed version; omitted from vulnerability checks.",
+ "express: range-parser: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: router: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: send: ^1.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: serve-static: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: statuses: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: type-is: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: vary: ^1.1.2 is not an exact installed version; omitted from vulnerability checks.",
+ "express: connect-redis: ^8.0.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: ejs: ^3.1.10 is not an exact installed version; omitted from vulnerability checks.",
+ "express: express-session: ^1.18.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: marked: ^15.0.3 is not an exact installed version; omitted from vulnerability checks.",
+ "express: mocha: ^11.7.5 is not an exact installed version; omitted from vulnerability checks.",
+ "express: nyc: ^17.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: supertest: ^6.3.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: vhost: ~3.0.2 is not an exact installed version; omitted from vulnerability checks."
+ ],
+ "durationSeconds": 3
+ },
+ {
+ "repository": "jagpreetsingh02/MIT",
+ "ref": "main",
+ "commit": "c75031ca7546706d042c585852d18b29c8f9015e",
+ "scanId": "85389187-40d3-41e1-a5c4-a5b756c00a95",
+ "projects": 1,
+ "files": 2,
+ "selected": [
+ {
+ "name": "rippleguard",
+ "path": ".",
+ "ecosystem": "npm"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 409,
+ "checked": 409,
+ "advisories": [],
+ "warnings": [
+ "rippleguard: Could not determine the installed dependency @emnapi/core used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @emnapi/runtime used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @emnapi/wasi-threads used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @napi-rs/wasm-runtime used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @tybys/wasm-util used by @tailwindcss/oxide-wasm32-wasi."
+ ],
+ "durationSeconds": 31
+ },
+ {
+ "repository": "npm/cli",
+ "ref": "latest",
+ "commit": "c9876d7ea7150b0702e4151210b9fa1a8dbc7fbf",
+ "scanId": "b4b6f48a-4bbc-4fb3-a23a-f7d3810b1fd3",
+ "projects": 478,
+ "files": 575,
+ "selected": [
+ {
+ "name": "@npmcli/mock-globals",
+ "path": "mock-globals",
+ "ecosystem": "npm"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 797,
+ "checked": 797,
+ "advisories": [
+ "GHSA-2883-xcg3-v3hh",
+ "GHSA-5p4m-2wfm-xmqj",
+ "GHSA-27p8-2357-5qqv",
+ "GHSA-4w3w-2rp5-g8jm",
+ "GHSA-6gmq-8vp8-gcm6",
+ "GHSA-6h8r-xr42-gp59",
+ "GHSA-8344-3jmq-59r6",
+ "GHSA-93r5-fhx6-vmg9",
+ "GHSA-965w-775f-mr7g",
+ "GHSA-c7q8-3ch8-vqpv",
+ "GHSA-w2rr-34g9-rvrj",
+ "GHSA-x4fp-j954-r2f4",
+ "GHSA-4x5r-pxfx-6jf8",
+ "GHSA-23c5-xmqv-rm74",
+ "GHSA-3ppc-4f35-3m26",
+ "GHSA-7r86-cg39-jmmj",
+ "GHSA-f23m-r3pf-42rh",
+ "GHSA-r5fr-rjxr-66jc",
+ "GHSA-3h5v-q93c-6h6q",
+ "GHSA-96hv-2xvq-fx4p",
+ "GHSA-w5hq-g745-h8pq",
+ "GHSA-48c2-rrv3-qjmp",
+ "GHSA-968p-4wvh-cqc8",
+ "GHSA-3jxr-9vmj-r5cp",
+ "GHSA-f886-m6hf-6m8v",
+ "GHSA-mh99-v99m-4gvg",
+ "GHSA-rgw5-rvv9-x895",
+ "GHSA-v6h2-p8h4-qcjw",
+ "GHSA-395f-4hp3-45gv",
+ "GHSA-w7jw-789q-3m8p",
+ "GHSA-73wf-gq98-2v4g",
+ "GHSA-c83g-rgw3-j3cx",
+ "GHSA-5jgf-p345-68v8",
+ "GHSA-7p8r-x3mc-p8w7",
+ "GHSA-f65p-4m7j-42xc",
+ "GHSA-fph4-wmhf-6fwf",
+ "GHSA-jqff-g426-hqxp",
+ "GHSA-v2hh-gcrm-f6hx",
+ "GHSA-w5vr-8v7q-w6rv"
+ ],
+ "warnings": [
+ "@npmcli/mock-globals: Lockfile version for conventional-commits-filter does not satisfy its declared range ^5.0.0; regenerate the lockfile."
+ ],
+ "durationSeconds": 67
+ },
+ {
+ "repository": "pallets/flask",
+ "ref": "main",
+ "commit": "d73fa1cdcbd8b1465c151db8924ba58b1dd14e35",
+ "scanId": "215e35c3-010a-4d2d-ae18-1af9bb659155",
+ "projects": 4,
+ "files": 6,
+ "selected": [
+ {
+ "name": "flask",
+ "path": ".",
+ "ecosystem": "pypi"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 83,
+ "checked": 83,
+ "advisories": [
+ "GHSA-g6cj-pr64-35w5"
+ ],
+ "warnings": [
+ "flask: Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies."
+ ],
+ "durationSeconds": 10
+ },
+ {
+ "repository": "spring-projects/spring-petclinic",
+ "ref": "main",
+ "commit": "818c4136ea971c21674525f9053de0d9c7ad8cfe",
+ "scanId": "6b3d9852-f6b9-4368-aa8d-519cf70c8ed2",
+ "projects": 1,
+ "files": 1,
+ "selected": [
+ {
+ "name": "spring-petclinic",
+ "path": ".",
+ "ecosystem": "maven"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 3,
+ "checked": 3,
+ "advisories": [],
+ "warnings": [
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: javax.cache:cache-api. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: jakarta.xml.bind:jakarta.xml.bind-api. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: com.h2database:h2. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: com.github.ben-manes.caffeine:caffeine. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: com.mysql:mysql-connector-j. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.postgresql:postgresql. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-devtools. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-testcontainers. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-docker-compose. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-junit-jupiter. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-mysql. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json."
+ ],
+ "durationSeconds": 3
+ },
+ {
+ "repository": "GoogleCloudPlatform/microservices-demo",
+ "ref": "main",
+ "commit": "b9a978db9e01f4ad3dca9494a22cb9edc17548fe",
+ "scanId": "2be98337-2417-4c7a-a411-84be00439d55",
+ "projects": 6,
+ "files": 8,
+ "selected": [
+ {
+ "name": "grpc-currency-service",
+ "path": "src/currencyservice",
+ "ecosystem": "npm"
+ },
+ {
+ "name": "emailservice",
+ "path": "src/emailservice",
+ "ecosystem": "pypi"
+ },
+ {
+ "name": "loadgenerator",
+ "path": "src/loadgenerator",
+ "ecosystem": "pypi"
+ },
+ {
+ "name": "paymentservice",
+ "path": "src/paymentservice",
+ "ecosystem": "npm"
+ },
+ {
+ "name": "recommendationservice",
+ "path": "src/recommendationservice",
+ "ecosystem": "pypi"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 333,
+ "checked": 333,
+ "advisories": [
+ "GHSA-f38q-mgvj-vph7",
+ "GHSA-j3f2-48v5-ccww",
+ "GHSA-wcpc-wj8m-hjx6",
+ "GHSA-w5hq-g745-h8pq",
+ "GHSA-8988-4f7v-96qf",
+ "GHSA-2pr8-phx7-x9h3",
+ "GHSA-66ff-xgx4-vchm",
+ "GHSA-685m-2w69-288q",
+ "GHSA-75px-5xx7-5xc7",
+ "GHSA-fx83-v9x8-x52w",
+ "GHSA-jggg-4jg4-v7c6",
+ "GHSA-jvwf-75h9-cwgg",
+ "GHSA-q6x5-8v7m-xcrf",
+ "GHSA-xq3m-2v4x-88gg",
+ "GHSA-hmw2-7cc7-3qxx",
+ "GHSA-vpq2-c234-7xj6",
+ "GHSA-65pc-fj4g-8rjx",
+ "GHSA-8ppf-4f7h-5ppj",
+ "GHSA-hm4w-wwcw-mr6r",
+ "GHSA-jr27-m4p2-rc6r",
+ "GHSA-m4p7-r5rc-7g4j",
+ "GHSA-gc5v-m9x4-r6x2",
+ "GHSA-mf9v-mfxr-j63j",
+ "GHSA-qccp-gfcp-xxvc",
+ "PYSEC-2026-2132",
+ "GHSA-6v7p-g79w-8964",
+ "GHSA-5239-wwwm-4pmq",
+ "GHSA-6w46-j5rx-g56g",
+ "GHSA-cgwc-pv48-fhj5",
+ "GHSA-m9gh-vj53-gvh9",
+ "GHSA-5w7q-77mv-v69f",
+ "GHSA-g8c6-8fjj-2r4m"
+ ],
+ "warnings": [
+ "emailservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "loadgenerator: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "recommendationservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound."
+ ],
+ "durationSeconds": 39
+ },
+ {
+ "repository": "octocat/Hello-World",
+ "ref": "master",
+ "commit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
+ "scanId": "04fd98d5-1834-4673-ad2e-cbd265e270bd",
+ "projects": 0,
+ "files": 0,
+ "selected": [],
+ "status": "mapped",
+ "packageVersions": 0,
+ "checked": 0,
+ "advisories": [],
+ "warnings": [],
+ "durationSeconds": 2
+ }
+ ]
+}
\ No newline at end of file
diff --git a/scripts/verify-controlled-demo.ts b/scripts/verify-controlled-demo.ts
new file mode 100644
index 0000000..b4fbaaa
--- /dev/null
+++ b/scripts/verify-controlled-demo.ts
@@ -0,0 +1,65 @@
+import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
+import { discoverProjects, type SourceFile } from "../src/server/discovery.js";
+import { resolveProject } from "../src/server/resolve-project.js";
+import { finalize, risks, simulate } from "../src/server/graph.js";
+import { enrichGraph } from "../src/server/connectors.js";
+import { Transport } from "../src/server/transport.js";
+import { Store } from "../src/server/store.js";
+const names = [
+ "package.json",
+ "package-lock.json",
+ "apps/storefront/package.json",
+ "apps/checkout/package.json",
+ "apps/admin/package.json",
+ "packages/shared-http/package.json",
+];
+const files: SourceFile[] = await Promise.all(
+ names.map(async (filename) => ({
+ filename,
+ content: await readFile("demo-repository/" + filename, "utf8"),
+ provenance: {
+ source: "Controlled demo lockfile",
+ url: "",
+ retrievedAt: new Date().toISOString(),
+ },
+ })),
+);
+const map = discoverProjects(
+ names.map((path) => ({ path, sha: "" })),
+ "demo/rippleguard-recording",
+ "main",
+ "local-prepared",
+);
+const selected = map.projects.filter((p) => p.path.startsWith("apps/"));
+const graphs = selected.map((p) => resolveProject(p, files));
+const graph = finalize({
+ nodes: graphs.flatMap((g) => g.nodes),
+ edges: graphs.flatMap((g) => g.edges),
+ warnings: graphs.flatMap((g) => g.warnings),
+});
+const store = new Store("data/controlled-demo-verification.sqlite");
+try {
+ await enrichGraph(graph, new Transport(store), () => {});
+ const ranked = risks(graph);
+ const top = ranked[0];
+ const ripple = simulate(graph, top.nodeId);
+ const report = {
+ verifiedAt: new Date().toISOString(),
+ projects: selected.map((p) => p.path),
+ packageVersions: ranked.length,
+ checked: graph.nodes.filter((n) => n.coverage === "checked").length,
+ findings: ranked.filter((r) => r.advisories > 0).length,
+ top: { name: graph.nodes.find((n) => n.id === top.nodeId)?.name, ...top },
+ ripple: { projects: ripple.services.length, paths: ripple.pathCount },
+ warnings: graph.warnings,
+ advisories: graph.nodes
+ .filter((n) => n.advisories.length)
+ .map((n) => ({ name: n.name, version: n.version, advisories: n.advisories })),
+ };
+ await mkdir("docs/verification", { recursive: true });
+ await writeFile("docs/verification/controlled-demo.json", JSON.stringify(report, null, 2));
+ console.log(JSON.stringify({ ...report, advisories: report.advisories.length }));
+ if (!report.findings || ripple.services.length !== 3) process.exitCode = 1;
+} finally {
+ store.close();
+}
diff --git a/scripts/verify-live-browser.ts b/scripts/verify-live-browser.ts
new file mode 100644
index 0000000..b48454f
--- /dev/null
+++ b/scripts/verify-live-browser.ts
@@ -0,0 +1,40 @@
+import { chromium, expect } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+const browser = await chromium.launch(process.env.PW_CHROME ? { channel: "chrome" } : {});
+try {
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
+ const errors: string[] = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto((process.env.VERIFY_BASE || "http://127.0.0.1:3000") + "/app#new");
+ await page
+ .getByLabel("Repository URL")
+ .fill("https://github.com/GoogleCloudPlatform/microservices-demo");
+ await page.getByRole("button", { name: "Map repository", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Your repository, understood." })).toBeVisible({
+ timeout: 120000,
+ });
+ await page.getByRole("button", { name: "Analyze repository", exact: true }).click();
+ await expect(page.getByRole("heading", { name: /Start here:/ })).toBeVisible({ timeout: 300000 });
+ await page.getByRole("button", { name: "Trace Ripple", exact: true }).first().click();
+ const impact = page.getByRole("heading", { name: /Ripple traced: affects/ });
+ await expect(impact).toBeVisible();
+ await expect(page.locator('.package-details a[href^="https://osv.dev/"]').first()).toBeVisible();
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
+ ).toBeTruthy();
+ expect(errors).toEqual([]);
+ await mkdir("test-results", { recursive: true });
+ await page.screenshot({ path: "test-results/live-github-ripple.png" });
+ const report = {
+ verifiedAt: new Date().toISOString(),
+ repository: "GoogleCloudPlatform/microservices-demo",
+ journey: "GitHub URL → Repository Map → Analyze → source-backed finding → Trace Ripple",
+ impact: await impact.innerText(),
+ browserErrors: errors,
+ };
+ await mkdir("docs/verification", { recursive: true });
+ await writeFile("docs/verification/live-browser.json", JSON.stringify(report, null, 2));
+ console.log(JSON.stringify(report));
+} finally {
+ await browser.close();
+}
diff --git a/scripts/verify-real-repos.ts b/scripts/verify-real-repos.ts
new file mode 100644
index 0000000..4919698
--- /dev/null
+++ b/scripts/verify-real-repos.ts
@@ -0,0 +1,102 @@
+import { writeFile, mkdir } from "node:fs/promises";
+const base = process.env.VERIFY_BASE || "http://127.0.0.1:3000/api/v1";
+const repositories = process.argv.slice(2).length
+ ? process.argv.slice(2)
+ : [
+ "expressjs/express",
+ "jagpreetsingh02/MIT",
+ "npm/cli",
+ "pallets/flask",
+ "spring-projects/spring-petclinic",
+ "GoogleCloudPlatform/microservices-demo",
+ "octocat/Hello-World",
+ ];
+async function request(path: string, body?: unknown) {
+ const response = await fetch(base + path, {
+ method: body ? "POST" : "GET",
+ headers: body ? { "content-type": "application/json" } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error);
+ return data;
+}
+async function wait(id: string, statuses: string[]) {
+ const started = Date.now();
+ let scan;
+ while (Date.now() - started < 600000) {
+ scan = await request("/scans/" + id);
+ if (statuses.includes(scan.status) || scan.status === "failed") return scan;
+ await new Promise((r) => setTimeout(r, 1700));
+ }
+ throw new Error("Timed out waiting for " + id);
+}
+let results: any[] = [];
+try {
+ const { readFile } = await import("node:fs/promises");
+ results = JSON.parse(
+ await readFile("docs/verification/live-repositories.json", "utf8"),
+ ).results.filter((r: any) => !repositories.includes(r.repository));
+} catch {}
+for (const repository of repositories) {
+ const started = Date.now();
+ try {
+ const created = await request("/scans", { mode: "github", repository, ref: "HEAD" });
+ let scan = await wait(created.id, ["mapped"]);
+ if (scan.status === "failed") throw new Error(scan.error);
+ console.log(
+ repository +
+ ": mapped " +
+ scan.repositoryMap.projects.length +
+ " projects / " +
+ scan.repositoryMap.fileCount +
+ " files",
+ );
+ const candidates = scan.repositoryMap.projects.filter((p: any) => p.selected);
+ const selected =
+ repository === "GoogleCloudPlatform/microservices-demo"
+ ? candidates.filter((p: any) => ["pypi", "maven", "npm"].includes(p.ecosystem)).slice(0, 5)
+ : candidates.filter((p: any) => p.role !== "workspace").slice(0, 1);
+ if (selected.length) {
+ await request("/scans/" + scan.id + "/analyze", {
+ projects: selected.map((p: any) => ({ id: p.id, name: p.name })),
+ });
+ scan = await wait(scan.id, ["completed"]);
+ }
+ const packages = scan.graph?.nodes.filter((n: any) => n.kind === "package") || [];
+ const unique = [...new Map(packages.map((n: any) => [n.purl, n])).values()];
+ const record = {
+ repository,
+ ref: scan.ref,
+ commit: scan.commit,
+ scanId: scan.id,
+ projects: scan.repositoryMap.projects.length,
+ files: scan.repositoryMap.fileCount,
+ selected: selected.map((p: any) => ({ name: p.name, path: p.path, ecosystem: p.ecosystem })),
+ status: scan.status,
+ packageVersions: unique.length,
+ checked: unique.filter((n) => n.coverage === "checked").length,
+ advisories: [...new Set(unique.flatMap((n) => n.advisories.map((a: any) => a.id)))],
+ warnings: scan.graph?.warnings || scan.repositoryMap.warnings,
+ error: scan.error,
+ durationSeconds: Math.round((Date.now() - started) / 1000),
+ };
+ results.push(record);
+ console.log(
+ JSON.stringify({
+ ...record,
+ warnings: record.warnings.length,
+ advisories: record.advisories.length,
+ }),
+ );
+ } catch (error) {
+ results.push({ repository, error: (error as Error).message });
+ console.log(repository + ": " + (error as Error).message);
+ }
+ await mkdir("docs/verification", { recursive: true });
+ await writeFile(
+ "docs/verification/live-repositories.json",
+ JSON.stringify({ testedAt: new Date().toISOString(), results }, null, 2),
+ );
+}
+if (results.some((result) => result.error || result.status === "failed")) process.exitCode = 1;
diff --git a/tsconfig.json b/tsconfig.json
index 22c07da..e058a9e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1 +1,34 @@
-{"compilerOptions":{"target":"ES2023","lib":["ES2023","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"esModuleInterop":true,"resolveJsonModule":true,"skipLibCheck":true,"noEmit":true,"types":["node","vite/client"],"paths":{"@/*":["./src/web/*"]}},"include":["src","tests","vite.config.ts"]}
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": [
+ "ES2023",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": [
+ "node",
+ "vite/client"
+ ],
+ "paths": {
+ "@/*": [
+ "./src/web/*"
+ ]
+ }
+ },
+ "include": [
+ "src",
+ "tests",
+ "vite.config.ts",
+ "scripts",
+ "playwright.config.ts"
+ ]
+}
From 39df873c8434eadfcd71056b1339ab26c0870b1f Mon Sep 17 00:00:00 2001
From: Jagpreet Singh
Date: Mon, 14 Sep 2026 11:20:02 +0530
Subject: [PATCH 4/5] Preserve unresolved declarations and preview actual
project extraction
---
src/server/connectors.ts | 4 +-
src/server/declarations.ts | 136 +++++++++++++++++++
src/server/graph.ts | 25 +++-
src/server/jobs.ts | 133 ++++++++++++-------
src/server/resolve-project.ts | 176 ++++++++++++++++++++++++-
src/shared/types.ts | 14 ++
src/web/components/DependencyGraph.tsx | 2 +-
src/web/components/PackageDetails.tsx | 19 ++-
src/web/components/RepositoryMap.tsx | 7 +
src/web/main.tsx | 39 ++++--
tests/ingestion.test.ts | 162 +++++++++++++++++++++++
11 files changed, 643 insertions(+), 74 deletions(-)
create mode 100644 src/server/declarations.ts
create mode 100644 tests/ingestion.test.ts
diff --git a/src/server/connectors.ts b/src/server/connectors.ts
index 274e5c3..fc27c7a 100644
--- a/src/server/connectors.ts
+++ b/src/server/connectors.ts
@@ -207,7 +207,9 @@ export async function enrichGraph(
const registries = new Registries(http);
const vulnerabilities = new Vulnerabilities(http);
const groups = new Map();
- for (const n of graph.nodes.filter((n) => n.kind === "package"))
+ for (const n of graph.nodes.filter(
+ (n) => n.kind === "package" && n.versionStatus !== "unresolved" && !!n.version,
+ ))
groups.set(n.purl, [...(groups.get(n.purl) || []), n]);
const packages = [...groups.values()].map((g) => g[0]);
const configured = Number(process.env.MAX_ENRICH_PACKAGES || 2000);
diff --git a/src/server/declarations.ts b/src/server/declarations.ts
new file mode 100644
index 0000000..0392853
--- /dev/null
+++ b/src/server/declarations.ts
@@ -0,0 +1,136 @@
+import { parse as toml } from "smol-toml";
+import { XMLParser } from "fast-xml-parser";
+import semver from "semver";
+import type { DependencyDeclaration, Project } from "../shared/types.js";
+import type { SourceFile } from "./discovery.js";
+
+/** Static declarations only. Never resolve a range against the latest registry release. */
+export function declarations(project: Project, files: SourceFile[]) {
+ const found: DependencyDeclaration[] = [];
+ const warnings: string[] = [];
+ for (const file of files) {
+ const base = file.filename.split("/").pop()!;
+ const add = (
+ name: string,
+ specifier: unknown,
+ scope: DependencyDeclaration["scope"] = "runtime",
+ exactVersion?: string,
+ ) => {
+ if (!name || typeof name !== "string") return;
+ found.push({
+ name,
+ specifier: String(specifier ?? ""),
+ file: file.filename,
+ scope,
+ exactVersion,
+ });
+ };
+ const requirement = (raw: unknown, scope: DependencyDeclaration["scope"] = "runtime") => {
+ const value = String(raw).trim();
+ if (!value || value.startsWith("#")) return;
+ const match = value.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)(?:\[[^\]]+\])?\s*(.*)$/);
+ if (!match || value.startsWith("-")) {
+ warnings.push(
+ `${file.filename}: unsupported requirement directive ${value.slice(0, 120)}; not executed.`,
+ );
+ return;
+ }
+ const specifier = match[2].replace(/\s+#.*$/, "").trim();
+ const pin = specifier.match(/^==([0-9][\w.!+-]*)(?:\s*(?:;.*|--hash=.*|\\))?$/);
+ add(match[1], specifier, scope, pin?.[1]);
+ };
+ try {
+ if (base === "package.json") {
+ const data = JSON.parse(file.content);
+ for (const group of [
+ "dependencies",
+ "devDependencies",
+ "optionalDependencies",
+ "peerDependencies",
+ ])
+ for (const [name, spec] of Object.entries(data[group] || {}))
+ add(
+ name,
+ spec,
+ group === "devDependencies" ? "development" : "runtime",
+ typeof spec === "string" && semver.valid(spec) ? spec : undefined,
+ );
+ } else if (/^requirements.*\.txt$/.test(base)) {
+ for (const line of file.content.split("\n"))
+ requirement(line, /dev|test/.test(base) ? "development" : "unknown");
+ } else if (base === "pyproject.toml") {
+ const data = toml(file.content) as any;
+ for (const value of data.project?.dependencies || []) requirement(value);
+ for (const [group, values] of Object.entries(data.project?.["optional-dependencies"] || {}))
+ for (const value of values as any[])
+ requirement(value, /^(dev|test|tests)$/.test(group) ? "development" : "unknown");
+ for (const [group, values] of Object.entries(data["dependency-groups"] || {}))
+ for (const value of values as any[])
+ if (typeof value === "string")
+ requirement(value, /dev|test/.test(group) ? "development" : "unknown");
+ const poetry = data.tool?.poetry;
+ const groups = [
+ { values: poetry?.dependencies, scope: "runtime" as const },
+ { values: poetry?.["dev-dependencies"], scope: "development" as const },
+ ...Object.values(poetry?.group || {}).map((g: any) => ({
+ values: g.dependencies,
+ scope: "unknown" as const,
+ })),
+ ];
+ for (const { values, scope } of groups)
+ for (const [name, value] of Object.entries(values || {})) {
+ if (name === "python") continue;
+ const spec =
+ typeof value === "string" ? value : (value as any)?.version || JSON.stringify(value);
+ add(name, spec, scope, /^[0-9][\w.!+-]*$/.test(spec) ? spec : undefined);
+ }
+ for (const value of data["build-system"]?.requires || []) requirement(value, "development");
+ } else if (base === "Pipfile" || base === "Pipfile.lock") {
+ const data = base.endsWith(".lock") ? JSON.parse(file.content) : toml(file.content);
+ for (const group of base.endsWith(".lock")
+ ? ["default", "develop"]
+ : ["packages", "dev-packages"])
+ for (const [name, value] of Object.entries((data as any)[group] || {})) {
+ const spec =
+ typeof value === "string" ? value : (value as any).version || JSON.stringify(value);
+ const pin = spec.match(/^==([0-9][\w.!+-]*)$/);
+ add(name, spec, group.includes("dev") ? "development" : "unknown", pin?.[1]);
+ }
+ } else if (base === "pom.xml") {
+ if (/ doc.properties?.[key] || original,
+ );
+ add(
+ d.groupId + ":" + d.artifactId,
+ spec,
+ d.scope === "test" ? "development" : "runtime",
+ /^[0-9][\w.!+-]*$/.test(version) ? version : undefined,
+ );
+ }
+ } else if (/^setup\.(py|cfg)$/.test(base))
+ warnings.push(
+ `${file.filename}: static dependency extraction is not supported; setup code was not executed.`,
+ );
+ } catch (error) {
+ warnings.push(`${file.filename}: declaration parsing failed: ${(error as Error).message}`);
+ }
+ }
+ return {
+ declarations: [
+ ...new Map(found.map((d) => [`${d.name}:${d.specifier}:${d.scope}`, d])).values(),
+ ],
+ warnings,
+ };
+}
diff --git a/src/server/graph.ts b/src/server/graph.ts
index 1469aaa..87b1d90 100644
--- a/src/server/graph.ts
+++ b/src/server/graph.ts
@@ -127,7 +127,10 @@ function trace(
): Simulation {
const selected = index.nodes.get(nodeId);
if (!selected) throw new Error("Package not found in this scan.");
- const seeds = selected.kind === "service" ? [nodeId] : index.byPurl.get(selected.purl)!;
+ const seeds =
+ selected.kind === "service" || selected.versionStatus === "unresolved"
+ ? [nodeId]
+ : index.byPurl.get(selected.purl)!;
const seedSet = new Set(seeds);
const seen = new Set(seeds);
const queue = [...seeds];
@@ -199,10 +202,28 @@ export function simulate(graph: Graph, nodeId: string): Simulation {
export function risks(graph: Graph): Risk[] {
const index = indexGraph(graph);
const unique = [
- ...new Map(graph.nodes.filter((n) => n.kind === "package").map((n) => [n.purl, n])).values(),
+ ...new Map(
+ graph.nodes
+ .filter((n) => n.kind === "package")
+ .map((n) => [n.versionStatus === "unresolved" ? n.id : n.purl, n]),
+ ).values(),
];
return unique
.map((n) => {
+ if (n.versionStatus === "unresolved")
+ return {
+ nodeId: n.id,
+ score: 0,
+ level: "low" as const,
+ factors: { severity: 0, exploit: 0, exposure: 0, blastRadius: 0 },
+ ancestors: 0,
+ services: [],
+ advisories: 0,
+ coverage: n.coverage,
+ reasons: [
+ "Exact installed version unresolved; vulnerability matching and priority scoring were skipped.",
+ ],
+ };
const ripple = trace(graph, n.id, index, false);
const instances = index.byPurl.get(n.purl)!.map((id) => index.nodes.get(id)!);
const advisories = instances.flatMap((n) => n.advisories);
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 03b293c..0e5192a 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -14,6 +14,7 @@ import type {
ProjectSelection,
ScanProgress,
Graph,
+ Project,
} from "../shared/types.js";
export const connectors = (): ConnectorHealth[] => [
...["GitHub", "npm", "PyPI", "Maven Central", "OSV", "NVD", "CVE Program", "CISA KEV"].map(
@@ -116,6 +117,57 @@ export class Jobs {
const http = this.httpFactory();
const github = new GitHub(http);
const input = this.store.input(scan.id);
+ const loaded = new Map>();
+ const extract = async (project: Project): Promise => {
+ const paths = [
+ ...new Set(
+ [...project.files, project.manifest, project.lockfile].filter((p): p is string => !!p),
+ ),
+ ];
+ const files: SourceFile[] = [];
+ const failures: string[] = [];
+ for (const path of paths) {
+ try {
+ if (!loaded.has(path))
+ loaded.set(
+ path,
+ input.mode === "manifest"
+ ? Promise.resolve({
+ filename: path,
+ content: input.content!,
+ provenance: {
+ source: "Uploaded manifest",
+ url: "",
+ retrievedAt: scan.createdAt,
+ },
+ })
+ : github.file(
+ input.repository!,
+ scan.commit!,
+ this.store.files(scan.id).find((f) => f.path === path)!,
+ input.installationId,
+ ),
+ );
+ files.push(await loaded.get(path)!);
+ } catch (error) {
+ failures.push(path + ": " + (error as Error).message);
+ }
+ }
+ let g: Graph;
+ try {
+ g = resolveProject(project, files);
+ } catch (error) {
+ g = resolveProject(project, []);
+ failures.push("Project extraction failed: " + (error as Error).message);
+ }
+ g.warnings.push(...failures);
+ if (failures.length) {
+ project.resolution = "partial";
+ if (project.exactCount) project.ingestionStatus = "PARTIAL";
+ }
+ project.notes = [...new Set([...project.notes, ...g.warnings])];
+ return g;
+ };
const persist = () => {
scan.connectors = connectors().map((c) => http.health.get(c.name) || c);
this.store.save(scan);
@@ -173,6 +225,28 @@ export class Jobs {
"upload",
"uploaded-file",
);
+ if (input.mode !== "demo") {
+ let cursor = 0,
+ completed = 0;
+ const projects = scan.repositoryMap!.projects;
+ await Promise.all(
+ Array.from({ length: 6 }, async () => {
+ while (cursor < projects.length) {
+ const project = projects[cursor++];
+ await extract(project);
+ completed++;
+ if (completed % 10 === 0 || completed === projects.length)
+ this.stage(
+ scan,
+ "discover",
+ "Reading dependency declarations: " + completed + " of " + projects.length,
+ completed,
+ projects.length,
+ );
+ }
+ }),
+ );
+ }
scan.status = "mapped";
this.stage(
scan,
@@ -218,58 +292,14 @@ export class Jobs {
p.analyzed = true;
});
} else {
- const tree = this.store.files(scan.id);
- const loaded = new Map();
for (let i = 0; i < chosen.length; i++) {
const project = chosen[i];
- this.stage(scan, "extract", `Reading ${project.name}`, i, chosen.length);
- try {
- const paths = [
- ...new Set([project.manifest, project.lockfile].filter((p): p is string => !!p)),
- ];
- const files: SourceFile[] = [];
- for (const path of paths) {
- try {
- if (!loaded.has(path))
- loaded.set(
- path,
- input.mode === "manifest"
- ? {
- filename: path,
- content: input.content!,
- provenance: {
- source: "Uploaded manifest",
- url: "",
- retrievedAt: scan.createdAt,
- },
- }
- : await github.file(
- input.repository!,
- scan.commit!,
- tree.find((f) => f.path === path)!,
- input.installationId,
- ),
- );
- files.push(loaded.get(path)!);
- } catch (error) {
- project.notes.push((error as Error).message);
- }
- }
- const g = resolveProject(project, files);
- graph.nodes.push(...g.nodes);
- graph.edges.push(...g.edges);
- graph.warnings.push(...g.warnings.map((w) => `${project.name}: ${w}`));
- project.notes = [...new Set([...project.notes, ...g.warnings])];
- project.resolution = g.warnings.length ? "partial" : "resolved";
- project.packageCount = g.nodes.filter((n) => n.kind === "package").length;
- project.analyzed = true;
- } catch (error) {
- project.resolution = "failed";
- project.notes.push((error as Error).message);
- graph.warnings.push(
- `${project.name}: ${(error as Error).message}. Other projects were retained.`,
- );
- }
+ this.stage(scan, "extract", "Reading " + project.name, i, chosen.length);
+ const g = await extract(project);
+ graph.nodes.push(...g.nodes);
+ graph.edges.push(...g.edges);
+ graph.warnings.push(...g.warnings.map((w) => project.name + ": " + w));
+ project.analyzed = true;
this.stage(
scan,
"extract",
@@ -289,7 +319,8 @@ export class Jobs {
"vulnerabilities",
"Checking known vulnerabilities",
0,
- graph.nodes.filter((n) => n.kind === "package").length,
+ graph.nodes.filter((n) => n.kind === "package" && n.versionStatus !== "unresolved")
+ .length,
);
await enrichGraph(scan.graph, http, (current, total, message) => {
this.stage(
diff --git a/src/server/resolve-project.ts b/src/server/resolve-project.ts
index f2cbcd6..e62a738 100644
--- a/src/server/resolve-project.ts
+++ b/src/server/resolve-project.ts
@@ -5,6 +5,7 @@ import { parse as parseYaml } from "yaml";
import semver from "semver";
import { node, finalize } from "./graph.js";
import { parseManifest } from "./parsers.js";
+import { declarations } from "./declarations.js";
import type { Graph, PackageNode, Project, Provenance } from "../shared/types.js";
import type { SourceFile } from "./discovery.js";
const hash = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 20);
@@ -24,6 +25,105 @@ function rootFor(p: Project, version = "0.0.0") {
return n;
}
export function resolveProject(project: Project, files: SourceFile[]): Graph {
+ const declared = declarations(
+ project,
+ files.filter(
+ (f) =>
+ project.files.includes(f.filename) ||
+ f.filename === project.manifest ||
+ f.filename === project.lockfile,
+ ),
+ );
+ let graph: Graph;
+ try {
+ graph = resolveExact(project, files);
+ } catch (error) {
+ graph = {
+ nodes: [rootFor(project)],
+ edges: [],
+ warnings: [`Exact resolution failed: ${(error as Error).message}`],
+ };
+ if (project.ecosystem === "npm") {
+ try {
+ const fallback = npmManifest(
+ project,
+ files.find((f) => f.filename === project.manifest),
+ );
+ graph.nodes.push(...fallback.nodes.filter((n) => n.kind === "package"));
+ graph.edges.push(...fallback.edges);
+ } catch {}
+ }
+ }
+ graph.warnings.push(...declared.warnings);
+ const normalize = (name: string) =>
+ project.ecosystem === "pypi" ? name.toLowerCase().replace(/[-_.]+/g, "-") : name;
+ for (const d of declared.declarations) {
+ if (
+ graph.nodes.some(
+ (n) =>
+ n.kind === "package" &&
+ normalize(n.name) === normalize(d.name) &&
+ (n.versionStatus === "unresolved"
+ ? !d.exactVersion && n.declaredSpecifier === d.specifier
+ : !d.exactVersion || n.version === d.exactVersion),
+ )
+ )
+ continue;
+ const n = node(project.ecosystem, d.name, d.exactVersion || "");
+ n.id = hash(project.id + ":" + normalize(d.name) + ":" + (d.exactVersion || d.specifier));
+ n.projectId = project.id;
+ n.versionStatus = d.exactVersion ? "exact" : "unresolved";
+ n.declaredSpecifier = d.specifier;
+ n.scope = d.scope;
+ n.relationship = "listed";
+ n.coverage = d.exactVersion ? "unchecked" : "partial";
+ n.provenance = files.filter((f) => f.filename === d.file).map((f) => f.provenance);
+ graph.nodes.push(n);
+ graph.edges.push({
+ from: "service-" + project.id,
+ to: n.id,
+ projectId: project.id,
+ relationship: "listed",
+ scope: d.scope,
+ });
+ }
+ graph = finalize(graph);
+ project.declarations = declared.declarations;
+ const packages = graph.nodes.filter((n) => n.kind === "package");
+ project.packageCount = packages.length;
+ project.exactCount = packages.filter((n) => n.versionStatus !== "unresolved").length;
+ project.unresolvedCount = packages.length - project.exactCount;
+ if (!packages.length)
+ graph.warnings.push(
+ files.length
+ ? "No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects."
+ : "No dependency files could be read for this project.",
+ );
+ if (project.unresolvedCount)
+ graph.warnings.push(
+ `${project.unresolvedCount} dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching.`,
+ );
+ project.ingestionStatus = !packages.length
+ ? "UNSUPPORTED"
+ : !project.exactCount
+ ? "DECLARED_ONLY"
+ : graph.warnings.length || project.unresolvedCount
+ ? "PARTIAL"
+ : "EXACT";
+ project.relationships = graph.edges.some((e) => e.relationship === "listed")
+ ? "partial"
+ : graph.edges.length
+ ? "recorded"
+ : "unavailable";
+ project.resolution =
+ project.ingestionStatus === "EXACT"
+ ? "resolved"
+ : project.ingestionStatus === "UNSUPPORTED"
+ ? "unsupported"
+ : "partial";
+ return graph;
+}
+function resolveExact(project: Project, files: SourceFile[]): Graph {
const lock = files.find((f) => f.filename === project.lockfile);
const manifest = files.find((f) => f.filename === project.manifest);
if (lock && /(?:package-lock|npm-shrinkwrap)\.json$/.test(lock.filename))
@@ -68,8 +168,34 @@ export function resolveProject(project: Project, files: SourceFile[]): Graph {
return finalize(graph);
}
function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
- const data = JSON.parse(file.content);
- const info = manifest ? JSON.parse(manifest.content) : {};
+ let data = JSON.parse(file.content);
+ let info: any = {};
+ let manifestError = "";
+ try {
+ info = manifest ? JSON.parse(manifest.content) : {};
+ } catch {
+ manifestError = "Manifest JSON could not be parsed; installed lockfile entries were retained.";
+ }
+ if (data.lockfileVersion === 1 && data.dependencies) {
+ const entries: Record = {
+ "": {
+ name: data.name,
+ version: data.version,
+ dependencies: Object.fromEntries(
+ Object.entries(data.dependencies).map(([name, d]: [string, any]) => [name, d.version]),
+ ),
+ },
+ };
+ const walk = (deps: Record, parent = "") => {
+ for (const [name, d] of Object.entries(deps)) {
+ const path = (parent ? parent + "/" : "") + "node_modules/" + name;
+ entries[path] = { ...d, dependencies: d.requires || {} };
+ if (d.dependencies) walk(d.dependencies, path);
+ }
+ };
+ walk(data.dependencies);
+ data = { ...data, lockfileVersion: 2, packages: entries };
+ }
if (![2, 3].includes(data.lockfileVersion) || !data.packages)
return {
...npmManifest(p, manifest),
@@ -78,6 +204,8 @@ function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
],
};
const entries = data.packages as Record;
+ if (!entries[""] && p.path === posix.dirname(file.filename))
+ entries[""] = { name: data.name, version: data.version };
const base = posix.dirname(file.filename);
const entry = posix.relative(base, p.path === "." ? "." : p.path);
if (!Object.hasOwn(entries, entry)) {
@@ -89,7 +217,7 @@ function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
}
const root = rootFor(p, info.version || entries[entry].version);
root.provenance = [file.provenance];
- const graph: Graph = { nodes: [root], edges: [], warnings: [] };
+ const graph: Graph = { nodes: [root], edges: [], warnings: manifestError ? [manifestError] : [] };
const byPath = new Map();
const pending: string[] = [];
const resolvePath = (from: string, name: string): string | undefined => {
@@ -162,13 +290,53 @@ function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
graph.warnings.push(
`Lockfile version for ${name} does not satisfy its declared range ${requested}; regenerate the lockfile.`,
);
- } else if (!record.optionalDependencies?.[name] && !record.peerDependencies?.[name])
+ } else {
+ const missing = node("npm", name, "");
+ missing.id = hash(file.filename + ":" + path + ":" + name + ":" + String(requested));
+ missing.versionStatus = "unresolved";
+ missing.declaredSpecifier = String(requested);
+ missing.coverage = "partial";
+ missing.scope = "unknown";
+ missing.projectId = p.id;
+ missing.relationship = "listed";
+ missing.provenance = [file.provenance];
+ graph.nodes.push(missing);
+ graph.edges.push({
+ from: parent.id,
+ to: missing.id,
+ projectId: p.id,
+ relationship: "listed",
+ scope: "unknown",
+ });
graph.warnings.push(
`Could not determine the installed dependency ${name} used by ${parent.name}.`,
);
+ }
}
}
connect(root, entry, { ...entries[entry], ...info }, true);
+ if (
+ !graph.nodes.some((n) => n.kind === "package") &&
+ entry === "" &&
+ !entries[entry].workspaces &&
+ !info.workspaces
+ ) {
+ for (const path of Object.keys(entries).filter((path) => path.startsWith("node_modules/"))) {
+ const child = add(path);
+ if (child)
+ graph.edges.push({
+ from: root.id,
+ to: child.id,
+ projectId: p.id,
+ relationship: "listed",
+ scope: "unknown",
+ });
+ }
+ if (graph.nodes.length > 1)
+ graph.warnings.push(
+ "Lockfile packages were retained, but root dependency declarations were unavailable. Listed links do not establish direct ancestry.",
+ );
+ }
for (let i = 0; i < pending.length; i++) {
if (i >= 10000) {
graph.warnings.push(
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 4d85f95..38a18f7 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -21,6 +21,8 @@ export interface PackageNode {
id: string;
name: string;
version: string;
+ versionStatus?: "exact" | "unresolved";
+ declaredSpecifier?: string;
ecosystem: Ecosystem;
purl: string;
kind: "package" | "service";
@@ -121,6 +123,18 @@ export interface Project {
notes: string[];
packageCount?: number;
analyzed?: boolean;
+ declarations?: DependencyDeclaration[];
+ exactCount?: number;
+ unresolvedCount?: number;
+ ingestionStatus?: "EXACT" | "PARTIAL" | "DECLARED_ONLY" | "UNSUPPORTED";
+ relationships?: "recorded" | "partial" | "unavailable";
+}
+export interface DependencyDeclaration {
+ name: string;
+ specifier: string;
+ file: string;
+ scope: PackageNode["scope"];
+ exactVersion?: string;
}
export interface RepositoryMap {
repository: string;
diff --git a/src/web/components/DependencyGraph.tsx b/src/web/components/DependencyGraph.tsx
index 78e1829..037e3d6 100644
--- a/src/web/components/DependencyGraph.tsx
+++ b/src/web/components/DependencyGraph.tsx
@@ -215,7 +215,7 @@ export function DependencyGraph({
? isAffected
? "AFFECTED APPLICATION"
: "APPLICATION / PROJECT"
- : `${n.version}${n.advisories.length ? " · " + (risk?.level || "known") + " priority" : ""}`}
+ : `${n.versionStatus === "unresolved" ? "Version unresolved" : n.version}${n.advisories.length ? " · " + (risk?.level || "known") + " priority" : ""}`}
);
diff --git a/src/web/components/PackageDetails.tsx b/src/web/components/PackageDetails.tsx
index 76f893b..c5b8349 100644
--- a/src/web/components/PackageDetails.tsx
+++ b/src/web/components/PackageDetails.tsx
@@ -25,11 +25,14 @@ export function PackageDetails({
{node.name}
- {node.version} · {node.ecosystem}
+ {node.versionStatus === "unresolved"
+ ? (node.declaredSpecifier || "No version declared") + " · exact version unresolved"
+ : node.version}{" "}
+ · {node.ecosystem}
- {risk && (
+ {risk && node.versionStatus !== "unresolved" && (
Ripple Priority
@@ -96,11 +99,13 @@ export function PackageDetails({
{node.advisories.length ? "Known vulnerabilities" : "Vulnerability coverage"}
{!node.advisories.length ? (
- {node.coverage === "checked"
- ? "No known vulnerabilities were found in this package by the sources successfully checked."
- : node.coverage === "fixture"
- ? "No advisory in the synthetic demo fixture."
- : "This package was not fully checked. No security conclusion can be drawn."}
+ {node.versionStatus === "unresolved"
+ ? "Discovered declaration; exact installed version is unresolved. Not sent to vulnerability matching."
+ : node.coverage === "checked"
+ ? "No known vulnerabilities were found in this package by the sources successfully checked."
+ : node.coverage === "fixture"
+ ? "No advisory in the synthetic demo fixture."
+ : "This package was not fully checked. No security conclusion can be drawn."}
) : (
node.advisories.map((a) => (
diff --git a/src/web/components/RepositoryMap.tsx b/src/web/components/RepositoryMap.tsx
index ae55794..c85fb61 100644
--- a/src/web/components/RepositoryMap.tsx
+++ b/src/web/components/RepositoryMap.tsx
@@ -75,6 +75,13 @@ export function RepositoryMap({
Manifest: {project.manifest || "not present"}
{project.lockfile && Lockfile: {project.lockfile} }
+ {project.packageCount !== undefined && (
+
+ Detected dependencies: {project.packageCount} · Exact versions:{" "}
+ {project.exactCount ?? 0} · Unresolved: {project.unresolvedCount ?? 0} · Coverage:{" "}
+ {project.ingestionStatus}
+
+ )}
{project.notes.map((note, i) => (
{note}
diff --git a/src/web/main.tsx b/src/web/main.tsx
index d939ce4..49be835 100644
--- a/src/web/main.tsx
+++ b/src/web/main.tsx
@@ -224,12 +224,16 @@ function App() {
const packages = useMemo(
() => [
...new Map(
- (graph?.nodes || []).filter((n) => n.kind === "package").map((n) => [n.purl, n]),
+ (graph?.nodes || [])
+ .filter((n) => n.kind === "package")
+ .map((n) => [n.versionStatus === "unresolved" ? n.id : n.purl, n]),
).values(),
],
[graph],
);
const checked = packages.filter((n) => ["checked", "fixture"].includes(n.coverage)).length;
+ const unresolved = packages.filter((n) => n.versionStatus === "unresolved").length;
+ const exactCount = packages.length - unresolved;
const findingCount = new Set(packages.flatMap((n) => n.advisories.map((a) => a.id))).size;
const top = risks[0];
const topNode = graph?.nodes.find((n) => n.id === top?.nodeId);
@@ -596,7 +600,7 @@ function App() {
<>
- {checked} of {packages.length} package versions checked
+ {checked} of {exactCount} exact package versions checked
{findingCount} known {scan.mode === "demo" ? "demo " : ""}
@@ -611,6 +615,15 @@ function App() {
projects mapped
+ {unresolved > 0 && (
+
+ {packages.length} dependencies were discovered, but {unresolved} could not be
+ matched to exact installed versions.{" "}
+ {exactCount
+ ? "Vulnerability checks were performed only on exact versions."
+ : "Dependencies were discovered, but no exact installed versions were available for vulnerability matching."}
+
+ )}
{(checked < packages.length ||
graph?.warnings.length ||
scan.repositoryMap?.projects.some((p) => p.resolution !== "resolved")) && (
@@ -798,7 +811,9 @@ function App() {
.map((p) => (
{p.name} · {p.resolution} · {p.packageCount ?? "Demo"}{" "}
- packages{p.notes.length ? ` — ${p.notes.join(" ")}` : ""}
+ dependencies · {p.exactCount ?? p.packageCount ?? 0} exact ·{" "}
+ {p.unresolvedCount ?? 0} unresolved · {p.ingestionStatus || p.resolution}
+ {p.notes.length ? ` — ${p.notes.join(" ")}` : ""}
))}
@@ -840,7 +855,7 @@ function App() {
{full ? "All package versions" : "Dependencies worth investigating"}
{full
- ? "Every resolved package version, including unscored and unchecked packages."
+ ? "Exact package versions and unresolved declarations, including unscored and unchecked dependencies."
: "Known findings first, followed by deterministic Ripple Priority."}
@@ -896,17 +911,25 @@ function App() {
{n.name}
- {n.version} · {n.ecosystem}
+ {n.versionStatus === "unresolved"
+ ? (n.declaredSpecifier || "No version declared") +
+ " · exact version unresolved"
+ : n.version}{" "}
+ · {n.ecosystem}
- {r.score}
- {r.level}
+
+ {n.versionStatus === "unresolved" ? "Not scored" : r.score}
+
+ {n.versionStatus !== "unresolved" && (
+ {r.level}
+ )}
- {n.advisories.length}
+ {n.versionStatus === "unresolved" ? "Not checked" : n.advisories.length}
{n.coverage === "checked"
? "Checked"
diff --git a/tests/ingestion.test.ts b/tests/ingestion.test.ts
new file mode 100644
index 0000000..034afe0
--- /dev/null
+++ b/tests/ingestion.test.ts
@@ -0,0 +1,162 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { resolveProject } from "../src/server/resolve-project.js";
+import { discoverProjects, type SourceFile } from "../src/server/discovery.js";
+import { enrichGraph } from "../src/server/connectors.js";
+import { risks } from "../src/server/graph.js";
+const provenance = { source: "test", url: "", retrievedAt: "2026-09-14T00:00:00Z" };
+function ingest(contents: Record) {
+ const map = discoverProjects(
+ Object.keys(contents).map((path) => ({ path, sha: "test" })),
+ "example/test",
+ "main",
+ "test",
+ );
+ const files: SourceFile[] = Object.entries(contents).map(([filename, content]) => ({
+ filename,
+ content,
+ provenance,
+ }));
+ const project = map.projects[0];
+ const graph = resolveProject(project, files);
+ return { map, project, graph, packages: graph.nodes.filter((n) => n.kind === "package") };
+}
+test("501 manifest pairs remain 501 logical projects without a count rejection", () => {
+ const files = Array.from({ length: 501 }, (_, i) =>
+ ["package.json", "package-lock.json"].map((name) => ({
+ path: "apps/" + i + "/" + name,
+ sha: "",
+ })),
+ ).flat();
+ const map = discoverProjects(files, "example/many", "main", "test");
+ assert.equal(map.projects.length, 501);
+ assert.equal(map.fileCount, 1002);
+});
+test("Python runtime, optional and build declarations survive with original constraints", async () => {
+ const { project, graph, packages } = ingest({
+ "pyproject.toml":
+ '[project]\nname="ocr"\ndependencies=["torch>=2.5","pillow>=11","requests==2.31.0"]\n[project.optional-dependencies]\ndev=["pytest>=8"]\n[build-system]\nrequires=["setuptools>=68"]',
+ });
+ assert.equal(packages.length, 5);
+ assert.equal(project.exactCount, 1);
+ assert.equal(project.unresolvedCount, 4);
+ assert.equal(project.ingestionStatus, "PARTIAL");
+ assert.equal(packages.find((n) => n.name === "torch")?.declaredSpecifier, ">=2.5");
+ const queried: string[] = [];
+ await enrichGraph(
+ graph,
+ {
+ get: async (_source: string, _url: string, options: any) => {
+ queried.push(options.body.package.purl);
+ return { data: {} as any, provenance };
+ },
+ },
+ () => {},
+ );
+ assert.deepEqual(queried, ["pkg:pypi/requests@2.31.0"]);
+ assert(
+ packages
+ .filter((n) => n.versionStatus === "unresolved")
+ .every((n) => n.coverage === "partial" && n.version === "" && !n.advisories.length),
+ );
+ assert(
+ risks(graph)
+ .filter((r) => packages.find((n) => n.id === r.nodeId)?.versionStatus === "unresolved")
+ .every((r) => r.score === 0),
+ );
+});
+test("declared-only repository produces nodes and makes no source requests", async () => {
+ const { graph, project, packages } = ingest({
+ "pyproject.toml": '[project]\ndependencies=["requests>=2.31","numpy"]',
+ });
+ assert.equal(project.ingestionStatus, "DECLARED_ONLY");
+ assert.equal(packages.length, 2);
+ let calls = 0;
+ await enrichGraph(
+ graph,
+ {
+ get: async () => {
+ calls++;
+ throw new Error("Unresolved packages must not be sent to sources");
+ },
+ },
+ () => {},
+ );
+ assert.equal(calls, 0);
+ assert(packages.every((n) => n.coverage === "partial"));
+});
+test("npm v1 lock creates exact direct and indirect nodes", () => {
+ const { packages, graph } = ingest({
+ "package-lock.json": JSON.stringify({
+ lockfileVersion: 1,
+ name: "app",
+ version: "1.0.0",
+ dependencies: {
+ a: {
+ version: "1.0.0",
+ requires: { b: "^2.0.0" },
+ dependencies: { b: { version: "2.0.0" } },
+ },
+ },
+ }),
+ });
+ assert.equal(packages.length, 2);
+ assert.equal(packages.find((n) => n.name === "b")?.depth, 2);
+ assert.equal(graph.edges.length, 2);
+});
+test("lockfile without root declarations retains installed packages as listed", () => {
+ const { packages, project } = ingest({
+ "package-lock.json": JSON.stringify({
+ lockfileVersion: 3,
+ packages: { "node_modules/a": { version: "1.2.3" } },
+ }),
+ });
+ assert.equal(packages.length, 1);
+ assert.equal(project.exactCount, 1);
+ assert.equal(packages[0].depth, null);
+});
+test("malformed lock does not discard valid npm declarations", () => {
+ const { packages, project } = ingest({
+ "package.json": JSON.stringify({ dependencies: { a: "^1.0.0", b: "2.0.0" } }),
+ "package-lock.json": "invalid",
+ });
+ assert.equal(packages.length, 2);
+ assert.equal(project.exactCount, 1);
+ assert.equal(project.unresolvedCount, 1);
+});
+test("Python companion requirements remain one project and retain ranges", () => {
+ const { map, packages } = ingest({
+ "pyproject.toml": '[project]\nname="app"\n',
+ "requirements.txt": "requests>=2.31\nnumpy==1.26.4",
+ "requirements-dev.txt": "pytest>=8",
+ });
+ assert.equal(map.projects.length, 1);
+ assert.equal(packages.length, 3);
+ assert.equal(packages.find((n) => n.name === "numpy")?.version, "1.26.4");
+});
+test("Maven managed declarations survive missing dependency versions", () => {
+ const { packages, project } = ingest({
+ "pom.xml":
+ "example app 1.0.0 org.example lib ",
+ });
+ assert.equal(packages.length, 1);
+ assert.equal(project.unresolvedCount, 1);
+ assert.equal(packages[0].name, "org.example:lib");
+});
+test("invalid manifest does not hide a valid lock and missing lock entries are not duplicated", () => {
+ const lock = JSON.stringify({
+ lockfileVersion: 3,
+ packages: {
+ "": { dependencies: { a: "1.2.3", missing: "^1.0.0" } },
+ "node_modules/a": { version: "1.2.3" },
+ },
+ });
+ const broken = ingest({ "package.json": "invalid", "package-lock.json": lock });
+ assert.equal(broken.project.exactCount, 1);
+ assert.equal(broken.project.unresolvedCount, 1);
+ const valid = ingest({
+ "package.json": JSON.stringify({ dependencies: { a: "1.2.3", missing: "^1.0.0" } }),
+ "package-lock.json": lock,
+ });
+ assert.equal(valid.packages.length, 2);
+});
From 98a00e9c8134f73aff34543a2c853d380f07ad72 Mon Sep 17 00:00:00 2001
From: Jagpreet Singh
Date: Mon, 14 Sep 2026 16:29:24 +0530
Subject: [PATCH 5/5] Commiting start work
---
README.md | 7 +-
docs/verification/ingestion-browser.json | 15 +
docs/verification/ingestion-repositories.json | 4000 +++++++++++++++++
docs/verification/live-repositories.json | 69 +-
scripts/verify-ingestion.ts | 147 +
src/server/jobs.ts | 3 +-
src/server/parsers.ts | 6 +-
src/server/resolve-project.ts | 9 +-
src/web/main.tsx | 10 +-
tests/browser/workspace.spec.ts | 19 +
tests/ingestion.test.ts | 55 +
11 files changed, 4331 insertions(+), 9 deletions(-)
create mode 100644 docs/verification/ingestion-browser.json
create mode 100644 docs/verification/ingestion-repositories.json
create mode 100644 scripts/verify-ingestion.ts
diff --git a/README.md b/README.md
index ba83b6a..16c4dd7 100644
--- a/README.md
+++ b/README.md
@@ -30,13 +30,13 @@ For a recording using real source-backed findings, publish the prepared [control
| npm package-lock/shrinkwrap v2/v3 | Installed paths, nearest dependency resolution, workspace links, direct/indirect depth, and runtime/development scope. Independent installations remain distinct. |
| npm older lockfiles | Supported by the existing parser; less workspace context than v2/v3. |
| pnpm locks | Importer dependencies and recorded snapshot relationships; unsupported local workspace links remain explicit warnings. |
-| package.json without usable lock / Yarn | Exact manifest pins only; ranges and unavailable indirect relationships are reported as partial. Yarn lock resolution is not implemented. |
-| Python requirements / Pipfile.lock | Exact pinned packages; flat files do not establish indirect ancestry. Ranges, includes, and commands are not executed. |
+| package.json without usable lock / Yarn | Exact manifest pins are checked; ranges and other declarations remain visible as unresolved. Yarn lock resolution is not implemented. |
+| Python requirements / Pipfile.lock | Exact pins are checked; unpinned declarations are retained with their constraints. Flat files do not establish indirect ancestry. Includes and commands are not executed. |
| uv / Poetry locks | Recorded packages and reconstructable edges; environment selection and application membership may remain unknown. Root links labeled “listed” do not establish direct depth. |
| Maven dependency tree | Recorded resolved dependency relationships. |
| Maven pom.xml | Literal versions only; parent properties, BOM-managed versions, profiles, and indirect dependencies may be unresolved. Maven is never executed. |
-Related files are grouped by path and ecosystem. Repository discovery has no manifest-count cutoff. Truncated GitHub trees use bounded directory traversal and disclose remaining gaps. File/transport limits, unsupported formats, and individual parser failures produce project-level coverage notes; they do not turn unchecked packages into safe ones.
+Related files are grouped by path and ecosystem. Repository Map reads declarations and reports detected, exact and unresolved counts before analysis. Project coverage is EXACT, PARTIAL, DECLARED_ONLY or UNSUPPORTED. Unresolved nodes retain an unversioned identity, their declared specifier and partial coverage; they are not vulnerability-checked or priority-scored. Repository discovery has no manifest-count cutoff. Truncated GitHub trees use bounded directory traversal and disclose remaining gaps. File/transport limits, unsupported formats, and individual parser failures produce project-level coverage notes; they do not turn unchecked packages into safe ones.
OSV provides package/version findings; CVSS vectors are scored deterministically. Registry sources can supplement findings; NVD enriches already-mapped CVEs only. CISA KEV adds positive known-exploitation evidence by exact CVE. Missing signals remain unknown. Fixed versions come only from matching affected-package evidence. No exploitation probability is invented.
@@ -55,6 +55,7 @@ npm run test:e2e
node --import tsx scripts/verify-real-repos.ts
node --import tsx scripts/verify-controlled-demo.ts
node --import tsx scripts/verify-live-browser.ts
+VERIFY_BROWSER=1 node --import tsx scripts/verify-ingestion.ts
```
Browser tests require Playwright Chromium (`npx playwright install chromium`), or set `PW_CHROME=1` to use installed Chrome. Tests use a separate local server and database on port 3100.
diff --git a/docs/verification/ingestion-browser.json b/docs/verification/ingestion-browser.json
new file mode 100644
index 0000000..91dd709
--- /dev/null
+++ b/docs/verification/ingestion-browser.json
@@ -0,0 +1,15 @@
+{
+ "verifiedAt": "2026-09-14T05:53:17.425Z",
+ "checks": [
+ {
+ "repository": "google/osv-scanner",
+ "errors": [],
+ "passed": true
+ },
+ {
+ "repository": "jagpreetsingh02/SWASTRA-OCR",
+ "errors": [],
+ "passed": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/verification/ingestion-repositories.json b/docs/verification/ingestion-repositories.json
new file mode 100644
index 0000000..35f1485
--- /dev/null
+++ b/docs/verification/ingestion-repositories.json
@@ -0,0 +1,4000 @@
+{
+ "verifiedAt": "2026-09-14T05:53:11.112Z",
+ "results": [
+ {
+ "repository": "google/osv-scanner",
+ "scanId": "6be7fac2-b3ee-4496-9369-272802d2c7a1",
+ "commit": "e840a6e8adb14b7777c78e26cfbf6e2abc1d1fc6",
+ "ref": "main",
+ "status": "completed",
+ "projects": 24,
+ "files": 30,
+ "allProjectPreview": {
+ "dependencies": 143,
+ "exact": 143,
+ "unresolved": 0
+ },
+ "selected": [
+ "cmd/osv-scanner/fix/testdata/in-place-npm",
+ "cmd/osv-scanner/fix/testdata/override-maven",
+ "cmd/osv-scanner/fix/testdata/relax-npm",
+ "cmd/osv-scanner/scan/image/testdata/java-fixture/app",
+ "cmd/osv-scanner/scan/image/testdata/python-fixture",
+ "cmd/osv-scanner/scan/source/testdata/exp-plugins-pkgdeprecate/deprecated-npm",
+ "cmd/osv-scanner/scan/source/testdata/locks-empty",
+ "cmd/osv-scanner/scan/source/testdata/locks-gitignore",
+ "cmd/osv-scanner/scan/source/testdata/locks-gitignore/ignored",
+ "cmd/osv-scanner/scan/source/testdata/locks-gitignore/subdir",
+ "cmd/osv-scanner/scan/source/testdata/locks-licenses",
+ "cmd/osv-scanner/scan/source/testdata/locks-many",
+ "cmd/osv-scanner/scan/source/testdata/locks-many-with-insecure",
+ "cmd/osv-scanner/scan/source/testdata/locks-many-with-invalid",
+ "cmd/osv-scanner/scan/source/testdata/locks-one-with-nested",
+ "cmd/osv-scanner/scan/source/testdata/locks-requirements",
+ "cmd/osv-scanner/scan/source/testdata/locks-scalibr",
+ "cmd/osv-scanner/scan/source/testdata/locks-test-ignore",
+ "cmd/osv-scanner/scan/source/testdata/maven-transitive",
+ "cmd/osv-scanner/scan/testdata/locks-many",
+ "cmd/osv-scanner/scan/testdata/locks-one-with-nested",
+ "cmd/osv-scanner/testdata/locks-many",
+ "cmd/osv-scanner/testdata/locks-one-with-nested",
+ "cmd/osv-scanner/update/testdata"
+ ],
+ "dependencies": 143,
+ "exactVersions": 137,
+ "unresolved": 0,
+ "graphNodes": 167,
+ "checked": 137,
+ "coverage": [
+ {
+ "path": "cmd/osv-scanner/fix/testdata/in-place-npm",
+ "status": "PARTIAL",
+ "detected": 106,
+ "exact": 106,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/fix/testdata/override-maven",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/fix/testdata/relax-npm",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/image/testdata/java-fixture/app",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/image/testdata/python-fixture",
+ "status": "PARTIAL",
+ "detected": 13,
+ "exact": 13,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/exp-plugins-pkgdeprecate/deprecated-npm",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-empty",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-gitignore",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-gitignore/ignored",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-gitignore/subdir",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-licenses",
+ "status": "EXACT",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-many",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-many-with-insecure",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-many-with-invalid",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-one-with-nested",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-requirements",
+ "status": "PARTIAL",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-scalibr",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/locks-test-ignore",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/source/testdata/maven-transitive",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/testdata/locks-many",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/scan/testdata/locks-one-with-nested",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/testdata/locks-many",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/testdata/locks-one-with-nested",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "cmd/osv-scanner/update/testdata",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ }
+ ],
+ "warnings": [
+ "in-place-npm: Lockfile version for readable-stream does not satisfy its declared range ~2.0.0; regenerate the lockfile.",
+ "override-maven: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.",
+ "osv-fix: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "app: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.",
+ "python-fixture: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "locks-empty: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "locks-empty: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.",
+ "locks-gitignore: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "locks-gitignore: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.",
+ "ignored: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "ignored: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.",
+ "subdir: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "subdir: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.",
+ "locks-many-with-invalid: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "locks-many-with-invalid: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.",
+ "locks-one-with-nested: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "locks-one-with-nested: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.",
+ "locks-requirements: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "locks-scalibr: Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies.",
+ "maven-transitive: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.",
+ "testdata: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.",
+ "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound."
+ ]
+ },
+ {
+ "repository": "jagpreetsingh02/SWASTRA-OCR",
+ "scanId": "c79a673a-78b8-4935-a752-2ccf360dc19d",
+ "commit": "840ded5663fe721607741e96270c81920704bd15",
+ "ref": "main",
+ "status": "completed",
+ "projects": 1,
+ "files": 1,
+ "allProjectPreview": {
+ "dependencies": 14,
+ "exact": 0,
+ "unresolved": 14
+ },
+ "selected": [
+ "."
+ ],
+ "dependencies": 14,
+ "exactVersions": 0,
+ "unresolved": 14,
+ "graphNodes": 15,
+ "checked": 0,
+ "coverage": [
+ {
+ "path": ".",
+ "status": "DECLARED_ONLY",
+ "detected": 14,
+ "exact": 0,
+ "unresolved": 14
+ }
+ ],
+ "warnings": [
+ "SWASTRA-OCR: No exact resolved Python snapshot is available. Unpinned dependencies are not checked; use uv.lock, poetry.lock, Pipfile.lock or pinned requirements.",
+ "SWASTRA-OCR: 14 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching."
+ ]
+ },
+ {
+ "repository": "expressjs/express",
+ "scanId": "837c0c29-4ddf-4a4e-8bd6-090a79c1bfa6",
+ "commit": "3ce6d0eb86e9d93529ff3191c6bb5db8ce6e72c8",
+ "ref": "master",
+ "status": "completed",
+ "projects": 1,
+ "files": 1,
+ "allProjectPreview": {
+ "dependencies": 44,
+ "exact": 8,
+ "unresolved": 36
+ },
+ "selected": [
+ "."
+ ],
+ "dependencies": 44,
+ "exactVersions": 8,
+ "unresolved": 36,
+ "graphNodes": 45,
+ "checked": 8,
+ "coverage": [
+ {
+ "path": ".",
+ "status": "PARTIAL",
+ "detected": 44,
+ "exact": 8,
+ "unresolved": 36
+ }
+ ],
+ "warnings": [
+ "express: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "express: accepts: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: body-parser: ^2.3.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: content-disposition: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: content-type: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: cookie: ^0.7.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: cookie-signature: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: debug: ^4.4.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: depd: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: encodeurl: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: escape-html: ^1.0.3 is not an exact installed version; omitted from vulnerability checks.",
+ "express: etag: ^1.8.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: finalhandler: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: fresh: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: http-errors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: merge-descriptors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: mime-types: ^3.0.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: on-finished: ^2.4.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: once: ^1.4.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: parseurl: ^1.3.3 is not an exact installed version; omitted from vulnerability checks.",
+ "express: proxy-addr: ^2.0.7 is not an exact installed version; omitted from vulnerability checks.",
+ "express: qs: ^6.15.2 is not an exact installed version; omitted from vulnerability checks.",
+ "express: range-parser: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: router: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: send: ^1.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: serve-static: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: statuses: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: type-is: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: vary: ^1.1.2 is not an exact installed version; omitted from vulnerability checks.",
+ "express: connect-redis: ^8.0.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: ejs: ^3.1.10 is not an exact installed version; omitted from vulnerability checks.",
+ "express: express-session: ^1.18.1 is not an exact installed version; omitted from vulnerability checks.",
+ "express: marked: ^15.0.3 is not an exact installed version; omitted from vulnerability checks.",
+ "express: mocha: ^11.7.5 is not an exact installed version; omitted from vulnerability checks.",
+ "express: nyc: ^17.1.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: supertest: ^6.3.0 is not an exact installed version; omitted from vulnerability checks.",
+ "express: vhost: ~3.0.2 is not an exact installed version; omitted from vulnerability checks.",
+ "express: 36 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching."
+ ]
+ },
+ {
+ "repository": "jagpreetsingh02/MIT",
+ "scanId": "51974380-385c-4bfe-9d84-2f2af8ec95d2",
+ "commit": "c75031ca7546706d042c585852d18b29c8f9015e",
+ "ref": "main",
+ "status": "completed",
+ "projects": 1,
+ "files": 2,
+ "allProjectPreview": {
+ "dependencies": 427,
+ "exact": 412,
+ "unresolved": 15
+ },
+ "selected": [
+ "."
+ ],
+ "dependencies": 427,
+ "exactVersions": 409,
+ "unresolved": 15,
+ "graphNodes": 428,
+ "checked": 409,
+ "coverage": [
+ {
+ "path": ".",
+ "status": "PARTIAL",
+ "detected": 427,
+ "exact": 412,
+ "unresolved": 15
+ }
+ ],
+ "warnings": [
+ "rippleguard: Could not determine the installed dependency @rolldown/plugin-babel used by @vitejs/plugin-react.",
+ "rippleguard: Could not determine the installed dependency babel-plugin-react-compiler used by @vitejs/plugin-react.",
+ "rippleguard: Could not determine the installed dependency oxc-transform-react used by @vitejs/plugin-react.",
+ "rippleguard: Could not determine the installed dependency @vitejs/devtools used by vite.",
+ "rippleguard: Could not determine the installed dependency less used by vite.",
+ "rippleguard: Could not determine the installed dependency sass used by vite.",
+ "rippleguard: Could not determine the installed dependency sass-embedded used by vite.",
+ "rippleguard: Could not determine the installed dependency stylus used by vite.",
+ "rippleguard: Could not determine the installed dependency sugarss used by vite.",
+ "rippleguard: Could not determine the installed dependency terser used by vite.",
+ "rippleguard: Could not determine the installed dependency @emnapi/core used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @emnapi/runtime used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @emnapi/wasi-threads used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @napi-rs/wasm-runtime used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: Could not determine the installed dependency @tybys/wasm-util used by @tailwindcss/oxide-wasm32-wasi.",
+ "rippleguard: 15 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching."
+ ]
+ },
+ {
+ "repository": "npm/cli",
+ "scanId": "661ba1e8-e137-48fb-9420-2c34bf1f5031",
+ "commit": "c9876d7ea7150b0702e4151210b9fa1a8dbc7fbf",
+ "ref": "latest",
+ "status": "completed",
+ "projects": 478,
+ "files": 575,
+ "allProjectPreview": {
+ "dependencies": 23330,
+ "exact": 22485,
+ "unresolved": 845
+ },
+ "selected": [
+ "mock-globals"
+ ],
+ "dependencies": 964,
+ "exactVersions": 797,
+ "unresolved": 7,
+ "graphNodes": 965,
+ "checked": 797,
+ "coverage": [
+ {
+ "path": ".",
+ "status": "PARTIAL",
+ "detected": 1102,
+ "exact": 1085,
+ "unresolved": 17
+ },
+ {
+ "path": "docs",
+ "status": "PARTIAL",
+ "detected": 1099,
+ "exact": 1089,
+ "unresolved": 10
+ },
+ {
+ "path": "mock-globals",
+ "status": "PARTIAL",
+ "detected": 964,
+ "exact": 957,
+ "unresolved": 7
+ },
+ {
+ "path": "mock-registry",
+ "status": "PARTIAL",
+ "detected": 973,
+ "exact": 966,
+ "unresolved": 7
+ },
+ {
+ "path": "smoke-tests",
+ "status": "PARTIAL",
+ "detected": 980,
+ "exact": 973,
+ "unresolved": 7
+ },
+ {
+ "path": "smoke-tests/test/fixtures/large-install",
+ "status": "PARTIAL",
+ "detected": 1324,
+ "exact": 1306,
+ "unresolved": 18
+ },
+ {
+ "path": "workspaces/arborist",
+ "status": "PARTIAL",
+ "detected": 995,
+ "exact": 983,
+ "unresolved": 12
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/accept-dependencies",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/accept-dependencies/dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/accept-dependencies/with-newer",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/ancient-lockfile-invalid",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-all-severities",
+ "status": "PARTIAL",
+ "detected": 474,
+ "exact": 473,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-dep-vuln-with-own-advisory",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-fix-old-tap",
+ "status": "EXACT",
+ "detected": 571,
+ "exact": 571,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-linked-package",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-missing-packument",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-mkdirp",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-mkdirp/mkdirp-unfixable",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-nyc-mkdirp",
+ "status": "EXACT",
+ "detected": 329,
+ "exact": 329,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-omit",
+ "status": "EXACT",
+ "detected": 751,
+ "exact": 751,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/audit-one-vuln",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bad",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/a-latest",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/a-old",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/bundle-metadep-duplication",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/x",
+ "status": "PARTIAL",
+ "detected": 4,
+ "exact": 3,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/y",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/z",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/carbonium",
+ "status": "EXACT",
+ "detected": 126,
+ "exact": 126,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/cli-750",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/cli-750-fresh",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/cli-750-fresh/app",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/cli-750-fresh/lib",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/cli-750/app",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/cli-750/lib",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/conflict-bundle-file-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/conflict-bundle-file-dep/fixtures-has-package-exports",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/conflict-package-lock",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/conflicted-peer-optional-from-dev-dep/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/conflicted-peer-optional-from-dev-dep/peer/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/conflicted-peer-optional-from-dev-dep/peer/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dedupe-actual",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dedupe-lockfile",
+ "status": "EXACT",
+ "detected": 3,
+ "exact": 3,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dedupe-tests",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dedupe-tests-2",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/deepmixedloop",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dep-installed-without-bin-link",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dep-missing-resolved",
+ "status": "EXACT",
+ "detected": 233,
+ "exact": 233,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/deprecated-dep",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/dev-deps",
+ "status": "EXACT",
+ "detected": 5,
+ "exact": 5,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/devloop",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/changed",
+ "status": "EXACT",
+ "detected": 7,
+ "exact": 7,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/ok",
+ "status": "EXACT",
+ "detected": 7,
+ "exact": 7,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/removed",
+ "status": "EXACT",
+ "detected": 6,
+ "exact": 6,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed/c",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/empty-with-shrinkwrap",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/empty-with-shrinkwrap-no-pj",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/engine-omit-test",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/engine-specification",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/eslintme",
+ "status": "EXACT",
+ "detected": 156,
+ "exact": 156,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-dep/root",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link-cached-dummy-dep/a/t/u/v/w/x/y/z",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link-cached-dummy-dep/root",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 4,
+ "exact": 0,
+ "unresolved": 4
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link/a",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link/i",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link/i/j",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link/m",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/external-link/root",
+ "status": "PARTIAL",
+ "detected": 12,
+ "exact": 7,
+ "unresolved": 5
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fail-allinstall",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fail-install",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fail-postinstall",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fail-preinstall",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/flow-outdated",
+ "status": "EXACT",
+ "detected": 5,
+ "exact": 5,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/y",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/y/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/y/z",
+ "status": "DECLARED_ONLY",
+ "detected": 4,
+ "exact": 0,
+ "unresolved": 4
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/hidden-lockfile",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/install-types",
+ "status": "PARTIAL",
+ "detected": 13,
+ "exact": 5,
+ "unresolved": 8
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/install-types-sw-only",
+ "status": "PARTIAL",
+ "detected": 32,
+ "exact": 15,
+ "unresolved": 17
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/install-types/abbrev-link-target",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-cycle",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-cycle/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-cycle/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-empty",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-empty/target",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-has-dep-with-optional-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-has-dep-with-optional-dep/a",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-lifecycle-scripts",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-lifecycle-scripts/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-nested",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-nested/once",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep-nested/root",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dep/target",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dev-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-dev-dep/target",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-meta-deps",
+ "status": "PARTIAL",
+ "detected": 3,
+ "exact": 2,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/link-meta-deps-empty",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/links-all-over",
+ "status": "DECLARED_ONLY",
+ "detected": 4,
+ "exact": 0,
+ "unresolved": 4
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/links-all-over/real",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/malformed-json",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/minimist-git-dep",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/minimist-git-metadep",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/mixedloop",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/mixedmidway",
+ "status": "DECLARED_ONLY",
+ "detected": 9,
+ "exact": 0,
+ "unresolved": 9
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/mkdirp-pinned",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/old-lock-with-link",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/old-lock-with-link/link-dep",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/old-package-lock",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/old-package-lock-with-bins",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/once-outdated",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-allinstall-fail",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-enotarget",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-install-fail",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-missing",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-postinstall-fail",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-preinstall-fail",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 1,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-dep-tgz-missing",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-engine-omit-test",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-engine-specification",
+ "status": "PARTIAL",
+ "detected": 3,
+ "exact": 2,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-allinstall-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-enotarget",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-install-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-missing",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-postinstall-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-preinstall-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-metadep-tgz-missing",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-ok",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optional-platform-specification",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optionalloop",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/optofdev",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/outdated-no-lockfile",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/package-json-shorthands",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/a1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/a2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/b1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/b2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/1",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/2",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/3",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/4",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/j/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/j/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/x",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/y",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-cycle",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-cycle-nested",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-cycle-nested-with-sw",
+ "status": "EXACT",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-dep-cycle-with-sw",
+ "status": "EXACT",
+ "detected": 3,
+ "exact": 3,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-engine-omit-test",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/x",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/y/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/y/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/z",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b/x",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b/y/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b/y/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/x",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/y/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/y/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/z",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/x",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/y",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/z/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/z/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/x",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/y",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/z/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/z/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/w",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/x",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/y",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/z/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/z/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/peer-optional-installs",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/platform-specification",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/pnpm",
+ "status": "EXACT",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-allinstall-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-enotarget",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-install-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-missing",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-postinstall-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-preinstall-fail",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prod-dep-tgz-missing",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-actual",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-actual-omit-dev",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-dev-bins",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-dev-dep-duplicate",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-lockfile",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-lockfile-omit-dev",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/prune-lockfile-optional-peer",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/rebuild-foreground-scripts",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/root",
+ "status": "DECLARED_ONLY",
+ "detected": 4,
+ "exact": 0,
+ "unresolved": 4
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/root-bundler",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/root-bundler/child",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/sax",
+ "status": "PARTIAL",
+ "detected": 213,
+ "exact": 9,
+ "unresolved": 204
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/selflink",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/semver-installed-with-old-package-lock",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/b1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/b2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/c",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/simple",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/symlinked-node-modules/bar",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/symlinked-node-modules/example",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/symlinked-node-modules/linked-node-modules/foo",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tab-indented-package-json",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tap-and-flow",
+ "status": "EXACT",
+ "detected": 471,
+ "exact": 471,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tap-old-lockfile",
+ "status": "EXACT",
+ "detected": 413,
+ "exact": 413,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tap-react15-collision",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tap-react15-collision-legacy-sw",
+ "status": "EXACT",
+ "detected": 385,
+ "exact": 385,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tap-with-yarn-lock",
+ "status": "DECLARED_ONLY",
+ "detected": 42,
+ "exact": 0,
+ "unresolved": 42
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tarball-dependencies",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tarball-dependencies/testing-file-transitive-a",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tarball-dependencies/testing-file-transitive-a/c",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/tarball-dependencies/testing-file-transitive-b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/has-peer",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/has-peer-optional",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/meta-peer",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/meta-peer-optional",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/nest-peer-optional",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/omit-peer-optional",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/peer/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/peer/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-package-with-shrinkwrap",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev/a-peer-dep/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev/a-peer-dep/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev/devdep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-root-matches-metadep",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-root-matches-metadep/x",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/test-root-matches-metadep/y",
+ "status": "PARTIAL",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-no-lock",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-no-lock/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-no-lock/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-with-lock",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-with-lock/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-with-lock/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/a/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/a/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/b/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/b/2.0",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/b/2.1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps-2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps-3",
+ "status": "EXACT",
+ "detected": 5,
+ "exact": 5,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps-empty",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps-legacy-bundling",
+ "status": "EXACT",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps-parent",
+ "status": "EXACT",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-bundledeps-sw",
+ "status": "EXACT",
+ "detected": 5,
+ "exact": 5,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-dev-optional-flags",
+ "status": "DECLARED_ONLY",
+ "detected": 4,
+ "exact": 0,
+ "unresolved": 4
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-dev-optional-flags-2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-missing-tgz",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain",
+ "status": "EXACT",
+ "detected": 6,
+ "exact": 6,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/a/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/a/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/a/3",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/b/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/b/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/b/3",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/c/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/c/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/c/3",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/d/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/d/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/d/3",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/e/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/e/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/i/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/i/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ii/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ii/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/j/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/j/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/jj/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/jj/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/k/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/k/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/kk/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/kk/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/l/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/l/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ll/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ll/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/m/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/m/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/mm/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/mm/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override-peer",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override-peer-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/p/1",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/p/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/q/1",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/q/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/r/1",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/r/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/s/1",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/s/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-a/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-a/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-b/1",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-b/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/t/1",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/t/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/3",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/4",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/vv/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/vv/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/3",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/4",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/3",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/4",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/3",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/4",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/2",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/3",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/4",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/multi",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/p/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/p/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/q",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/s/1",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/s/2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/simple",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/x",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/y",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/z",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-deps",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-deps-nested",
+ "status": "EXACT",
+ "detected": 8,
+ "exact": 8,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-deps-overlap",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-peer-deps-unresolvable",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle",
+ "status": "EXACT",
+ "detected": 3,
+ "exact": 3,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle-reified",
+ "status": "EXACT",
+ "detected": 3,
+ "exact": 3,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle/parent",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-rebuild-script-env-flags",
+ "status": "EXACT",
+ "detected": 4,
+ "exact": 4,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/a",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/b/1",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/b/2",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/c",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/root",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/two-bundled-deps",
+ "status": "EXACT",
+ "detected": 421,
+ "exact": 421,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/update-exact-version",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace/packages/b",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace/packages/c",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace2",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace2/x",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace3",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace3/app",
+ "status": "DECLARED_ONLY",
+ "detected": 4,
+ "exact": 0,
+ "unresolved": 4
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace3/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace3/packages/b",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace3/packages/c",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace4",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace4/packages/bar",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspace4/packages/foo",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-add-new-dep",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-add-new-dep/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions-virtual",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions-virtual/packages/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions-virtual/packages/b",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions/packages/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-duplicate",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-duplicate/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-duplicate/packages/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm-virtual",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm-virtual/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-link-bin",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-link-bin/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-link-bin/packages/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-need-update",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-need-update/a",
+ "status": "EXACT",
+ "detected": 3,
+ "exact": 3,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-need-update/b",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-non-simplistic",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-non-simplistic/a",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-not-root",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-not-root/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-not-root/packages/b",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-not-root/packages/c",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-peer-ranges",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-peer-ranges/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-peer-ranges/packages/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking-virtual",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking-virtual/packages/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking-virtual/packages/abbrev",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking/packages/abbrev",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-root-linked",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-root-linked/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-root-linked/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-scoped-pkg",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-scoped-pkg/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-scoped-pkg/packages/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual/packages/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual/packages/b",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual/packages/c",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps/packages/b",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps/packages/c",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-simple",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-simple-virtual",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-simple-virtual/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-simple-virtual/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-simple/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-simple/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link-virtual",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link-virtual/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link/packages/a",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps-virtual",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps-virtual/packages/a",
+ "status": "EXACT",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied-virtual",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied-virtual/packages/a",
+ "status": "EXACT",
+ "detected": 1,
+ "exact": 1,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied-virtual/packages/abbrev",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied/packages/abbrev",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/d",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/packages/a",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/packages/b",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/packages/c",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-overrides",
+ "status": "UNSUPPORTED",
+ "detected": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/workspaces-with-overrides/ws",
+ "status": "PARTIAL",
+ "detected": 2,
+ "exact": 2,
+ "unresolved": 0
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/yargs",
+ "status": "PARTIAL",
+ "detected": 28,
+ "exact": 1,
+ "unresolved": 27
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp-file-dep",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp-file-dep/mkdirp",
+ "status": "DECLARED_ONLY",
+ "detected": 2,
+ "exact": 0,
+ "unresolved": 2
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp-no-resolved",
+ "status": "DECLARED_ONLY",
+ "detected": 1,
+ "exact": 0,
+ "unresolved": 1
+ },
+ {
+ "path": "workspaces/arborist/test/fixtures/yarn-stuff",
+ "status": "DECLARED_ONLY",
+ "detected": 9,
+ "exact": 0,
+ "unresolved": 9
+ },
+ {
+ "path": "workspaces/config",
+ "status": "PARTIAL",
+ "detected": 966,
+ "exact": 959,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmaccess",
+ "status": "PARTIAL",
+ "detected": 965,
+ "exact": 958,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmdiff",
+ "status": "PARTIAL",
+ "detected": 965,
+ "exact": 958,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmexec",
+ "status": "PARTIAL",
+ "detected": 971,
+ "exact": 964,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmfund",
+ "status": "PARTIAL",
+ "detected": 964,
+ "exact": 957,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmorg",
+ "status": "PARTIAL",
+ "detected": 974,
+ "exact": 967,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmpack",
+ "status": "PARTIAL",
+ "detected": 974,
+ "exact": 967,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmpublish",
+ "status": "PARTIAL",
+ "detected": 967,
+ "exact": 960,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmsearch",
+ "status": "PARTIAL",
+ "detected": 973,
+ "exact": 966,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmteam",
+ "status": "PARTIAL",
+ "detected": 974,
+ "exact": 967,
+ "unresolved": 7
+ },
+ {
+ "path": "workspaces/libnpmversion",
+ "status": "PARTIAL",
+ "detected": 966,
+ "exact": 959,
+ "unresolved": 7
+ }
+ ],
+ "warnings": [
+ "@npmcli/mock-globals: Could not determine the installed dependency coveralls used by tap.",
+ "@npmcli/mock-globals: Could not determine the installed dependency flow-remove-types used by tap.",
+ "@npmcli/mock-globals: Could not determine the installed dependency ts-node used by tap.",
+ "@npmcli/mock-globals: Could not determine the installed dependency babel-plugin-macros used by dedent.",
+ "@npmcli/mock-globals: Could not determine the installed dependency bufferutil used by ws.",
+ "@npmcli/mock-globals: Could not determine the installed dependency utf-8-validate used by ws.",
+ "@npmcli/mock-globals: Lockfile version for conventional-commits-filter does not satisfy its declared range ^5.0.0; regenerate the lockfile.",
+ "@npmcli/mock-globals: Could not determine the installed dependency kerberos used by proxy-agent-negotiate.",
+ "@npmcli/mock-globals: 7 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching."
+ ]
+ },
+ {
+ "repository": "pallets/flask",
+ "scanId": "0408f72a-ccc5-462e-a04f-f83f9ac4852c",
+ "commit": "d73fa1cdcbd8b1465c151db8924ba58b1dd14e35",
+ "ref": "main",
+ "status": "completed",
+ "projects": 4,
+ "files": 6,
+ "allProjectPreview": {
+ "dependencies": 114,
+ "exact": 104,
+ "unresolved": 10
+ },
+ "selected": [
+ "."
+ ],
+ "dependencies": 84,
+ "exactVersions": 83,
+ "unresolved": 1,
+ "graphNodes": 85,
+ "checked": 83,
+ "coverage": [
+ {
+ "path": ".",
+ "status": "PARTIAL",
+ "detected": 84,
+ "exact": 83,
+ "unresolved": 1
+ },
+ {
+ "path": "examples/celery",
+ "status": "PARTIAL",
+ "detected": 24,
+ "exact": 21,
+ "unresolved": 3
+ },
+ {
+ "path": "examples/javascript",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ },
+ {
+ "path": "examples/tutorial",
+ "status": "DECLARED_ONLY",
+ "detected": 3,
+ "exact": 0,
+ "unresolved": 3
+ }
+ ],
+ "warnings": [
+ "flask: Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies.",
+ "flask: 1 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching."
+ ]
+ },
+ {
+ "repository": "spring-projects/spring-petclinic",
+ "scanId": "100058f2-d987-444e-a327-c12a6de34a11",
+ "commit": "818c4136ea971c21674525f9053de0d9c7ad8cfe",
+ "ref": "main",
+ "status": "completed",
+ "projects": 1,
+ "files": 1,
+ "allProjectPreview": {
+ "dependencies": 28,
+ "exact": 3,
+ "unresolved": 25
+ },
+ "selected": [
+ "."
+ ],
+ "dependencies": 28,
+ "exactVersions": 3,
+ "unresolved": 25,
+ "graphNodes": 29,
+ "checked": 3,
+ "coverage": [
+ {
+ "path": ".",
+ "status": "PARTIAL",
+ "detected": 28,
+ "exact": 3,
+ "unresolved": 25
+ }
+ ],
+ "warnings": [
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: javax.cache:cache-api. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: jakarta.xml.bind:jakarta.xml.bind-api. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: com.h2database:h2. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: com.github.ben-manes.caffeine:caffeine. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: com.mysql:mysql-connector-j. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.postgresql:postgresql. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-devtools. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-testcontainers. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-docker-compose. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache-test. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-junit-jupiter. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-mysql. Export dependency-tree.json for full resolution.",
+ "spring-petclinic: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.",
+ "spring-petclinic: 25 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching."
+ ]
+ },
+ {
+ "repository": "GoogleCloudPlatform/microservices-demo",
+ "scanId": "305a680f-64eb-431b-a03c-6d026be14b1e",
+ "commit": "b9a978db9e01f4ad3dca9494a22cb9edc17548fe",
+ "ref": "main",
+ "status": "completed",
+ "projects": 6,
+ "files": 8,
+ "allProjectPreview": {
+ "dependencies": 725,
+ "exact": 723,
+ "unresolved": 2
+ },
+ "selected": [
+ "src/currencyservice",
+ "src/emailservice",
+ "src/loadgenerator",
+ "src/paymentservice",
+ "src/recommendationservice",
+ "src/shoppingassistantservice"
+ ],
+ "dependencies": 725,
+ "exactVersions": 394,
+ "unresolved": 2,
+ "graphNodes": 731,
+ "checked": 394,
+ "coverage": [
+ {
+ "path": "src/currencyservice",
+ "status": "PARTIAL",
+ "detected": 285,
+ "exact": 284,
+ "unresolved": 1
+ },
+ {
+ "path": "src/emailservice",
+ "status": "PARTIAL",
+ "detected": 35,
+ "exact": 35,
+ "unresolved": 0
+ },
+ {
+ "path": "src/loadgenerator",
+ "status": "PARTIAL",
+ "detected": 39,
+ "exact": 39,
+ "unresolved": 0
+ },
+ {
+ "path": "src/paymentservice",
+ "status": "PARTIAL",
+ "detected": 256,
+ "exact": 255,
+ "unresolved": 1
+ },
+ {
+ "path": "src/recommendationservice",
+ "status": "PARTIAL",
+ "detected": 31,
+ "exact": 31,
+ "unresolved": 0
+ },
+ {
+ "path": "src/shoppingassistantservice",
+ "status": "PARTIAL",
+ "detected": 79,
+ "exact": 79,
+ "unresolved": 0
+ }
+ ],
+ "warnings": [
+ "grpc-currency-service: Could not determine the installed dependency encoding used by node-fetch.",
+ "grpc-currency-service: 1 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching.",
+ "emailservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "loadgenerator: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "paymentservice: Could not determine the installed dependency encoding used by node-fetch.",
+ "paymentservice: 1 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching.",
+ "recommendationservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "shoppingassistantservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.",
+ "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound."
+ ]
+ },
+ {
+ "repository": "octocat/Hello-World",
+ "scanId": "96295882-c914-4f2a-87e5-e2b626abb5a7",
+ "commit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
+ "ref": "master",
+ "status": "mapped",
+ "projects": 0,
+ "files": 0,
+ "allProjectPreview": {
+ "dependencies": 0,
+ "exact": 0,
+ "unresolved": 0
+ },
+ "selected": [],
+ "dependencies": 0,
+ "exactVersions": 0,
+ "unresolved": 0,
+ "graphNodes": 0,
+ "checked": 0,
+ "coverage": [],
+ "warnings": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/verification/live-repositories.json b/docs/verification/live-repositories.json
index 1cad86f..ecb8425 100644
--- a/docs/verification/live-repositories.json
+++ b/docs/verification/live-repositories.json
@@ -1,5 +1,5 @@
{
- "testedAt": "2026-09-13T15:57:28.559Z",
+ "testedAt": "2026-09-13T21:38:00.584Z",
"results": [
{
"repository": "expressjs/express",
@@ -316,6 +316,73 @@
"advisories": [],
"warnings": [],
"durationSeconds": 2
+ },
+ {
+ "repository": "google/osv-scanner",
+ "ref": "main",
+ "commit": "1f87b5cb9781a03a9b8b50dfc1eac1b2056c79d5",
+ "scanId": "367ba078-45f5-4504-a848-aeb8ebceb9b8",
+ "projects": 24,
+ "files": 30,
+ "selected": [
+ {
+ "name": "in-place-npm",
+ "path": "cmd/osv-scanner/fix/testdata/in-place-npm",
+ "ecosystem": "npm"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 106,
+ "checked": 106,
+ "advisories": [
+ "GHSA-c6rq-rjc2-86v2",
+ "GHSA-g74r-ffvr-5q9f",
+ "GHSA-p8p7-x288-28g6",
+ "GHSA-c2qf-rxjj-qqgw",
+ "GHSA-43f8-2h32-f4cj",
+ "GHSA-fjxv-7rqg-78g4",
+ "GHSA-hmw2-7cc7-3qxx",
+ "GHSA-4mjr-xmp4-gh2g",
+ "GHSA-6rw7-vpxm-498p",
+ "GHSA-72xf-g2v4-qvf3",
+ "GHSA-w5hq-g745-h8pq",
+ "GHSA-2g4f-4pwh-qvx6",
+ "GHSA-23c5-xmqv-rm74",
+ "GHSA-3ppc-4f35-3m26",
+ "GHSA-7r86-cg39-jmmj",
+ "GHSA-3jxr-9vmj-r5cp",
+ "GHSA-f886-m6hf-6m8v",
+ "GHSA-mh99-v99m-4gvg",
+ "GHSA-rgw5-rvv9-x895",
+ "GHSA-v6h2-p8h4-qcjw"
+ ],
+ "warnings": [
+ "in-place-npm: Lockfile version for readable-stream does not satisfy its declared range ~2.0.0; regenerate the lockfile."
+ ],
+ "durationSeconds": 14
+ },
+ {
+ "repository": "jagpreetsingh02/SWASTRA-OCR",
+ "ref": "main",
+ "commit": "9665f4579de5afe728ced1aaa92fdcf5f0c6428c",
+ "scanId": "8c7a4336-5b04-481e-9064-370b862fa29c",
+ "projects": 1,
+ "files": 1,
+ "selected": [
+ {
+ "name": "SWASTRA-OCR",
+ "path": ".",
+ "ecosystem": "pypi"
+ }
+ ],
+ "status": "completed",
+ "packageVersions": 0,
+ "checked": 0,
+ "advisories": [],
+ "warnings": [
+ "SWASTRA-OCR: No exact resolved Python snapshot is available. Unpinned dependencies are not checked; use uv.lock, poetry.lock, Pipfile.lock or pinned requirements."
+ ],
+ "durationSeconds": 2
}
]
}
\ No newline at end of file
diff --git a/scripts/verify-ingestion.ts b/scripts/verify-ingestion.ts
new file mode 100644
index 0000000..bb5dbfb
--- /dev/null
+++ b/scripts/verify-ingestion.ts
@@ -0,0 +1,147 @@
+import { writeFile, mkdir } from "node:fs/promises";
+import { chromium, expect } from "@playwright/test";
+const base = "http://127.0.0.1:3000";
+const repos = process.argv.slice(2).length
+ ? process.argv.slice(2)
+ : [
+ "google/osv-scanner",
+ "jagpreetsingh02/SWASTRA-OCR",
+ "expressjs/express",
+ "jagpreetsingh02/MIT",
+ "npm/cli",
+ "pallets/flask",
+ "spring-projects/spring-petclinic",
+ "GoogleCloudPlatform/microservices-demo",
+ "octocat/Hello-World",
+ ];
+async function api(path: string, body?: unknown) {
+ const r = await fetch(base + "/api/v1" + path, {
+ method: body ? "POST" : "GET",
+ headers: { "content-type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(j.error);
+ return j;
+}
+async function wait(id: string, status: string) {
+ for (let i = 0; i < 900; i++) {
+ const s = await api("/scans/" + id);
+ if (s.status === "failed") throw new Error(s.error);
+ if (s.status === status) return s;
+ await new Promise((r) => setTimeout(r, 1000));
+ }
+ throw new Error("Scan timeout");
+}
+const results: any[] = [];
+for (const repository of repos) {
+ try {
+ const created = await api("/scans", { mode: "github", repository, ref: "HEAD" });
+ let scan = await wait(created.id, "mapped");
+ const map = scan.repositoryMap;
+ console.log(repository + ": mapped " + map.projects.length + " projects");
+ const preview = {
+ dependencies: map.projects.reduce((s: number, p: any) => s + (p.packageCount || 0), 0),
+ exact: map.projects.reduce((s: number, p: any) => s + (p.exactCount || 0), 0),
+ unresolved: map.projects.reduce((s: number, p: any) => s + (p.unresolvedCount || 0), 0),
+ };
+ const selected =
+ repository === "google/osv-scanner" || repository === "GoogleCloudPlatform/microservices-demo"
+ ? map.projects
+ : map.projects.filter((p: any) => p.selected && p.role !== "workspace").slice(0, 1);
+ if (selected.length) {
+ await api("/scans/" + scan.id + "/analyze", {
+ projects: selected.map((p: any) => ({ id: p.id, name: p.name })),
+ });
+ scan = await wait(scan.id, "completed");
+ }
+ const packages = (scan.graph?.nodes || []).filter((n: any) => n.kind === "package");
+ const exact = [
+ ...new Map(
+ packages.filter((n: any) => n.versionStatus !== "unresolved").map((n: any) => [n.purl, n]),
+ ).values(),
+ ];
+ const unresolved = packages.filter((n: any) => n.versionStatus === "unresolved");
+ if (unresolved.some((n: any) => n.coverage === "checked" || n.advisories.length || n.version))
+ throw new Error("Unresolved dependency was falsely matched");
+ const result = {
+ repository,
+ scanId: scan.id,
+ commit: scan.commit,
+ ref: scan.ref,
+ status: scan.status,
+ projects: map.projects.length,
+ files: map.fileCount,
+ allProjectPreview: preview,
+ selected: selected.map((p: any) => p.path),
+ dependencies: packages.length,
+ exactVersions: exact.length,
+ unresolved: unresolved.length,
+ graphNodes: scan.graph?.nodes.length || 0,
+ checked: exact.filter((n) => n.coverage === "checked").length,
+ coverage: scan.repositoryMap.projects.map((p: any) => ({
+ path: p.path,
+ status: p.ingestionStatus,
+ detected: p.packageCount,
+ exact: p.exactCount,
+ unresolved: p.unresolvedCount,
+ })),
+ warnings: scan.graph?.warnings || map.warnings,
+ };
+ results.push(result);
+ console.log(
+ JSON.stringify({ ...result, coverage: undefined, warnings: result.warnings.length }),
+ );
+ } catch (error) {
+ results.push({ repository, error: (error as Error).message });
+ console.error(repository + ": " + (error as Error).message);
+ process.exitCode = 1;
+ }
+ await mkdir("docs/verification", { recursive: true });
+ await writeFile(
+ "docs/verification/ingestion-repositories.json",
+ JSON.stringify({ verifiedAt: new Date().toISOString(), results }, null, 2),
+ );
+}
+if (process.env.VERIFY_BROWSER === "1") {
+ const browser = await chromium.launch();
+ try {
+ const checks: any[] = [];
+ for (const repository of ["google/osv-scanner", "jagpreetsingh02/SWASTRA-OCR"]) {
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
+ const errors: string[] = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.goto(base + "/app#new");
+ await page.getByLabel("Repository URL").fill("https://github.com/" + repository);
+ await page.getByRole("button", { name: "Map repository", exact: true }).click();
+ await expect(page.getByRole("heading", { name: "Your repository, understood." })).toBeVisible(
+ { timeout: 180000 },
+ );
+ if (repository.includes("SWASTRA"))
+ await expect(page.getByText("Detected dependencies: 14", { exact: false })).toBeVisible();
+ await page.getByRole("button", { name: "Analyze repository", exact: true }).click();
+ await expect(page.getByRole("region", { name: "Analysis summary" })).toBeVisible({
+ timeout: 300000,
+ });
+ if (repository.includes("SWASTRA")) {
+ await expect(page.getByRole("status")).toContainText("14 dependencies were discovered");
+ await page.getByRole("button", { name: "View all dependencies" }).click();
+ await page.getByRole("textbox", { name: "Search dependencies" }).fill("torch");
+ await expect(page.locator("tbody")).toContainText(">=2.5");
+ await expect(page.locator("tbody")).toContainText("Not checked");
+ }
+ expect(errors).toEqual([]);
+ checks.push({ repository, errors, passed: true });
+ await page.screenshot({
+ path: "test-results/ingestion-" + repository.split("/")[1] + ".png",
+ });
+ await page.close();
+ }
+ await writeFile(
+ "docs/verification/ingestion-browser.json",
+ JSON.stringify({ verifiedAt: new Date().toISOString(), checks }, null, 2),
+ );
+ } finally {
+ await browser.close();
+ }
+}
diff --git a/src/server/jobs.ts b/src/server/jobs.ts
index 0e5192a..ba77cc7 100644
--- a/src/server/jobs.ts
+++ b/src/server/jobs.ts
@@ -118,6 +118,7 @@ export class Jobs {
const github = new GitHub(http);
const input = this.store.input(scan.id);
const loaded = new Map>();
+ let repositoryFiles: ReturnType | undefined;
const extract = async (project: Project): Promise => {
const paths = [
...new Set(
@@ -144,7 +145,7 @@ export class Jobs {
: github.file(
input.repository!,
scan.commit!,
- this.store.files(scan.id).find((f) => f.path === path)!,
+ (repositoryFiles ??= this.store.files(scan.id)).find((f) => f.path === path)!,
input.installationId,
),
);
diff --git a/src/server/parsers.ts b/src/server/parsers.ts
index 7937679..a201704 100644
--- a/src/server/parsers.ts
+++ b/src/server/parsers.ts
@@ -107,7 +107,7 @@ export function parseManifest(filename: string, content: string, source?: Proven
);
if (!match) {
graph.warnings.push(
- `Unresolved requirement omitted: ${value.slice(0, 100)}. Supply an exact name==version pin.`,
+ `No exact version extracted for requirement: ${value.slice(0, 100)}. Supply an exact name==version pin for vulnerability matching.`,
);
continue;
}
@@ -124,7 +124,9 @@ export function parseManifest(filename: string, content: string, source?: Proven
for (const [name, raw] of Object.entries(data[group] || {})) {
const info = raw as { version?: string };
if (!info.version?.startsWith("==")) {
- graph.warnings.push(`No exact installed version for ${name}; skipped.`);
+ graph.warnings.push(
+ `No exact installed version for ${name}; excluded from exact-version matching.`,
+ );
continue;
}
const child = add("pypi", name, info.version.slice(2));
diff --git a/src/server/resolve-project.ts b/src/server/resolve-project.ts
index e62a738..efa0576 100644
--- a/src/server/resolve-project.ts
+++ b/src/server/resolve-project.ts
@@ -169,6 +169,7 @@ function resolveExact(project: Project, files: SourceFile[]): Graph {
}
function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
let data = JSON.parse(file.content);
+ const legacyWithoutManifest = data.lockfileVersion === 1 && !manifest;
let info: any = {};
let manifestError = "";
try {
@@ -315,6 +316,12 @@ function npmLock(p: Project, file: SourceFile, manifest?: SourceFile): Graph {
}
}
connect(root, entry, { ...entries[entry], ...info }, true);
+ if (legacyWithoutManifest) {
+ for (const edge of graph.edges.filter((e) => e.from === root.id)) edge.relationship = "listed";
+ graph.warnings.push(
+ "Legacy npm lockfile without package.json: installed entries are retained, but root direct dependencies are not known.",
+ );
+ }
if (
!graph.nodes.some((n) => n.kind === "package") &&
entry === "" &&
@@ -358,7 +365,7 @@ function npmManifest(p: Project, file?: SourceFile): Graph {
nodes: [root],
edges: [],
warnings: [
- "No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.",
+ "No supported resolved lockfile is available. Exact direct pins can be checked; other declarations remain unresolved and indirect relationships are unknown.",
],
};
for (const [name, version] of Object.entries({
diff --git a/src/web/main.tsx b/src/web/main.tsx
index 49be835..8b246e1 100644
--- a/src/web/main.tsx
+++ b/src/web/main.tsx
@@ -600,7 +600,15 @@ function App() {
<>
- {checked} of {exactCount} exact package versions checked
+ {exactCount === 0 && unresolved > 0 ? (
+ <>
+ {packages.length} dependencies discovered
+ >
+ ) : (
+ <>
+ {checked} of {exactCount} exact package versions checked
+ >
+ )}
{findingCount} known {scan.mode === "demo" ? "demo " : ""}
diff --git a/tests/browser/workspace.spec.ts b/tests/browser/workspace.spec.ts
index d293821..a73c7c0 100644
--- a/tests/browser/workspace.spec.ts
+++ b/tests/browser/workspace.spec.ts
@@ -63,3 +63,22 @@ test("invalid repository can be corrected without losing the form", async ({ pag
await expect(page.getByRole("alert")).toContainText("canonical GitHub");
await expect(page.getByLabel("Repository URL")).toHaveValue("https://localhost/private");
});
+test("unresolved declarations remain visible in map, graph and inventory", async ({ page }) => {
+ await page.goto("/app#new");
+ await page.getByRole("button", { name: "Dependency file", exact: true }).click();
+ await page.getByLabel("Filename", { exact: true }).fill("pyproject.toml");
+ await page.getByLabel("File content").fill('[project]\ndependencies=["requests>=2.31","numpy"]');
+ await page.getByRole("button", { name: "Map repository", exact: true }).click();
+ await expect(page.getByText("Detected dependencies: 2", { exact: false })).toBeVisible();
+ await expect(page.getByText("Coverage: DECLARED_ONLY", { exact: false })).toBeVisible();
+ await page.getByRole("button", { name: "Analyze repository", exact: true }).click();
+ await expect(page.getByRole("status")).toContainText("2 dependencies were discovered");
+ await expect(page.getByRole("region", { name: "Analysis summary" })).toContainText(
+ "2 dependencies discovered",
+ );
+ await page.getByRole("button", { name: "View all dependencies" }).click();
+ await page.getByRole("textbox", { name: "Search dependencies" }).fill("requests");
+ await expect(page.locator("tbody")).toContainText(">=2.31");
+ await expect(page.locator("tbody")).toContainText("Not checked");
+ await expect(page.locator("tbody")).toContainText("Not scored");
+});
diff --git a/tests/ingestion.test.ts b/tests/ingestion.test.ts
index 034afe0..67b4657 100644
--- a/tests/ingestion.test.ts
+++ b/tests/ingestion.test.ts
@@ -87,6 +87,7 @@ test("declared-only repository produces nodes and makes no source requests", asy
});
test("npm v1 lock creates exact direct and indirect nodes", () => {
const { packages, graph } = ingest({
+ "package.json": JSON.stringify({ dependencies: { a: "1.0.0" } }),
"package-lock.json": JSON.stringify({
lockfileVersion: 1,
name: "app",
@@ -160,3 +161,57 @@ test("invalid manifest does not hide a valid lock and missing lock entries are n
});
assert.equal(valid.packages.length, 2);
});
+test("one unsupported project cannot fail repository discovery or analysis", async () => {
+ const { Store } = await import("../src/server/store.js");
+ const { Jobs } = await import("../src/server/jobs.js");
+ const { Transport } = await import("../src/server/transport.js");
+ const store = new Store(":memory:");
+ const files: Record = {
+ "good/pyproject.toml": '[project]\ndependencies=["requests>=2.31"]',
+ "unsupported/setup.py": "raise RuntimeError('must never execute')",
+ "broken/package.json": "invalid",
+ };
+ const sha = "a".repeat(40);
+ const fetcher: typeof fetch = async (input) => {
+ const url = String(input);
+ let body: unknown = url.includes("/commits/")
+ ? { sha }
+ : url.includes("/trees/")
+ ? { tree: Object.keys(files).map((path) => ({ path, type: "blob", sha: "b".repeat(40) })) }
+ : { default_branch: "main" };
+ if (url.includes("raw.githubusercontent.com"))
+ return new Response(files[url.split(sha + "/")[1]], {
+ headers: { "content-type": "text/plain" },
+ });
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
+ };
+ const jobs = new Jobs(store, () => new Transport(store, fetcher));
+ try {
+ const created = jobs.create({ mode: "github", repository: "example/mixed", ref: "main" });
+ async function until(status: string) {
+ for (let i = 0; i < 1000; i++) {
+ const s = store.get(created.id)!;
+ if (s.status === status) return s;
+ assert.notEqual(s.status, "failed", s.error);
+ await new Promise((r) => setTimeout(r, 2));
+ }
+ throw new Error("Job timeout");
+ }
+ const mapped = await until("mapped");
+ assert.equal(mapped.repositoryMap!.projects.length, 3);
+ assert.equal(mapped.repositoryMap!.projects.find((p) => p.path === "good")?.unresolvedCount, 1);
+ jobs.analyze(
+ created.id,
+ mapped.repositoryMap!.projects.map((p) => ({ id: p.id, name: p.name })),
+ );
+ const completed = await until("completed");
+ assert.equal(completed.graph!.nodes.filter((n) => n.kind === "package").length, 1);
+ assert.equal(
+ completed.repositoryMap!.projects.filter((p) => p.ingestionStatus === "UNSUPPORTED").length,
+ 2,
+ );
+ } finally {
+ await jobs.close();
+ store.close();
+ }
+});