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 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 { 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','demoapp1.0org.examplelib2.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", + "demoapp1.0org.examplelib2.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
-

Dependency map {graph.nodes.length} nodes

{simulation?'Following risk back to the services that depend on it.':'Select a package to explore its connections.'}

-
- 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} - ;})} - -
-
CriticalHighMediumLow
Select to inspect{Math.round(zoom*100)}%
-
; +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."} +

+
+ +
+
+ 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 + +
+
+ + {Math.round(zoom * 100)}% + + +
+
+ {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 ( + + ); +} 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 + + +
+
+ {projects.map((project) => ( +
+ + setProjects((ps) => + ps.map((p) => (p.id === project.id ? { ...p, selected: e.target.checked } : p)), + ) + } + /> +
+ + + 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. +

+ +
+ )} +
+ ); +} 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 ( +
  1. + {done ? ( + + ) : current ? ( + + ) : ( + + )} + {label} + {current && In progress} +
  2. + ); + })} +
+

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 - + @@ -212,7 +206,7 @@ export default function HeroSection6() { every service you ship

- + 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
Evidence over guesswork.

Source-backed facts.
Explainable risk. Every time.

JG
Local workspaceSingle-tenant instance
; - return
Workspace{({overview:'Overview',inventory:'Dependencies',scans:'Scan history',connectors:'Connectors',methodology:'Documentation',new:'New scan'})[view]}
Local instance
-

{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'&&}
- {error&&
{error}
} - {auth?
{e.preventDefault();accessToken=token;void initialize();}}>

Connect to your workspace

Enter the API access token configured by your server administrator.

The token stays in memory for this tab.: - view==='new'?
{e.preventDefault();void start(mode==='github'?{mode,repository,ref}:{mode,filename,content});}}>
{mode==='github'?<>

Public repositories work without credentials. Private repositories use a server-configured GitHub App through the API.

:<>