diff --git a/.env.example b/.env.example index 20459a4..746359a 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,8 @@ GITHUB_INSTALLATION_IDS= GITHUB_ALLOWED_REPOS= GITHUB_WEBHOOK_SECRET= NVD_API_KEY= -MAX_ENRICH_PACKAGES=100 -MAX_NVD_CVES=20 +MAX_ENRICH_PACKAGES=2000 +MAX_NVD_CVES=3 OSS_INDEX_EMAIL= OSS_INDEX_TOKEN= OTEL_SERVICE_NAME=rippleguard diff --git a/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/README.md b/README.md new file mode 100644 index 0000000..16c4dd7 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# RippleGuard + +Understand a repository, check its dependencies, and trace how a package connects to applications. The existing React/Fastify app now follows **Scan → Repository Map → Analyze → Priority → Trace Ripple**. + +## Run locally + +Requires Node.js 24 or newer. From this directory: + +```sh +npm ci +npm run local +``` + +Open http://127.0.0.1:3000/app#new. Public GitHub scans need internet access; **Explore demo** works offline. No API key is required for public scans. Optional configuration is documented in `.env.example`. SQLite snapshots and source caches persist under `data/`. Keep the default loopback binding for local use; production requires an API token. + +## Two-minute recording + +1. **0:00–0:20:** Open **Explore demo** from the landing page. Say that this is the clearly labeled offline illustrative scenario. +2. **0:20–0:40:** Show the four projects in Repository Map. Rename Storefront if desired, leave all four selected, and click **Analyze repository**. +3. **0:40–1:00:** Point to lodash, its installed version, and the reasons for its priority: severity, source-reported exploitation, depth, and three affected projects. +4. **1:00–1:35:** Click **Trace Ripple**. Follow the package paths back to the three affected applications; show the four paths and unaffected fourth project. +5. **1:35–2:00:** Open the package details and explain how it entered the applications. Clarify that dependency paths describe possible impact, not proof that vulnerable code executes. + +For a recording using real source-backed findings, publish the prepared [controlled repository](demo-repository/README.md), paste its GitHub URL, and select the three `apps/` projects. Live verification found axios connected to all three through the shared package. Advisory counts and rankings can change as security sources update. The offline demo remains the reliable backup. + +## Accuracy and limits + +| Input | What is reconstructed | +| --- | --- | +| npm package-lock/shrinkwrap v2/v3 | Installed paths, nearest dependency resolution, workspace links, direct/indirect depth, and runtime/development scope. Independent installations remain distinct. | +| npm older lockfiles | Supported by the existing parser; less workspace context than v2/v3. | +| pnpm locks | Importer dependencies and recorded snapshot relationships; unsupported local workspace links remain explicit warnings. | +| package.json without usable lock / Yarn | Exact manifest pins are checked; ranges and other declarations remain visible as unresolved. Yarn lock resolution is not implemented. | +| Python requirements / Pipfile.lock | Exact pins are checked; unpinned declarations are retained with their constraints. Flat files do not establish indirect ancestry. Includes and commands are not executed. | +| uv / Poetry locks | Recorded packages and reconstructable edges; environment selection and application membership may remain unknown. Root links labeled “listed” do not establish direct depth. | +| Maven dependency tree | Recorded resolved dependency relationships. | +| Maven pom.xml | Literal versions only; parent properties, BOM-managed versions, profiles, and indirect dependencies may be unresolved. Maven is never executed. | + +Related files are grouped by path and ecosystem. Repository Map reads declarations and reports detected, exact and unresolved counts before analysis. Project coverage is EXACT, PARTIAL, DECLARED_ONLY or UNSUPPORTED. Unresolved nodes retain an unversioned identity, their declared specifier and partial coverage; they are not vulnerability-checked or priority-scored. Repository discovery has no manifest-count cutoff. Truncated GitHub trees use bounded directory traversal and disclose remaining gaps. File/transport limits, unsupported formats, and individual parser failures produce project-level coverage notes; they do not turn unchecked packages into safe ones. + +OSV provides package/version findings; CVSS vectors are scored deterministically. Registry sources can supplement findings; NVD enriches already-mapped CVEs only. CISA KEV adds positive known-exploitation evidence by exact CVE. Missing signals remain unknown. Fixed versions come only from matching affected-package evidence. No exploitation probability is invented. + +Ripple Priority is a bounded triage score, not a probability: severity up to 60 (CVSS × 6), source-reported exploitation 15, exposure up to 10 (inverse known depth, halved for development-only usage), and repository impact up to 15 (12 for affected-project proportion plus 3 for ancestor proportion). Findings rank ahead of packages without findings. Source facts, factors, and paths remain inspectable. Depth describes the recorded graph, not code execution. + +All occurrences of a selected package version are considered for impact, while installed-instance edges prevent false cross-project paths. Reverse traversal is cycle-safe. Path enumeration is bounded and labeled “at least” when truncated. The focused graph displays up to 70 nodes; the complete retained inventory and export remain available. Checking defaults to 2,000 unique package versions, configurable within bounds; omitted or failed checks remain visibly partial. + +The built-in demo contains synthetic `DEMO-*` security data and illustrative edges. The prepared real demo uses an npm-generated lockfile and live source findings. Neither is a production certification. + +## Verification + +```sh +npm run check +npm run test:e2e +# Start the app first for the live repository matrix: +node --import tsx scripts/verify-real-repos.ts +node --import tsx scripts/verify-controlled-demo.ts +node --import tsx scripts/verify-live-browser.ts +VERIFY_BROWSER=1 node --import tsx scripts/verify-ingestion.ts +``` + +Browser tests require Playwright Chromium (`npx playwright install chromium`), or set `PW_CHROME=1` to use installed Chrome. Tests use a separate local server and database on port 3100. + +Recorded results: [public repository matrix](docs/verification/live-repositories.json), [controlled demo evidence](docs/verification/controlled-demo.json). These are dated observations, not assertions about future repository contents or vulnerability-source responses. + +The matrix covers expressjs/express (small npm), this repository (lockfile), npm/cli (478 projects / 575 files), pallets/flask (Python), spring-projects/spring-petclinic (Maven), GoogleCloudPlatform/microservices-demo (mixed language), and octocat/Hello-World (empty supported-manifest state). Coverage warnings are preserved in the report. + +Main implementation: `src/server/discovery.ts`, `resolve-project.ts`, `advisories.ts`, `connectors.ts`, `jobs.ts`, and `graph.ts`; shared types in `src/shared/types.ts`; journey and focused graph in `src/web/main.tsx` and `src/web/components/`. Existing authenticated APIs, export, and webhook boundaries remain in place without expanding enterprise features. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..effaf10 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "files": { + "includes": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.ts", + "scripts/**/*.mjs", + "scripts/**/*.ts", + "playwright.config.ts", + "!src/web/components/ui/hero-section-6.tsx", + "!src/web/components/ui/animated-group.tsx" + ] + }, + "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/demo-repository/README.md b/demo-repository/README.md new file mode 100644 index 0000000..f219b14 --- /dev/null +++ b/demo-repository/README.md @@ -0,0 +1,20 @@ +# RippleGuard recording repository + +Prepared real npm workspace scenario: storefront, checkout, and admin each depend on `@rippleguard/shared-http`, which depends on pinned axios and express releases. `package-lock.json` was generated by npm and records real installed dependency relationships. There is no application runtime or install script. + +These deliberately old dependencies are analysis inputs. Use this folder for demonstrating dependency analysis, not as an application starter. + +To prepare the GitHub recording: + +1. Copy this folder into a separate Git repository and publish it to a public GitHub repository you control. Commit all manifests and the root lockfile. It has **not** been published automatically. +2. Paste that repository URL into RippleGuard and use the published commit SHA in the ref field for repeatable dependency input. +3. In Repository Map, select `apps/storefront`, `apps/checkout`, and `apps/admin`. Deselect the repository root and `packages/shared-http`; the shared package will be reached through the real workspace links. +4. Analyze, select the top vulnerable package, and click **Trace Ripple**. Inspect the source links and affected applications. + +Local verification from the parent RippleGuard repository: + +```sh +node --import tsx scripts/verify-controlled-demo.ts +``` + +The recorded verification checked 53 package versions, found findings on eight packages, and traced top-ranked axios through three paths to all three applications. See `../docs/verification/controlled-demo.json` for timestamped source evidence. Findings may evolve independently of the pinned dependency graph. Use RippleGuard’s clearly labeled offline synthetic demo if external APIs are unavailable during recording. diff --git a/demo-repository/apps/admin/package.json b/demo-repository/apps/admin/package.json new file mode 100644 index 0000000..d9f8dd0 --- /dev/null +++ b/demo-repository/apps/admin/package.json @@ -0,0 +1,8 @@ +{ + "name": "@rippleguard/admin", + "version": "1.0.0", + "private": true, + "dependencies": { + "@rippleguard/shared-http": "1.0.0" + } +} \ No newline at end of file diff --git a/demo-repository/apps/checkout/package.json b/demo-repository/apps/checkout/package.json new file mode 100644 index 0000000..fc8a562 --- /dev/null +++ b/demo-repository/apps/checkout/package.json @@ -0,0 +1,8 @@ +{ + "name": "@rippleguard/checkout", + "version": "1.0.0", + "private": true, + "dependencies": { + "@rippleguard/shared-http": "1.0.0" + } +} \ No newline at end of file diff --git a/demo-repository/apps/storefront/package.json b/demo-repository/apps/storefront/package.json new file mode 100644 index 0000000..bae69fa --- /dev/null +++ b/demo-repository/apps/storefront/package.json @@ -0,0 +1,8 @@ +{ + "name": "@rippleguard/storefront", + "version": "1.0.0", + "private": true, + "dependencies": { + "@rippleguard/shared-http": "1.0.0" + } +} \ No newline at end of file diff --git a/demo-repository/package-lock.json b/demo-repository/package-lock.json new file mode 100644 index 0000000..76bc6dd --- /dev/null +++ b/demo-repository/package-lock.json @@ -0,0 +1,615 @@ +{ + "name": "rippleguard-recording-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rippleguard-recording-demo", + "version": "1.0.0", + "workspaces": [ + "apps/*", + "packages/*" + ] + }, + "apps/admin": { + "name": "@rippleguard/admin", + "version": "1.0.0", + "dependencies": { + "@rippleguard/shared-http": "1.0.0" + } + }, + "apps/checkout": { + "name": "@rippleguard/checkout", + "version": "1.0.0", + "dependencies": { + "@rippleguard/shared-http": "1.0.0" + } + }, + "apps/storefront": { + "name": "@rippleguard/storefront", + "version": "1.0.0", + "dependencies": { + "@rippleguard/shared-http": "1.0.0" + } + }, + "node_modules/@rippleguard/admin": { + "resolved": "apps/admin", + "link": true + }, + "node_modules/@rippleguard/checkout": { + "resolved": "apps/checkout", + "link": true + }, + "node_modules/@rippleguard/shared-http": { + "resolved": "packages/shared-http", + "link": true + }, + "node_modules/@rippleguard/storefront": { + "resolved": "apps/storefront", + "link": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.10.0" + } + }, + "node_modules/body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "license": "MIT", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "packages/shared-http": { + "name": "@rippleguard/shared-http", + "version": "1.0.0", + "dependencies": { + "axios": "0.21.1", + "express": "4.17.1" + } + } + } +} diff --git a/demo-repository/package.json b/demo-repository/package.json new file mode 100644 index 0000000..2ec00f6 --- /dev/null +++ b/demo-repository/package.json @@ -0,0 +1 @@ +{"name":"rippleguard-recording-demo","version":"1.0.0","private":true,"workspaces":["apps/*","packages/*"],"description":"Dependency-only research fixture; do not deploy or execute vulnerable dependencies."} diff --git a/demo-repository/packages/shared-http/package.json b/demo-repository/packages/shared-http/package.json new file mode 100644 index 0000000..58ef1f9 --- /dev/null +++ b/demo-repository/packages/shared-http/package.json @@ -0,0 +1 @@ +{"name":"@rippleguard/shared-http","version":"1.0.0","private":true,"dependencies":{"express":"4.17.1","axios":"0.21.1"}} diff --git a/docs/verification/README.md b/docs/verification/README.md new file mode 100644 index 0000000..d2fa4e1 --- /dev/null +++ b/docs/verification/README.md @@ -0,0 +1,13 @@ +# Verification record + +Verified September 13–14, 2026 (local timezone Asia/Kolkata; JSON timestamps use UTC). + +- `npm run check`: lint, typecheck, 27 unit/API tests, and production build passed. +- `npm run test:e2e`: 10 tests passed across desktop and mobile Chromium. Covers landing entry, repository selection and rename, offline analysis, paths and ripple, mobile width, inventory, export, persistence, and invalid repository input. +- `live-repositories.json`: seven real public GitHub repositories. Each record contains the exact analyzed commit, selected projects, coverage, advisories, and warnings. npm/cli discovered 478 projects from 575 dependency files; one selected project was analyzed. The report does not claim all 478 were analyzed. +- `controlled-demo.json`: npm-generated workspace lockfile checked against live vulnerability sources; 53 versions checked, eight packages with findings, three application paths for top-ranked axios. +- `live-browser.json`: real GitHub URL submitted through the browser, Repository Map confirmed, analysis completed, OSV source link verified, and ripple traced to two of six projects. No JavaScript errors or page-width overflow. This run selected all six projects; the API matrix selected five. + +Visual review covered desktop results, Repository Map, and mobile ripple. The observed mobile grid overflow was corrected and the full browser suite passed afterward. Screenshots and browser traces are local ignored artifacts in `test-results/`. + +Live source contents may change. Python conditional relationships, manifest-only npm ranges, and Maven BOM resolution were explicitly partial in the tested inputs. No “secure” conclusion is inferred from an empty finding set or unavailable source. diff --git a/docs/verification/controlled-demo.json b/docs/verification/controlled-demo.json new file mode 100644 index 0000000..8577ca1 --- /dev/null +++ b/docs/verification/controlled-demo.json @@ -0,0 +1,900 @@ +{ + "verifiedAt": "2026-09-13T15:54:27.391Z", + "projects": [ + "apps/admin", + "apps/checkout", + "apps/storefront" + ], + "packageVersions": 53, + "checked": 53, + "findings": 8, + "top": { + "name": "axios", + "nodeId": "c9948ba25ba56398fe11", + "score": 69, + "level": "high", + "factors": { + "severity": 51.6, + "exploit": 0, + "exposure": 5, + "blastRadius": 12 + }, + "ancestors": 4, + "services": [ + "service-25a618fd55fb09c6", + "service-73834da44199b6b9", + "service-fc948145bb665052" + ], + "advisories": 24, + "coverage": "checked", + "reasons": [ + "High severity vulnerability", + "Hidden 2 levels deep", + "Used at runtime", + "Used by 3 of 3 projects" + ] + }, + "ripple": { + "projects": 3, + "paths": 3 + }, + "warnings": [], + "advisories": [ + { + "name": "axios", + "version": "0.21.1", + "advisories": [ + { + "id": "GHSA-3g43-6gmg-66jw", + "aliases": [ + "CVE-2026-44495" + ], + "summary": "axios Vulnerable to Credential Theft and Response Hijacking via Prototype Pollution Gadget in Config Merge", + "cvss": 7, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:L", + "severity": "high", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.2", + "From 0.19.0 to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-3g43-6gmg-66jw", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-3p68-rc4w-qgx5", + "aliases": [ + "CVE-2025-62718" + ], + "summary": "Axios has a NO_PROXY Hostname Normalization Bypass that Leads to SSRF", + "cvss": 6.3, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:L/SI:L/SA:N", + "fixed": "0.31.0", + "affected": [ + "From 1.0.0 to before 1.15.0", + "Earlier versions to before 0.31.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-3p68-rc4w-qgx5", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-43fc-jf86-j433", + "aliases": [ + "CVE-2026-25639" + ], + "summary": "Axios is Vulnerable to Denial of Service via __proto__ Key in mergeConfig", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "severity": "high", + "fixed": "0.30.3", + "affected": [ + "From 1.0.0 to before 1.13.5", + "Earlier versions to before 0.30.3" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-43fc-jf86-j433", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-5c9x-8gcm-mpgx", + "aliases": [ + "CVE-2026-42034" + ], + "summary": "Axios' HTTP adapter-streamed uploads bypass maxBodyLength when maxRedirects: 0", + "cvss": 5.3, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-5c9x-8gcm-mpgx", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-62hf-57xw-28j9", + "aliases": [ + "CVE-2026-42039" + ], + "summary": "Axios: unbounded recursion in toFormData causes DoS via deeply nested request data", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-62hf-57xw-28j9", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-6chq-wfr3-2hj9", + "aliases": [ + "CVE-2026-42035" + ], + "summary": "Axios: Header Injection via Prototype Pollution", + "cvss": 7.4, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N", + "severity": "high", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-6chq-wfr3-2hj9", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-7q8q-rj6j-mhjq", + "aliases": [ + "CVE-2026-67319" + ], + "summary": "Axios: Nested axios option objects can consume polluted prototype values", + "cvss": 6.3, + "cvssVector": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N", + "fixed": "0.33.0", + "affected": [ + "From 0.8.0 to before 0.33.0", + "From 1.0.0 to before 1.18.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-7q8q-rj6j-mhjq", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-898c-q2cr-xwhg", + "aliases": [ + "CVE-2026-44490" + ], + "summary": "axios has DoS & Header Injection via Prototype Pollution Read-Side Gadgets in axios merge functions", + "cvss": 4.8, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:L", + "fixed": "0.32.0", + "affected": [ + "From 1.0.0 to before 1.16.0", + "Earlier versions to before 0.32.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-898c-q2cr-xwhg", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-cph5-m8f7-6c5x", + "aliases": [ + "CVE-2021-3749" + ], + "summary": "axios Inefficient Regular Expression Complexity vulnerability", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "severity": "high", + "fixed": "0.21.2", + "affected": [ + "Earlier versions to before 0.21.2" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-cph5-m8f7-6c5x", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-fvcv-3m26-pcqx", + "aliases": [ + "CVE-2026-40175" + ], + "summary": "Axios has Unrestricted Cloud Metadata Exfiltration via Header Injection Chain", + "cvss": 4.8, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N", + "fixed": "0.31.0", + "affected": [ + "From 1.0.0 to before 1.15.0", + "Earlier versions to before 0.31.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-fvcv-3m26-pcqx", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-hfxv-24rg-xrqf", + "aliases": [ + "CVE-2026-44496" + ], + "summary": "Axios: Regular Expression Denial of Service (ReDoS) via Cookie Name Injection", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "severity": "high", + "fixed": "0.32.0", + "affected": [ + "From 1.0.0 to before 1.16.0", + "Earlier versions to before 0.32.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-hfxv-24rg-xrqf", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-j5f8-grm9-p9fc", + "aliases": [ + "CVE-2026-44486" + ], + "summary": "Axios: Proxy-Authorization header leaks to redirect target when proxy is re-evaluated to direct connection", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", + "severity": "high", + "fixed": "0.32.0", + "affected": [ + "From 1.0.0 to before 1.16.0", + "Earlier versions to before 0.32.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-j5f8-grm9-p9fc", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-jr5f-v2jv-69x6", + "aliases": [ + "CVE-2025-27152" + ], + "summary": "axios Requests Vulnerable To Possible SSRF and Credential Leakage via Absolute URL", + "cvss": 7.7, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:P", + "severity": "high", + "fixed": "0.30.0", + "affected": [ + "From 1.0.0 to before 1.8.2", + "Earlier versions to before 0.30.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-jr5f-v2jv-69x6", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-m7pr-hjqh-92cm", + "aliases": [ + "CVE-2026-42038" + ], + "summary": "Axios: no_proxy bypass via IP alias allows SSRF", + "cvss": 6.8, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-m7pr-hjqh-92cm", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-mmx7-hfxf-jppx", + "aliases": [ + "CVE-2026-67316" + ], + "summary": "Axios: Prototype pollution gadgets can alter axios request construction", + "cvss": 6.3, + "cvssVector": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N", + "fixed": "0.33.0", + "affected": [ + "From 1.0.0 to before 1.18.0", + "Earlier versions to before 0.33.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-mmx7-hfxf-jppx", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-p92q-9vqr-4j8v", + "aliases": [ + "CVE-2026-44487" + ], + "summary": "Axios: Proxy-Authorization Credential Leak to Origin Server Across HTTP-to-HTTPS Redirect in Axios Node.js HTTP Adapter", + "cvss": 8.2, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N", + "severity": "high", + "fixed": "0.32.0", + "affected": [ + "From 1.0.0 to before 1.16.0", + "Earlier versions to before 0.32.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-p92q-9vqr-4j8v", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-pf86-5x62-jrwf", + "aliases": [ + "CVE-2026-42033" + ], + "summary": "Axios: Prototype Pollution Gadgets - Response Tampering, Data Exfiltration, and Request Hijacking", + "cvss": 7.4, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N", + "severity": "high", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-pf86-5x62-jrwf", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-pjwm-pj3p-43mv", + "aliases": [ + "CVE-2026-44492" + ], + "summary": "axios's shouldBypassProxy does not recognize IPv4-mapped IPv6 addresses, allowing NO_PROXY bypass (incomplete fix for CVE-2025-62718)", + "cvss": 8.6, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N", + "severity": "high", + "fixed": "0.32.0", + "affected": [ + "From 1.15.0 to before 1.16.0", + "Earlier versions to before 0.32.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-pjwm-pj3p-43mv", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-pmwg-cvhr-8vh7", + "aliases": [ + "CVE-2026-42043" + ], + "summary": "Axios: Incomplete Fix for CVE-2025-62718 — NO_PROXY Protection Bypassed via RFC 1122 Loopback Subnet (127.0.0.0/8) in Axios 1.15.0", + "cvss": 7.2, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N", + "severity": "high", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-pmwg-cvhr-8vh7", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-vf2m-468p-8v99", + "aliases": [ + "CVE-2026-42036" + ], + "summary": "Axios: HTTP adapter streamed responses bypass maxContentLength", + "cvss": 5.3, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-vf2m-468p-8v99", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-w9j2-pvgh-6h63", + "aliases": [ + "CVE-2026-42041" + ], + "summary": "Axios: Authentication Bypass via Prototype Pollution Gadget in `validateStatus` Merge Strategy", + "cvss": 4.8, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-w9j2-pvgh-6h63", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-wf5p-g6vw-rhxx", + "aliases": [ + "CVE-2023-45857" + ], + "summary": "Axios Cross-Site Request Forgery Vulnerability", + "cvss": 6.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N", + "fixed": "0.28.0", + "affected": [ + "From 1.0.0 to before 1.6.0", + "From 0.8.1 to before 0.28.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-wf5p-g6vw-rhxx", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-xhjh-pmcv-23jw", + "aliases": [ + "CVE-2026-42040" + ], + "summary": "Axios: Null Byte Injection via Reverse-Encoding in AxiosURLSearchParams", + "cvss": 3.7, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N", + "severity": "low", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-xhjh-pmcv-23jw", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + }, + { + "id": "GHSA-xx6v-rp6x-q39c", + "aliases": [ + "CVE-2026-42042" + ], + "summary": "Axios: XSRF Token Cross-Origin Leakage via Prototype Pollution Gadget in `withXSRFToken` Boolean Coercion", + "cvss": 5.4, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N", + "fixed": "0.31.1", + "affected": [ + "From 1.0.0 to before 1.15.1", + "Earlier versions to before 0.31.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-xx6v-rp6x-q39c", + "retrievedAt": "2026-09-13T15:54:19.888Z" + } + ] + } + ] + }, + { + "name": "express", + "version": "4.17.1", + "advisories": [ + { + "id": "GHSA-qw6h-vgh9-j6wx", + "aliases": [ + "CVE-2024-43796" + ], + "summary": "express vulnerable to XSS via response.redirect()", + "cvss": 5, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L", + "severity": "low", + "fixed": "4.20.0", + "affected": [ + "Earlier versions to before 4.20.0", + "From 5.0.0-alpha.1 to before 5.0.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-qw6h-vgh9-j6wx", + "retrievedAt": "2026-09-13T15:54:19.438Z" + } + ] + }, + { + "id": "GHSA-rv95-896h-c2vc", + "aliases": [ + "CVE-2024-29041" + ], + "summary": "Express.js Open Redirect in malformed URLs", + "cvss": 6.1, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "fixed": "4.19.2", + "affected": [ + "Earlier versions to before 4.19.2", + "From 5.0.0-alpha.1 to before 5.0.0-beta.3" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-rv95-896h-c2vc", + "retrievedAt": "2026-09-13T15:54:19.438Z" + } + ] + } + ] + }, + { + "name": "body-parser", + "version": "1.19.0", + "advisories": [ + { + "id": "GHSA-qwcr-r2fm-qrc7", + "aliases": [ + "CVE-2024-45590" + ], + "summary": "body-parser vulnerable to denial of service when url encoding is enabled", + "cvss": 8.7, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N", + "severity": "high", + "fixed": "1.20.3", + "affected": [ + "Earlier versions to before 1.20.3" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-qwcr-r2fm-qrc7", + "retrievedAt": "2026-09-13T15:54:19.788Z" + } + ] + }, + { + "id": "GHSA-v422-hmwv-36x6", + "aliases": [ + "CVE-2026-12590" + ], + "summary": "body-parser vulnerable to denial of service when invalid limit value silently disables size enforcement", + "cvss": 3.7, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", + "severity": "low", + "fixed": "1.20.6", + "affected": [ + "Earlier versions to before 1.20.6", + "From 2.0.0 to before 2.3.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-v422-hmwv-36x6", + "retrievedAt": "2026-09-13T15:54:19.788Z" + } + ] + } + ] + }, + { + "name": "cookie", + "version": "0.4.0", + "advisories": [ + { + "id": "GHSA-pxg6-pf52-xh8x", + "aliases": [ + "CVE-2024-47764" + ], + "summary": "cookie accepts cookie name, path, and domain with out of bounds characters", + "cvss": 6.9, + "severity": "low", + "fixed": "0.7.0", + "affected": [ + "Earlier versions to before 0.7.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-pxg6-pf52-xh8x", + "retrievedAt": "2026-09-13T15:54:19.907Z" + }, + { + "source": "NVD", + "url": "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-47764", + "retrievedAt": "2026-09-13T15:54:27.369Z" + } + ] + } + ] + }, + { + "name": "path-to-regexp", + "version": "0.1.7", + "advisories": [ + { + "id": "GHSA-37ch-88jc-xwx2", + "aliases": [ + "CVE-2024-45296", + "CVE-2026-4867", + "GHSA-9wv6-86v2-598j" + ], + "summary": "path-to-regexp vulnerable to Regular Expression Denial of Service via multiple route parameters", + "cvss": 7.7, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "severity": "high", + "fixed": "0.1.13", + "affected": [ + "Earlier versions to before 0.1.13" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-37ch-88jc-xwx2", + "retrievedAt": "2026-09-13T15:54:20.755Z" + }, + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-9wv6-86v2-598j", + "retrievedAt": "2026-09-13T15:54:20.755Z" + } + ] + }, + { + "id": "GHSA-rhx6-c78j-4q9w", + "aliases": [ + "CVE-2024-52798" + ], + "summary": "path-to-regexp contains a ReDoS", + "cvss": 7.7, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N/E:P", + "severity": "high", + "fixed": "0.1.12", + "affected": [ + "Earlier versions to before 0.1.12" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-rhx6-c78j-4q9w", + "retrievedAt": "2026-09-13T15:54:20.755Z" + } + ] + } + ] + }, + { + "name": "qs", + "version": "6.7.0", + "advisories": [ + { + "id": "GHSA-4mjr-xmp4-gh2g", + "aliases": [ + "CVE-2026-82417" + ], + "summary": "qs: Denial of Service via Attacker Controlled isBuffer", + "cvss": 6.3, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N", + "fixed": "6.16.0", + "affected": [ + "From 2.2.5 to before 6.16.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-4mjr-xmp4-gh2g", + "retrievedAt": "2026-09-13T15:54:21.055Z" + } + ] + }, + { + "id": "GHSA-6rw7-vpxm-498p", + "aliases": [ + "CVE-2025-15284" + ], + "summary": "qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion", + "cvss": 6.3, + "cvssVector": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:L", + "fixed": "6.14.1", + "affected": [ + "Earlier versions to before 6.14.1" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-6rw7-vpxm-498p", + "retrievedAt": "2026-09-13T15:54:21.055Z" + } + ] + }, + { + "id": "GHSA-hrpp-h998-j3pp", + "aliases": [ + "CVE-2022-24999" + ], + "summary": "qs vulnerable to Prototype Pollution", + "cvss": 7.5, + "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H", + "severity": "high", + "fixed": "6.7.3", + "affected": [ + "From 6.10.0 to before 6.10.3", + "From 6.9.0 to before 6.9.7", + "From 6.8.0 to before 6.8.3", + "From 6.7.0 to before 6.7.3", + "From 6.6.0 to before 6.6.1", + "From 6.5.0 to before 6.5.3", + "From 6.4.0 to before 6.4.1", + "From 6.3.0 to before 6.3.3", + "Earlier versions to before 6.2.4" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-hrpp-h998-j3pp", + "retrievedAt": "2026-09-13T15:54:21.055Z" + } + ] + }, + { + "id": "GHSA-w7fw-mjwx-w883", + "aliases": [ + "CVE-2026-2391" + ], + "summary": "qs's arrayLimit bypass in comma parsing allows denial of service", + "cvss": 3.7, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L", + "severity": "low", + "fixed": "6.14.2", + "affected": [ + "From 6.7.0 to before 6.14.2" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-w7fw-mjwx-w883", + "retrievedAt": "2026-09-13T15:54:21.055Z" + } + ] + } + ] + }, + { + "name": "send", + "version": "0.17.1", + "advisories": [ + { + "id": "GHSA-m6fv-jmcg-4jfg", + "aliases": [ + "CVE-2024-43799" + ], + "summary": "send vulnerable to template injection that can lead to XSS", + "cvss": 5, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L", + "severity": "low", + "fixed": "0.19.0", + "affected": [ + "Earlier versions to before 0.19.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-m6fv-jmcg-4jfg", + "retrievedAt": "2026-09-13T15:54:21.120Z" + } + ] + } + ] + }, + { + "name": "serve-static", + "version": "1.14.1", + "advisories": [ + { + "id": "GHSA-cm22-4g7w-348p", + "aliases": [ + "CVE-2024-43800" + ], + "summary": "serve-static vulnerable to template injection that can lead to XSS", + "cvss": 5, + "cvssVector": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:L", + "severity": "low", + "fixed": "1.16.0", + "affected": [ + "Earlier versions to before 1.16.0", + "From 2.0.0 to before 2.1.0" + ], + "provenance": [ + { + "source": "OSV", + "url": "https://osv.dev/vulnerability/GHSA-cm22-4g7w-348p", + "retrievedAt": "2026-09-13T15:54:21.174Z" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/verification/ingestion-browser.json b/docs/verification/ingestion-browser.json new file mode 100644 index 0000000..91dd709 --- /dev/null +++ b/docs/verification/ingestion-browser.json @@ -0,0 +1,15 @@ +{ + "verifiedAt": "2026-09-14T05:53:17.425Z", + "checks": [ + { + "repository": "google/osv-scanner", + "errors": [], + "passed": true + }, + { + "repository": "jagpreetsingh02/SWASTRA-OCR", + "errors": [], + "passed": true + } + ] +} \ No newline at end of file diff --git a/docs/verification/ingestion-repositories.json b/docs/verification/ingestion-repositories.json new file mode 100644 index 0000000..35f1485 --- /dev/null +++ b/docs/verification/ingestion-repositories.json @@ -0,0 +1,4000 @@ +{ + "verifiedAt": "2026-09-14T05:53:11.112Z", + "results": [ + { + "repository": "google/osv-scanner", + "scanId": "6be7fac2-b3ee-4496-9369-272802d2c7a1", + "commit": "e840a6e8adb14b7777c78e26cfbf6e2abc1d1fc6", + "ref": "main", + "status": "completed", + "projects": 24, + "files": 30, + "allProjectPreview": { + "dependencies": 143, + "exact": 143, + "unresolved": 0 + }, + "selected": [ + "cmd/osv-scanner/fix/testdata/in-place-npm", + "cmd/osv-scanner/fix/testdata/override-maven", + "cmd/osv-scanner/fix/testdata/relax-npm", + "cmd/osv-scanner/scan/image/testdata/java-fixture/app", + "cmd/osv-scanner/scan/image/testdata/python-fixture", + "cmd/osv-scanner/scan/source/testdata/exp-plugins-pkgdeprecate/deprecated-npm", + "cmd/osv-scanner/scan/source/testdata/locks-empty", + "cmd/osv-scanner/scan/source/testdata/locks-gitignore", + "cmd/osv-scanner/scan/source/testdata/locks-gitignore/ignored", + "cmd/osv-scanner/scan/source/testdata/locks-gitignore/subdir", + "cmd/osv-scanner/scan/source/testdata/locks-licenses", + "cmd/osv-scanner/scan/source/testdata/locks-many", + "cmd/osv-scanner/scan/source/testdata/locks-many-with-insecure", + "cmd/osv-scanner/scan/source/testdata/locks-many-with-invalid", + "cmd/osv-scanner/scan/source/testdata/locks-one-with-nested", + "cmd/osv-scanner/scan/source/testdata/locks-requirements", + "cmd/osv-scanner/scan/source/testdata/locks-scalibr", + "cmd/osv-scanner/scan/source/testdata/locks-test-ignore", + "cmd/osv-scanner/scan/source/testdata/maven-transitive", + "cmd/osv-scanner/scan/testdata/locks-many", + "cmd/osv-scanner/scan/testdata/locks-one-with-nested", + "cmd/osv-scanner/testdata/locks-many", + "cmd/osv-scanner/testdata/locks-one-with-nested", + "cmd/osv-scanner/update/testdata" + ], + "dependencies": 143, + "exactVersions": 137, + "unresolved": 0, + "graphNodes": 167, + "checked": 137, + "coverage": [ + { + "path": "cmd/osv-scanner/fix/testdata/in-place-npm", + "status": "PARTIAL", + "detected": 106, + "exact": 106, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/fix/testdata/override-maven", + "status": "PARTIAL", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/fix/testdata/relax-npm", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/image/testdata/java-fixture/app", + "status": "PARTIAL", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/image/testdata/python-fixture", + "status": "PARTIAL", + "detected": 13, + "exact": 13, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/exp-plugins-pkgdeprecate/deprecated-npm", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-empty", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-gitignore", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-gitignore/ignored", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-gitignore/subdir", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-licenses", + "status": "EXACT", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-many", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-many-with-insecure", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-many-with-invalid", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-one-with-nested", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-requirements", + "status": "PARTIAL", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-scalibr", + "status": "PARTIAL", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/locks-test-ignore", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/source/testdata/maven-transitive", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/testdata/locks-many", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/scan/testdata/locks-one-with-nested", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/testdata/locks-many", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/testdata/locks-one-with-nested", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "cmd/osv-scanner/update/testdata", + "status": "PARTIAL", + "detected": 2, + "exact": 2, + "unresolved": 0 + } + ], + "warnings": [ + "in-place-npm: Lockfile version for readable-stream does not satisfy its declared range ~2.0.0; regenerate the lockfile.", + "override-maven: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.", + "osv-fix: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "app: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.", + "python-fixture: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "locks-empty: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "locks-empty: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.", + "locks-gitignore: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "locks-gitignore: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.", + "ignored: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "ignored: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.", + "subdir: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "subdir: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.", + "locks-many-with-invalid: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "locks-many-with-invalid: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.", + "locks-one-with-nested: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "locks-one-with-nested: No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects.", + "locks-requirements: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "locks-scalibr: Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies.", + "maven-transitive: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.", + "testdata: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.", + "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound." + ] + }, + { + "repository": "jagpreetsingh02/SWASTRA-OCR", + "scanId": "c79a673a-78b8-4935-a752-2ccf360dc19d", + "commit": "840ded5663fe721607741e96270c81920704bd15", + "ref": "main", + "status": "completed", + "projects": 1, + "files": 1, + "allProjectPreview": { + "dependencies": 14, + "exact": 0, + "unresolved": 14 + }, + "selected": [ + "." + ], + "dependencies": 14, + "exactVersions": 0, + "unresolved": 14, + "graphNodes": 15, + "checked": 0, + "coverage": [ + { + "path": ".", + "status": "DECLARED_ONLY", + "detected": 14, + "exact": 0, + "unresolved": 14 + } + ], + "warnings": [ + "SWASTRA-OCR: No exact resolved Python snapshot is available. Unpinned dependencies are not checked; use uv.lock, poetry.lock, Pipfile.lock or pinned requirements.", + "SWASTRA-OCR: 14 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching." + ] + }, + { + "repository": "expressjs/express", + "scanId": "837c0c29-4ddf-4a4e-8bd6-090a79c1bfa6", + "commit": "3ce6d0eb86e9d93529ff3191c6bb5db8ce6e72c8", + "ref": "master", + "status": "completed", + "projects": 1, + "files": 1, + "allProjectPreview": { + "dependencies": 44, + "exact": 8, + "unresolved": 36 + }, + "selected": [ + "." + ], + "dependencies": 44, + "exactVersions": 8, + "unresolved": 36, + "graphNodes": 45, + "checked": 8, + "coverage": [ + { + "path": ".", + "status": "PARTIAL", + "detected": 44, + "exact": 8, + "unresolved": 36 + } + ], + "warnings": [ + "express: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "express: accepts: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: body-parser: ^2.3.0 is not an exact installed version; omitted from vulnerability checks.", + "express: content-disposition: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.", + "express: content-type: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: cookie: ^0.7.1 is not an exact installed version; omitted from vulnerability checks.", + "express: cookie-signature: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.", + "express: debug: ^4.4.0 is not an exact installed version; omitted from vulnerability checks.", + "express: depd: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: encodeurl: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: escape-html: ^1.0.3 is not an exact installed version; omitted from vulnerability checks.", + "express: etag: ^1.8.1 is not an exact installed version; omitted from vulnerability checks.", + "express: finalhandler: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: fresh: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: http-errors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: merge-descriptors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: mime-types: ^3.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: on-finished: ^2.4.1 is not an exact installed version; omitted from vulnerability checks.", + "express: once: ^1.4.0 is not an exact installed version; omitted from vulnerability checks.", + "express: parseurl: ^1.3.3 is not an exact installed version; omitted from vulnerability checks.", + "express: proxy-addr: ^2.0.7 is not an exact installed version; omitted from vulnerability checks.", + "express: qs: ^6.15.2 is not an exact installed version; omitted from vulnerability checks.", + "express: range-parser: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.", + "express: router: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.", + "express: send: ^1.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: serve-static: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.", + "express: statuses: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.", + "express: type-is: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: vary: ^1.1.2 is not an exact installed version; omitted from vulnerability checks.", + "express: connect-redis: ^8.0.1 is not an exact installed version; omitted from vulnerability checks.", + "express: ejs: ^3.1.10 is not an exact installed version; omitted from vulnerability checks.", + "express: express-session: ^1.18.1 is not an exact installed version; omitted from vulnerability checks.", + "express: marked: ^15.0.3 is not an exact installed version; omitted from vulnerability checks.", + "express: mocha: ^11.7.5 is not an exact installed version; omitted from vulnerability checks.", + "express: nyc: ^17.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: supertest: ^6.3.0 is not an exact installed version; omitted from vulnerability checks.", + "express: vhost: ~3.0.2 is not an exact installed version; omitted from vulnerability checks.", + "express: 36 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching." + ] + }, + { + "repository": "jagpreetsingh02/MIT", + "scanId": "51974380-385c-4bfe-9d84-2f2af8ec95d2", + "commit": "c75031ca7546706d042c585852d18b29c8f9015e", + "ref": "main", + "status": "completed", + "projects": 1, + "files": 2, + "allProjectPreview": { + "dependencies": 427, + "exact": 412, + "unresolved": 15 + }, + "selected": [ + "." + ], + "dependencies": 427, + "exactVersions": 409, + "unresolved": 15, + "graphNodes": 428, + "checked": 409, + "coverage": [ + { + "path": ".", + "status": "PARTIAL", + "detected": 427, + "exact": 412, + "unresolved": 15 + } + ], + "warnings": [ + "rippleguard: Could not determine the installed dependency @rolldown/plugin-babel used by @vitejs/plugin-react.", + "rippleguard: Could not determine the installed dependency babel-plugin-react-compiler used by @vitejs/plugin-react.", + "rippleguard: Could not determine the installed dependency oxc-transform-react used by @vitejs/plugin-react.", + "rippleguard: Could not determine the installed dependency @vitejs/devtools used by vite.", + "rippleguard: Could not determine the installed dependency less used by vite.", + "rippleguard: Could not determine the installed dependency sass used by vite.", + "rippleguard: Could not determine the installed dependency sass-embedded used by vite.", + "rippleguard: Could not determine the installed dependency stylus used by vite.", + "rippleguard: Could not determine the installed dependency sugarss used by vite.", + "rippleguard: Could not determine the installed dependency terser used by vite.", + "rippleguard: Could not determine the installed dependency @emnapi/core used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @emnapi/runtime used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @emnapi/wasi-threads used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @napi-rs/wasm-runtime used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @tybys/wasm-util used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: 15 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching." + ] + }, + { + "repository": "npm/cli", + "scanId": "661ba1e8-e137-48fb-9420-2c34bf1f5031", + "commit": "c9876d7ea7150b0702e4151210b9fa1a8dbc7fbf", + "ref": "latest", + "status": "completed", + "projects": 478, + "files": 575, + "allProjectPreview": { + "dependencies": 23330, + "exact": 22485, + "unresolved": 845 + }, + "selected": [ + "mock-globals" + ], + "dependencies": 964, + "exactVersions": 797, + "unresolved": 7, + "graphNodes": 965, + "checked": 797, + "coverage": [ + { + "path": ".", + "status": "PARTIAL", + "detected": 1102, + "exact": 1085, + "unresolved": 17 + }, + { + "path": "docs", + "status": "PARTIAL", + "detected": 1099, + "exact": 1089, + "unresolved": 10 + }, + { + "path": "mock-globals", + "status": "PARTIAL", + "detected": 964, + "exact": 957, + "unresolved": 7 + }, + { + "path": "mock-registry", + "status": "PARTIAL", + "detected": 973, + "exact": 966, + "unresolved": 7 + }, + { + "path": "smoke-tests", + "status": "PARTIAL", + "detected": 980, + "exact": 973, + "unresolved": 7 + }, + { + "path": "smoke-tests/test/fixtures/large-install", + "status": "PARTIAL", + "detected": 1324, + "exact": 1306, + "unresolved": 18 + }, + { + "path": "workspaces/arborist", + "status": "PARTIAL", + "detected": 995, + "exact": 983, + "unresolved": 12 + }, + { + "path": "workspaces/arborist/test/fixtures/accept-dependencies", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/accept-dependencies/dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/accept-dependencies/with-newer", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/ancient-lockfile-invalid", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-all-severities", + "status": "PARTIAL", + "detected": 474, + "exact": 473, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-dep-vuln-with-own-advisory", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-fix-old-tap", + "status": "EXACT", + "detected": 571, + "exact": 571, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-linked-package", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-missing-packument", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-mkdirp", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-mkdirp/mkdirp-unfixable", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-nyc-mkdirp", + "status": "EXACT", + "detected": 329, + "exact": 329, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-omit", + "status": "EXACT", + "detected": 751, + "exact": 751, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/audit-one-vuln", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/bad", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/a-latest", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/a-old", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/bundle-metadep-duplication", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/x", + "status": "PARTIAL", + "detected": 4, + "exact": 3, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/y", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/bundle-metadep-duplication/z", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/carbonium", + "status": "EXACT", + "detected": 126, + "exact": 126, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/cli-750", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/cli-750-fresh", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/cli-750-fresh/app", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/cli-750-fresh/lib", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/cli-750/app", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/cli-750/lib", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/conflict-bundle-file-dep", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/conflict-bundle-file-dep/fixtures-has-package-exports", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/conflict-package-lock", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/conflicted-peer-optional-from-dev-dep/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/conflicted-peer-optional-from-dev-dep/peer/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/conflicted-peer-optional-from-dev-dep/peer/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/dedupe-actual", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/dedupe-lockfile", + "status": "EXACT", + "detected": 3, + "exact": 3, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/dedupe-tests", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/dedupe-tests-2", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/deepmixedloop", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/dep-installed-without-bin-link", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/dep-missing-resolved", + "status": "EXACT", + "detected": 233, + "exact": 233, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/deprecated-dep", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/dev-deps", + "status": "EXACT", + "detected": 5, + "exact": 5, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/devloop", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/changed", + "status": "EXACT", + "detected": 7, + "exact": 7, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/ok", + "status": "EXACT", + "detected": 7, + "exact": 7, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/removed", + "status": "EXACT", + "detected": 6, + "exact": 6, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/edit-package-json/workspaces-changed/c", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/empty-with-shrinkwrap", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/empty-with-shrinkwrap-no-pj", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/engine-omit-test", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/engine-specification", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/eslintme", + "status": "EXACT", + "detected": 156, + "exact": 156, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/external-dep/root", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link-cached-dummy-dep/a/t/u/v/w/x/y/z", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link-cached-dummy-dep/root", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link-dep", + "status": "DECLARED_ONLY", + "detected": 4, + "exact": 0, + "unresolved": 4 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link/a", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link/i", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link/i/j", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link/m", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/external-link/root", + "status": "PARTIAL", + "detected": 12, + "exact": 7, + "unresolved": 5 + }, + { + "path": "workspaces/arborist/test/fixtures/fail-allinstall", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fail-install", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fail-postinstall", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fail-preinstall", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/flow-outdated", + "status": "EXACT", + "detected": 5, + "exact": 5, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/y", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/y/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/fs-parent-dots/x/y/z", + "status": "DECLARED_ONLY", + "detected": 4, + "exact": 0, + "unresolved": 4 + }, + { + "path": "workspaces/arborist/test/fixtures/hidden-lockfile", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/install-types", + "status": "PARTIAL", + "detected": 13, + "exact": 5, + "unresolved": 8 + }, + { + "path": "workspaces/arborist/test/fixtures/install-types-sw-only", + "status": "PARTIAL", + "detected": 32, + "exact": 15, + "unresolved": 17 + }, + { + "path": "workspaces/arborist/test/fixtures/install-types/abbrev-link-target", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-cycle", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-cycle/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-cycle/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-empty", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-empty/target", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-has-dep-with-optional-dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-has-dep-with-optional-dep/a", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-lifecycle-scripts", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-lifecycle-scripts/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-nested", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-nested/once", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep-nested/root", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dep/target", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dev-dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-dev-dep/target", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/link-meta-deps", + "status": "PARTIAL", + "detected": 3, + "exact": 2, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/link-meta-deps-empty", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/links-all-over", + "status": "DECLARED_ONLY", + "detected": 4, + "exact": 0, + "unresolved": 4 + }, + { + "path": "workspaces/arborist/test/fixtures/links-all-over/real", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/malformed-json", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/minimist-git-dep", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/minimist-git-metadep", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/mixedloop", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/mixedmidway", + "status": "DECLARED_ONLY", + "detected": 9, + "exact": 0, + "unresolved": 9 + }, + { + "path": "workspaces/arborist/test/fixtures/mkdirp-pinned", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/old-lock-with-link", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/old-lock-with-link/link-dep", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/old-package-lock", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/old-package-lock-with-bins", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/once-outdated", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-allinstall-fail", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-enotarget", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-install-fail", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-missing", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-postinstall-fail", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-preinstall-fail", + "status": "PARTIAL", + "detected": 2, + "exact": 1, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-dep-tgz-missing", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-engine-omit-test", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-engine-specification", + "status": "PARTIAL", + "detected": 3, + "exact": 2, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-allinstall-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-enotarget", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-install-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-missing", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-postinstall-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-preinstall-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-metadep-tgz-missing", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-ok", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/optional-platform-specification", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/optionalloop", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/optofdev", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/outdated-no-lockfile", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/package-json-shorthands", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/a1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/a2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/b1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/pathological-dep-nesting-cycle/b2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/1", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/2", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/3", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/a/4", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/j/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/j/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/x", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-conflict-infinite-loop/y", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-cycle", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-cycle-nested", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-cycle-nested-with-sw", + "status": "EXACT", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-dep-cycle-with-sw", + "status": "EXACT", + "detected": 3, + "exact": 3, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-engine-omit-test", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/x", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/y/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/y/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/a/z", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b/x", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b/y/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/b/y/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/x", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/y/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/y/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/c/z", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/x", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/y", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/z/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/d/z/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/x", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/y", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/z/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/e/z/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/w", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/x", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/y", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/z/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-eresolve/f/z/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/peer-optional-installs", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/platform-specification", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/pnpm", + "status": "EXACT", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-allinstall-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-enotarget", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-install-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-missing", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-postinstall-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-preinstall-fail", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prod-dep-tgz-missing", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-actual", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-actual-omit-dev", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-dev-bins", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-dev-dep-duplicate", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-lockfile", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-lockfile-omit-dev", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/prune-lockfile-optional-peer", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/rebuild-foreground-scripts", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/root", + "status": "DECLARED_ONLY", + "detected": 4, + "exact": 0, + "unresolved": 4 + }, + { + "path": "workspaces/arborist/test/fixtures/root-bundler", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/root-bundler/child", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/sax", + "status": "PARTIAL", + "detected": 213, + "exact": 9, + "unresolved": 204 + }, + { + "path": "workspaces/arborist/test/fixtures/selflink", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/semver-installed-with-old-package-lock", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/b1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/b2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/sequental-peer-dep-tree-shuffling/c", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/simple", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/symlinked-node-modules/bar", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/symlinked-node-modules/example", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/symlinked-node-modules/linked-node-modules/foo", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/tab-indented-package-json", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/tap-and-flow", + "status": "EXACT", + "detected": 471, + "exact": 471, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/tap-old-lockfile", + "status": "EXACT", + "detected": 413, + "exact": 413, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/tap-react15-collision", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/tap-react15-collision-legacy-sw", + "status": "EXACT", + "detected": 385, + "exact": 385, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/tap-with-yarn-lock", + "status": "DECLARED_ONLY", + "detected": 42, + "exact": 0, + "unresolved": 42 + }, + { + "path": "workspaces/arborist/test/fixtures/tarball-dependencies", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/tarball-dependencies/testing-file-transitive-a", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/tarball-dependencies/testing-file-transitive-a/c", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/tarball-dependencies/testing-file-transitive-b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/has-peer", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/has-peer-optional", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/meta-peer", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/meta-peer-optional", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/nest-peer-optional", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/omit-peer-optional", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/peer/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-conflicted-optional-peer-dep/peer/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-package-with-shrinkwrap", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev/a-peer-dep/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev/a-peer-dep/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-peer-looser-than-dev/devdep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/test-root-matches-metadep", + "status": "PARTIAL", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-root-matches-metadep/x", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/test-root-matches-metadep/y", + "status": "PARTIAL", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-no-lock", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-no-lock/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-no-lock/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-with-lock", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-with-lock/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-asymmetrical-bin-with-lock/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/a/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/a/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/b/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/b/2.0", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundle-dupes/b/2.1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps-2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps-3", + "status": "EXACT", + "detected": 5, + "exact": 5, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps-empty", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps-legacy-bundling", + "status": "EXACT", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps-parent", + "status": "EXACT", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-bundledeps-sw", + "status": "EXACT", + "detected": 5, + "exact": 5, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-dev-optional-flags", + "status": "DECLARED_ONLY", + "detected": 4, + "exact": 0, + "unresolved": 4 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-dev-optional-flags-2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-missing-tgz", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain", + "status": "EXACT", + "detected": 6, + "exact": 6, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/a/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/a/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/a/3", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/b/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/b/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/b/3", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/c/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/c/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/c/3", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/d/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/d/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/d/3", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/e/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/e/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/i/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/i/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ii/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ii/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/j/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/j/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/jj/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/jj/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/k/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/k/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/kk/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/kk/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/l/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/l/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ll/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/ll/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/m/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/m/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/mm/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/mm/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override-dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override-peer", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/override-peer-dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/p/1", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/p/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/q/1", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/q/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/r/1", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/r/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/s/1", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/s/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-a/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-a/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-b/1", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/single-b/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/t/1", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/t/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/3", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/v/4", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/vv/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/vv/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/3", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/w/4", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/3", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/x/4", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/3", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/y/4", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/2", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/3", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-conflict-chain/z/4", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/multi", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/p/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/p/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/q", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/s/1", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/s/2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/simple", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/x", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/y", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-dep-nesting/z", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-deps", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-deps-nested", + "status": "EXACT", + "detected": 8, + "exact": 8, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-deps-overlap", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-peer-deps-unresolvable", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle", + "status": "EXACT", + "detected": 3, + "exact": 3, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle-reified", + "status": "EXACT", + "detected": 3, + "exact": 3, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-rebuild-bundle/parent", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-rebuild-script-env-flags", + "status": "EXACT", + "detected": 4, + "exact": 4, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/a", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/b/1", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/b/2", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/c", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/testing-transitive-conflicted-peer/root", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/two-bundled-deps", + "status": "EXACT", + "detected": 421, + "exact": 421, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/update-exact-version", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace/packages/a", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace/packages/b", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace/packages/c", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace2", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace2/x", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace3", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace3/app", + "status": "DECLARED_ONLY", + "detected": 4, + "exact": 0, + "unresolved": 4 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace3/packages/a", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace3/packages/b", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace3/packages/c", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace4", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace4/packages/bar", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspace4/packages/foo", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-add-new-dep", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-add-new-dep/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions-virtual", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions-virtual/packages/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions-virtual/packages/b", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-conflicting-versions/packages/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-duplicate", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-duplicate/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-duplicate/packages/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm-virtual", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm-virtual/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-ignore-nm/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-link-bin", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-link-bin/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-link-bin/packages/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-need-update", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-need-update/a", + "status": "EXACT", + "detected": 3, + "exact": 3, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-need-update/b", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-non-simplistic", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-non-simplistic/a", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-not-root", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-not-root/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-not-root/packages/b", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-not-root/packages/c", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-peer-ranges", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-peer-ranges/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-peer-ranges/packages/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking-virtual", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking-virtual/packages/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking-virtual/packages/abbrev", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-prefer-linking/packages/abbrev", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-root-linked", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-root-linked/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-root-linked/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-scoped-pkg", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-scoped-pkg/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-scoped-pkg/packages/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual/packages/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual/packages/b", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps-virtual/packages/c", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps/packages/b", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-shared-deps/packages/c", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-simple", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-simple-virtual", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-simple-virtual/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-simple-virtual/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-simple/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-simple/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link-virtual", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link-virtual/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-top-level-link/packages/a", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps-virtual", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps-virtual/packages/a", + "status": "EXACT", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-transitive-deps/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied-virtual", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied-virtual/packages/a", + "status": "EXACT", + "detected": 1, + "exact": 1, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied-virtual/packages/abbrev", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-version-unsatisfied/packages/abbrev", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/d", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/packages/a", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/packages/b", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-files-spec/packages/c", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-overrides", + "status": "UNSUPPORTED", + "detected": 0, + "exact": 0, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/workspaces-with-overrides/ws", + "status": "PARTIAL", + "detected": 2, + "exact": 2, + "unresolved": 0 + }, + { + "path": "workspaces/arborist/test/fixtures/yargs", + "status": "PARTIAL", + "detected": 28, + "exact": 1, + "unresolved": 27 + }, + { + "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp-file-dep", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp-file-dep/mkdirp", + "status": "DECLARED_ONLY", + "detected": 2, + "exact": 0, + "unresolved": 2 + }, + { + "path": "workspaces/arborist/test/fixtures/yarn-lock-mkdirp-no-resolved", + "status": "DECLARED_ONLY", + "detected": 1, + "exact": 0, + "unresolved": 1 + }, + { + "path": "workspaces/arborist/test/fixtures/yarn-stuff", + "status": "DECLARED_ONLY", + "detected": 9, + "exact": 0, + "unresolved": 9 + }, + { + "path": "workspaces/config", + "status": "PARTIAL", + "detected": 966, + "exact": 959, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmaccess", + "status": "PARTIAL", + "detected": 965, + "exact": 958, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmdiff", + "status": "PARTIAL", + "detected": 965, + "exact": 958, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmexec", + "status": "PARTIAL", + "detected": 971, + "exact": 964, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmfund", + "status": "PARTIAL", + "detected": 964, + "exact": 957, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmorg", + "status": "PARTIAL", + "detected": 974, + "exact": 967, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmpack", + "status": "PARTIAL", + "detected": 974, + "exact": 967, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmpublish", + "status": "PARTIAL", + "detected": 967, + "exact": 960, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmsearch", + "status": "PARTIAL", + "detected": 973, + "exact": 966, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmteam", + "status": "PARTIAL", + "detected": 974, + "exact": 967, + "unresolved": 7 + }, + { + "path": "workspaces/libnpmversion", + "status": "PARTIAL", + "detected": 966, + "exact": 959, + "unresolved": 7 + } + ], + "warnings": [ + "@npmcli/mock-globals: Could not determine the installed dependency coveralls used by tap.", + "@npmcli/mock-globals: Could not determine the installed dependency flow-remove-types used by tap.", + "@npmcli/mock-globals: Could not determine the installed dependency ts-node used by tap.", + "@npmcli/mock-globals: Could not determine the installed dependency babel-plugin-macros used by dedent.", + "@npmcli/mock-globals: Could not determine the installed dependency bufferutil used by ws.", + "@npmcli/mock-globals: Could not determine the installed dependency utf-8-validate used by ws.", + "@npmcli/mock-globals: Lockfile version for conventional-commits-filter does not satisfy its declared range ^5.0.0; regenerate the lockfile.", + "@npmcli/mock-globals: Could not determine the installed dependency kerberos used by proxy-agent-negotiate.", + "@npmcli/mock-globals: 7 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching." + ] + }, + { + "repository": "pallets/flask", + "scanId": "0408f72a-ccc5-462e-a04f-f83f9ac4852c", + "commit": "d73fa1cdcbd8b1465c151db8924ba58b1dd14e35", + "ref": "main", + "status": "completed", + "projects": 4, + "files": 6, + "allProjectPreview": { + "dependencies": 114, + "exact": 104, + "unresolved": 10 + }, + "selected": [ + "." + ], + "dependencies": 84, + "exactVersions": 83, + "unresolved": 1, + "graphNodes": 85, + "checked": 83, + "coverage": [ + { + "path": ".", + "status": "PARTIAL", + "detected": 84, + "exact": 83, + "unresolved": 1 + }, + { + "path": "examples/celery", + "status": "PARTIAL", + "detected": 24, + "exact": 21, + "unresolved": 3 + }, + { + "path": "examples/javascript", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + }, + { + "path": "examples/tutorial", + "status": "DECLARED_ONLY", + "detected": 3, + "exact": 0, + "unresolved": 3 + } + ], + "warnings": [ + "flask: Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies.", + "flask: 1 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching." + ] + }, + { + "repository": "spring-projects/spring-petclinic", + "scanId": "100058f2-d987-444e-a327-c12a6de34a11", + "commit": "818c4136ea971c21674525f9053de0d9c7ad8cfe", + "ref": "main", + "status": "completed", + "projects": 1, + "files": 1, + "allProjectPreview": { + "dependencies": 28, + "exact": 3, + "unresolved": 25 + }, + "selected": [ + "." + ], + "dependencies": 28, + "exactVersions": 3, + "unresolved": 25, + "graphNodes": 29, + "checked": 3, + "coverage": [ + { + "path": ".", + "status": "PARTIAL", + "detected": 28, + "exact": 3, + "unresolved": 25 + } + ], + "warnings": [ + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: javax.cache:cache-api. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: jakarta.xml.bind:jakarta.xml.bind-api. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: com.h2database:h2. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: com.github.ben-manes.caffeine:caffeine. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: com.mysql:mysql-connector-j. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.postgresql:postgresql. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-devtools. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-testcontainers. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-docker-compose. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-junit-jupiter. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-mysql. Export dependency-tree.json for full resolution.", + "spring-petclinic: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json.", + "spring-petclinic: 25 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching." + ] + }, + { + "repository": "GoogleCloudPlatform/microservices-demo", + "scanId": "305a680f-64eb-431b-a03c-6d026be14b1e", + "commit": "b9a978db9e01f4ad3dca9494a22cb9edc17548fe", + "ref": "main", + "status": "completed", + "projects": 6, + "files": 8, + "allProjectPreview": { + "dependencies": 725, + "exact": 723, + "unresolved": 2 + }, + "selected": [ + "src/currencyservice", + "src/emailservice", + "src/loadgenerator", + "src/paymentservice", + "src/recommendationservice", + "src/shoppingassistantservice" + ], + "dependencies": 725, + "exactVersions": 394, + "unresolved": 2, + "graphNodes": 731, + "checked": 394, + "coverage": [ + { + "path": "src/currencyservice", + "status": "PARTIAL", + "detected": 285, + "exact": 284, + "unresolved": 1 + }, + { + "path": "src/emailservice", + "status": "PARTIAL", + "detected": 35, + "exact": 35, + "unresolved": 0 + }, + { + "path": "src/loadgenerator", + "status": "PARTIAL", + "detected": 39, + "exact": 39, + "unresolved": 0 + }, + { + "path": "src/paymentservice", + "status": "PARTIAL", + "detected": 256, + "exact": 255, + "unresolved": 1 + }, + { + "path": "src/recommendationservice", + "status": "PARTIAL", + "detected": 31, + "exact": 31, + "unresolved": 0 + }, + { + "path": "src/shoppingassistantservice", + "status": "PARTIAL", + "detected": 79, + "exact": 79, + "unresolved": 0 + } + ], + "warnings": [ + "grpc-currency-service: Could not determine the installed dependency encoding used by node-fetch.", + "grpc-currency-service: 1 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching.", + "emailservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "loadgenerator: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "paymentservice: Could not determine the installed dependency encoding used by node-fetch.", + "paymentservice: 1 dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching.", + "recommendationservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "shoppingassistantservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound." + ] + }, + { + "repository": "octocat/Hello-World", + "scanId": "96295882-c914-4f2a-87e5-e2b626abb5a7", + "commit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "ref": "master", + "status": "mapped", + "projects": 0, + "files": 0, + "allProjectPreview": { + "dependencies": 0, + "exact": 0, + "unresolved": 0 + }, + "selected": [], + "dependencies": 0, + "exactVersions": 0, + "unresolved": 0, + "graphNodes": 0, + "checked": 0, + "coverage": [], + "warnings": [] + } + ] +} \ No newline at end of file diff --git a/docs/verification/live-browser.json b/docs/verification/live-browser.json new file mode 100644 index 0000000..6601352 --- /dev/null +++ b/docs/verification/live-browser.json @@ -0,0 +1,7 @@ +{ + "verifiedAt": "2026-09-13T21:25:47.885Z", + "repository": "GoogleCloudPlatform/microservices-demo", + "journey": "GitHub URL → Repository Map → Analyze → source-backed finding → Trace Ripple", + "impact": "Ripple traced: affects 2 of 6 projects", + "browserErrors": [] +} \ No newline at end of file diff --git a/docs/verification/live-repositories.json b/docs/verification/live-repositories.json new file mode 100644 index 0000000..ecb8425 --- /dev/null +++ b/docs/verification/live-repositories.json @@ -0,0 +1,388 @@ +{ + "testedAt": "2026-09-13T21:38:00.584Z", + "results": [ + { + "repository": "expressjs/express", + "ref": "master", + "commit": "3ce6d0eb86e9d93529ff3191c6bb5db8ce6e72c8", + "scanId": "5115fa31-86c4-4507-9ad0-613c805f64cf", + "projects": 1, + "files": 1, + "selected": [ + { + "name": "express", + "path": ".", + "ecosystem": "npm" + } + ], + "status": "completed", + "packageVersions": 8, + "checked": 8, + "advisories": [], + "warnings": [ + "express: No supported resolved lockfile is available. Only exact direct pins are shown; indirect relationships are unknown.", + "express: accepts: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: body-parser: ^2.3.0 is not an exact installed version; omitted from vulnerability checks.", + "express: content-disposition: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.", + "express: content-type: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: cookie: ^0.7.1 is not an exact installed version; omitted from vulnerability checks.", + "express: cookie-signature: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.", + "express: debug: ^4.4.0 is not an exact installed version; omitted from vulnerability checks.", + "express: depd: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: encodeurl: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: escape-html: ^1.0.3 is not an exact installed version; omitted from vulnerability checks.", + "express: etag: ^1.8.1 is not an exact installed version; omitted from vulnerability checks.", + "express: finalhandler: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: fresh: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: http-errors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: merge-descriptors: ^2.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: mime-types: ^3.0.0 is not an exact installed version; omitted from vulnerability checks.", + "express: on-finished: ^2.4.1 is not an exact installed version; omitted from vulnerability checks.", + "express: once: ^1.4.0 is not an exact installed version; omitted from vulnerability checks.", + "express: parseurl: ^1.3.3 is not an exact installed version; omitted from vulnerability checks.", + "express: proxy-addr: ^2.0.7 is not an exact installed version; omitted from vulnerability checks.", + "express: qs: ^6.15.2 is not an exact installed version; omitted from vulnerability checks.", + "express: range-parser: ^1.2.1 is not an exact installed version; omitted from vulnerability checks.", + "express: router: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.", + "express: send: ^1.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: serve-static: ^2.2.0 is not an exact installed version; omitted from vulnerability checks.", + "express: statuses: ^2.0.1 is not an exact installed version; omitted from vulnerability checks.", + "express: type-is: ^2.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: vary: ^1.1.2 is not an exact installed version; omitted from vulnerability checks.", + "express: connect-redis: ^8.0.1 is not an exact installed version; omitted from vulnerability checks.", + "express: ejs: ^3.1.10 is not an exact installed version; omitted from vulnerability checks.", + "express: express-session: ^1.18.1 is not an exact installed version; omitted from vulnerability checks.", + "express: marked: ^15.0.3 is not an exact installed version; omitted from vulnerability checks.", + "express: mocha: ^11.7.5 is not an exact installed version; omitted from vulnerability checks.", + "express: nyc: ^17.1.0 is not an exact installed version; omitted from vulnerability checks.", + "express: supertest: ^6.3.0 is not an exact installed version; omitted from vulnerability checks.", + "express: vhost: ~3.0.2 is not an exact installed version; omitted from vulnerability checks." + ], + "durationSeconds": 3 + }, + { + "repository": "jagpreetsingh02/MIT", + "ref": "main", + "commit": "c75031ca7546706d042c585852d18b29c8f9015e", + "scanId": "85389187-40d3-41e1-a5c4-a5b756c00a95", + "projects": 1, + "files": 2, + "selected": [ + { + "name": "rippleguard", + "path": ".", + "ecosystem": "npm" + } + ], + "status": "completed", + "packageVersions": 409, + "checked": 409, + "advisories": [], + "warnings": [ + "rippleguard: Could not determine the installed dependency @emnapi/core used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @emnapi/runtime used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @emnapi/wasi-threads used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @napi-rs/wasm-runtime used by @tailwindcss/oxide-wasm32-wasi.", + "rippleguard: Could not determine the installed dependency @tybys/wasm-util used by @tailwindcss/oxide-wasm32-wasi." + ], + "durationSeconds": 31 + }, + { + "repository": "npm/cli", + "ref": "latest", + "commit": "c9876d7ea7150b0702e4151210b9fa1a8dbc7fbf", + "scanId": "b4b6f48a-4bbc-4fb3-a23a-f7d3810b1fd3", + "projects": 478, + "files": 575, + "selected": [ + { + "name": "@npmcli/mock-globals", + "path": "mock-globals", + "ecosystem": "npm" + } + ], + "status": "completed", + "packageVersions": 797, + "checked": 797, + "advisories": [ + "GHSA-2883-xcg3-v3hh", + "GHSA-5p4m-2wfm-xmqj", + "GHSA-27p8-2357-5qqv", + "GHSA-4w3w-2rp5-g8jm", + "GHSA-6gmq-8vp8-gcm6", + "GHSA-6h8r-xr42-gp59", + "GHSA-8344-3jmq-59r6", + "GHSA-93r5-fhx6-vmg9", + "GHSA-965w-775f-mr7g", + "GHSA-c7q8-3ch8-vqpv", + "GHSA-w2rr-34g9-rvrj", + "GHSA-x4fp-j954-r2f4", + "GHSA-4x5r-pxfx-6jf8", + "GHSA-23c5-xmqv-rm74", + "GHSA-3ppc-4f35-3m26", + "GHSA-7r86-cg39-jmmj", + "GHSA-f23m-r3pf-42rh", + "GHSA-r5fr-rjxr-66jc", + "GHSA-3h5v-q93c-6h6q", + "GHSA-96hv-2xvq-fx4p", + "GHSA-w5hq-g745-h8pq", + "GHSA-48c2-rrv3-qjmp", + "GHSA-968p-4wvh-cqc8", + "GHSA-3jxr-9vmj-r5cp", + "GHSA-f886-m6hf-6m8v", + "GHSA-mh99-v99m-4gvg", + "GHSA-rgw5-rvv9-x895", + "GHSA-v6h2-p8h4-qcjw", + "GHSA-395f-4hp3-45gv", + "GHSA-w7jw-789q-3m8p", + "GHSA-73wf-gq98-2v4g", + "GHSA-c83g-rgw3-j3cx", + "GHSA-5jgf-p345-68v8", + "GHSA-7p8r-x3mc-p8w7", + "GHSA-f65p-4m7j-42xc", + "GHSA-fph4-wmhf-6fwf", + "GHSA-jqff-g426-hqxp", + "GHSA-v2hh-gcrm-f6hx", + "GHSA-w5vr-8v7q-w6rv" + ], + "warnings": [ + "@npmcli/mock-globals: Lockfile version for conventional-commits-filter does not satisfy its declared range ^5.0.0; regenerate the lockfile." + ], + "durationSeconds": 67 + }, + { + "repository": "pallets/flask", + "ref": "main", + "commit": "d73fa1cdcbd8b1465c151db8924ba58b1dd14e35", + "scanId": "215e35c3-010a-4d2d-ae18-1af9bb659155", + "projects": 4, + "files": 6, + "selected": [ + { + "name": "flask", + "path": ".", + "ecosystem": "pypi" + } + ], + "status": "completed", + "packageVersions": 83, + "checked": 83, + "advisories": [ + "GHSA-g6cj-pr64-35w5" + ], + "warnings": [ + "flask: Python lockfile versions and recorded package edges are shown. Root membership, environment markers and optional groups are not fully resolved; dashed root links mean listed packages, not proven direct dependencies." + ], + "durationSeconds": 10 + }, + { + "repository": "spring-projects/spring-petclinic", + "ref": "main", + "commit": "818c4136ea971c21674525f9053de0d9c7ad8cfe", + "scanId": "6b3d9852-f6b9-4368-aa8d-519cf70c8ed2", + "projects": 1, + "files": 1, + "selected": [ + { + "name": "spring-petclinic", + "path": ".", + "ecosystem": "maven" + } + ], + "status": "completed", + "packageVersions": 3, + "checked": 3, + "advisories": [], + "warnings": [ + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: javax.cache:cache-api. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: jakarta.xml.bind:jakarta.xml.bind-api. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: com.h2database:h2. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: com.github.ben-manes.caffeine:caffeine. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: com.mysql:mysql-connector-j. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.postgresql:postgresql. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-devtools. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-data-jpa-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-restclient-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-thymeleaf-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-validation-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-webmvc-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-actuator-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-testcontainers. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-docker-compose. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.springframework.boot:spring-boot-starter-cache-test. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-junit-jupiter. Export dependency-tree.json for full resolution.", + "spring-petclinic: Unresolved managed version: org.testcontainers:testcontainers-mysql. Export dependency-tree.json for full resolution.", + "spring-petclinic: POM import includes exact direct dependencies only. Parent/BOM mediation, profiles and transitives require dependency-tree.json." + ], + "durationSeconds": 3 + }, + { + "repository": "GoogleCloudPlatform/microservices-demo", + "ref": "main", + "commit": "b9a978db9e01f4ad3dca9494a22cb9edc17548fe", + "scanId": "2be98337-2417-4c7a-a411-84be00439d55", + "projects": 6, + "files": 8, + "selected": [ + { + "name": "grpc-currency-service", + "path": "src/currencyservice", + "ecosystem": "npm" + }, + { + "name": "emailservice", + "path": "src/emailservice", + "ecosystem": "pypi" + }, + { + "name": "loadgenerator", + "path": "src/loadgenerator", + "ecosystem": "pypi" + }, + { + "name": "paymentservice", + "path": "src/paymentservice", + "ecosystem": "npm" + }, + { + "name": "recommendationservice", + "path": "src/recommendationservice", + "ecosystem": "pypi" + } + ], + "status": "completed", + "packageVersions": 333, + "checked": 333, + "advisories": [ + "GHSA-f38q-mgvj-vph7", + "GHSA-j3f2-48v5-ccww", + "GHSA-wcpc-wj8m-hjx6", + "GHSA-w5hq-g745-h8pq", + "GHSA-8988-4f7v-96qf", + "GHSA-2pr8-phx7-x9h3", + "GHSA-66ff-xgx4-vchm", + "GHSA-685m-2w69-288q", + "GHSA-75px-5xx7-5xc7", + "GHSA-fx83-v9x8-x52w", + "GHSA-jggg-4jg4-v7c6", + "GHSA-jvwf-75h9-cwgg", + "GHSA-q6x5-8v7m-xcrf", + "GHSA-xq3m-2v4x-88gg", + "GHSA-hmw2-7cc7-3qxx", + "GHSA-vpq2-c234-7xj6", + "GHSA-65pc-fj4g-8rjx", + "GHSA-8ppf-4f7h-5ppj", + "GHSA-hm4w-wwcw-mr6r", + "GHSA-jr27-m4p2-rc6r", + "GHSA-m4p7-r5rc-7g4j", + "GHSA-gc5v-m9x4-r6x2", + "GHSA-mf9v-mfxr-j63j", + "GHSA-qccp-gfcp-xxvc", + "PYSEC-2026-2132", + "GHSA-6v7p-g79w-8964", + "GHSA-5239-wwwm-4pmq", + "GHSA-6w46-j5rx-g56g", + "GHSA-cgwc-pv48-fhj5", + "GHSA-m9gh-vj53-gvh9", + "GHSA-5w7q-77mv-v69f", + "GHSA-g8c6-8fjj-2r4m" + ], + "warnings": [ + "emailservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "loadgenerator: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "recommendationservice: Flat requirements snapshot: all pins are attached to the application. Transitive ancestry and environment markers are not resolved.", + "Some advisories have no numeric CVSS in this snapshot. Their severity is shown as unscored; priority may be a lower bound." + ], + "durationSeconds": 39 + }, + { + "repository": "octocat/Hello-World", + "ref": "master", + "commit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", + "scanId": "04fd98d5-1834-4673-ad2e-cbd265e270bd", + "projects": 0, + "files": 0, + "selected": [], + "status": "mapped", + "packageVersions": 0, + "checked": 0, + "advisories": [], + "warnings": [], + "durationSeconds": 2 + }, + { + "repository": "google/osv-scanner", + "ref": "main", + "commit": "1f87b5cb9781a03a9b8b50dfc1eac1b2056c79d5", + "scanId": "367ba078-45f5-4504-a848-aeb8ebceb9b8", + "projects": 24, + "files": 30, + "selected": [ + { + "name": "in-place-npm", + "path": "cmd/osv-scanner/fix/testdata/in-place-npm", + "ecosystem": "npm" + } + ], + "status": "completed", + "packageVersions": 106, + "checked": 106, + "advisories": [ + "GHSA-c6rq-rjc2-86v2", + "GHSA-g74r-ffvr-5q9f", + "GHSA-p8p7-x288-28g6", + "GHSA-c2qf-rxjj-qqgw", + "GHSA-43f8-2h32-f4cj", + "GHSA-fjxv-7rqg-78g4", + "GHSA-hmw2-7cc7-3qxx", + "GHSA-4mjr-xmp4-gh2g", + "GHSA-6rw7-vpxm-498p", + "GHSA-72xf-g2v4-qvf3", + "GHSA-w5hq-g745-h8pq", + "GHSA-2g4f-4pwh-qvx6", + "GHSA-23c5-xmqv-rm74", + "GHSA-3ppc-4f35-3m26", + "GHSA-7r86-cg39-jmmj", + "GHSA-3jxr-9vmj-r5cp", + "GHSA-f886-m6hf-6m8v", + "GHSA-mh99-v99m-4gvg", + "GHSA-rgw5-rvv9-x895", + "GHSA-v6h2-p8h4-qcjw" + ], + "warnings": [ + "in-place-npm: Lockfile version for readable-stream does not satisfy its declared range ~2.0.0; regenerate the lockfile." + ], + "durationSeconds": 14 + }, + { + "repository": "jagpreetsingh02/SWASTRA-OCR", + "ref": "main", + "commit": "9665f4579de5afe728ced1aaa92fdcf5f0c6428c", + "scanId": "8c7a4336-5b04-481e-9064-370b862fa29c", + "projects": 1, + "files": 1, + "selected": [ + { + "name": "SWASTRA-OCR", + "path": ".", + "ecosystem": "pypi" + } + ], + "status": "completed", + "packageVersions": 0, + "checked": 0, + "advisories": [], + "warnings": [ + "SWASTRA-OCR: No exact resolved Python snapshot is available. Unpinned dependencies are not checked; use uv.lock, poetry.lock, Pipfile.lock or pinned requirements." + ], + "durationSeconds": 2 + } + ] +} \ No newline at end of file diff --git a/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/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/scripts/verify-controlled-demo.ts b/scripts/verify-controlled-demo.ts new file mode 100644 index 0000000..b4fbaaa --- /dev/null +++ b/scripts/verify-controlled-demo.ts @@ -0,0 +1,65 @@ +import { readFile, readdir, writeFile, mkdir } from "node:fs/promises"; +import { discoverProjects, type SourceFile } from "../src/server/discovery.js"; +import { resolveProject } from "../src/server/resolve-project.js"; +import { finalize, risks, simulate } from "../src/server/graph.js"; +import { enrichGraph } from "../src/server/connectors.js"; +import { Transport } from "../src/server/transport.js"; +import { Store } from "../src/server/store.js"; +const names = [ + "package.json", + "package-lock.json", + "apps/storefront/package.json", + "apps/checkout/package.json", + "apps/admin/package.json", + "packages/shared-http/package.json", +]; +const files: SourceFile[] = await Promise.all( + names.map(async (filename) => ({ + filename, + content: await readFile("demo-repository/" + filename, "utf8"), + provenance: { + source: "Controlled demo lockfile", + url: "", + retrievedAt: new Date().toISOString(), + }, + })), +); +const map = discoverProjects( + names.map((path) => ({ path, sha: "" })), + "demo/rippleguard-recording", + "main", + "local-prepared", +); +const selected = map.projects.filter((p) => p.path.startsWith("apps/")); +const graphs = selected.map((p) => resolveProject(p, files)); +const graph = finalize({ + nodes: graphs.flatMap((g) => g.nodes), + edges: graphs.flatMap((g) => g.edges), + warnings: graphs.flatMap((g) => g.warnings), +}); +const store = new Store("data/controlled-demo-verification.sqlite"); +try { + await enrichGraph(graph, new Transport(store), () => {}); + const ranked = risks(graph); + const top = ranked[0]; + const ripple = simulate(graph, top.nodeId); + const report = { + verifiedAt: new Date().toISOString(), + projects: selected.map((p) => p.path), + packageVersions: ranked.length, + checked: graph.nodes.filter((n) => n.coverage === "checked").length, + findings: ranked.filter((r) => r.advisories > 0).length, + top: { name: graph.nodes.find((n) => n.id === top.nodeId)?.name, ...top }, + ripple: { projects: ripple.services.length, paths: ripple.pathCount }, + warnings: graph.warnings, + advisories: graph.nodes + .filter((n) => n.advisories.length) + .map((n) => ({ name: n.name, version: n.version, advisories: n.advisories })), + }; + await mkdir("docs/verification", { recursive: true }); + await writeFile("docs/verification/controlled-demo.json", JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ ...report, advisories: report.advisories.length })); + if (!report.findings || ripple.services.length !== 3) process.exitCode = 1; +} finally { + store.close(); +} diff --git a/scripts/verify-ingestion.ts b/scripts/verify-ingestion.ts new file mode 100644 index 0000000..bb5dbfb --- /dev/null +++ b/scripts/verify-ingestion.ts @@ -0,0 +1,147 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { chromium, expect } from "@playwright/test"; +const base = "http://127.0.0.1:3000"; +const repos = process.argv.slice(2).length + ? process.argv.slice(2) + : [ + "google/osv-scanner", + "jagpreetsingh02/SWASTRA-OCR", + "expressjs/express", + "jagpreetsingh02/MIT", + "npm/cli", + "pallets/flask", + "spring-projects/spring-petclinic", + "GoogleCloudPlatform/microservices-demo", + "octocat/Hello-World", + ]; +async function api(path: string, body?: unknown) { + const r = await fetch(base + "/api/v1" + path, { + method: body ? "POST" : "GET", + headers: { "content-type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error); + return j; +} +async function wait(id: string, status: string) { + for (let i = 0; i < 900; i++) { + const s = await api("/scans/" + id); + if (s.status === "failed") throw new Error(s.error); + if (s.status === status) return s; + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error("Scan timeout"); +} +const results: any[] = []; +for (const repository of repos) { + try { + const created = await api("/scans", { mode: "github", repository, ref: "HEAD" }); + let scan = await wait(created.id, "mapped"); + const map = scan.repositoryMap; + console.log(repository + ": mapped " + map.projects.length + " projects"); + const preview = { + dependencies: map.projects.reduce((s: number, p: any) => s + (p.packageCount || 0), 0), + exact: map.projects.reduce((s: number, p: any) => s + (p.exactCount || 0), 0), + unresolved: map.projects.reduce((s: number, p: any) => s + (p.unresolvedCount || 0), 0), + }; + const selected = + repository === "google/osv-scanner" || repository === "GoogleCloudPlatform/microservices-demo" + ? map.projects + : map.projects.filter((p: any) => p.selected && p.role !== "workspace").slice(0, 1); + if (selected.length) { + await api("/scans/" + scan.id + "/analyze", { + projects: selected.map((p: any) => ({ id: p.id, name: p.name })), + }); + scan = await wait(scan.id, "completed"); + } + const packages = (scan.graph?.nodes || []).filter((n: any) => n.kind === "package"); + const exact = [ + ...new Map( + packages.filter((n: any) => n.versionStatus !== "unresolved").map((n: any) => [n.purl, n]), + ).values(), + ]; + const unresolved = packages.filter((n: any) => n.versionStatus === "unresolved"); + if (unresolved.some((n: any) => n.coverage === "checked" || n.advisories.length || n.version)) + throw new Error("Unresolved dependency was falsely matched"); + const result = { + repository, + scanId: scan.id, + commit: scan.commit, + ref: scan.ref, + status: scan.status, + projects: map.projects.length, + files: map.fileCount, + allProjectPreview: preview, + selected: selected.map((p: any) => p.path), + dependencies: packages.length, + exactVersions: exact.length, + unresolved: unresolved.length, + graphNodes: scan.graph?.nodes.length || 0, + checked: exact.filter((n) => n.coverage === "checked").length, + coverage: scan.repositoryMap.projects.map((p: any) => ({ + path: p.path, + status: p.ingestionStatus, + detected: p.packageCount, + exact: p.exactCount, + unresolved: p.unresolvedCount, + })), + warnings: scan.graph?.warnings || map.warnings, + }; + results.push(result); + console.log( + JSON.stringify({ ...result, coverage: undefined, warnings: result.warnings.length }), + ); + } catch (error) { + results.push({ repository, error: (error as Error).message }); + console.error(repository + ": " + (error as Error).message); + process.exitCode = 1; + } + await mkdir("docs/verification", { recursive: true }); + await writeFile( + "docs/verification/ingestion-repositories.json", + JSON.stringify({ verifiedAt: new Date().toISOString(), results }, null, 2), + ); +} +if (process.env.VERIFY_BROWSER === "1") { + const browser = await chromium.launch(); + try { + const checks: any[] = []; + for (const repository of ["google/osv-scanner", "jagpreetsingh02/SWASTRA-OCR"]) { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(base + "/app#new"); + await page.getByLabel("Repository URL").fill("https://github.com/" + repository); + await page.getByRole("button", { name: "Map repository", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Your repository, understood." })).toBeVisible( + { timeout: 180000 }, + ); + if (repository.includes("SWASTRA")) + await expect(page.getByText("Detected dependencies: 14", { exact: false })).toBeVisible(); + await page.getByRole("button", { name: "Analyze repository", exact: true }).click(); + await expect(page.getByRole("region", { name: "Analysis summary" })).toBeVisible({ + timeout: 300000, + }); + if (repository.includes("SWASTRA")) { + await expect(page.getByRole("status")).toContainText("14 dependencies were discovered"); + await page.getByRole("button", { name: "View all dependencies" }).click(); + await page.getByRole("textbox", { name: "Search dependencies" }).fill("torch"); + await expect(page.locator("tbody")).toContainText(">=2.5"); + await expect(page.locator("tbody")).toContainText("Not checked"); + } + expect(errors).toEqual([]); + checks.push({ repository, errors, passed: true }); + await page.screenshot({ + path: "test-results/ingestion-" + repository.split("/")[1] + ".png", + }); + await page.close(); + } + await writeFile( + "docs/verification/ingestion-browser.json", + JSON.stringify({ verifiedAt: new Date().toISOString(), checks }, null, 2), + ); + } finally { + await browser.close(); + } +} diff --git a/scripts/verify-live-browser.ts b/scripts/verify-live-browser.ts new file mode 100644 index 0000000..b48454f --- /dev/null +++ b/scripts/verify-live-browser.ts @@ -0,0 +1,40 @@ +import { chromium, expect } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +const browser = await chromium.launch(process.env.PW_CHROME ? { channel: "chrome" } : {}); +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto((process.env.VERIFY_BASE || "http://127.0.0.1:3000") + "/app#new"); + await page + .getByLabel("Repository URL") + .fill("https://github.com/GoogleCloudPlatform/microservices-demo"); + await page.getByRole("button", { name: "Map repository", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Your repository, understood." })).toBeVisible({ + timeout: 120000, + }); + await page.getByRole("button", { name: "Analyze repository", exact: true }).click(); + await expect(page.getByRole("heading", { name: /Start here:/ })).toBeVisible({ timeout: 300000 }); + await page.getByRole("button", { name: "Trace Ripple", exact: true }).first().click(); + const impact = page.getByRole("heading", { name: /Ripple traced: affects/ }); + await expect(impact).toBeVisible(); + await expect(page.locator('.package-details a[href^="https://osv.dev/"]').first()).toBeVisible(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + ).toBeTruthy(); + expect(errors).toEqual([]); + await mkdir("test-results", { recursive: true }); + await page.screenshot({ path: "test-results/live-github-ripple.png" }); + const report = { + verifiedAt: new Date().toISOString(), + repository: "GoogleCloudPlatform/microservices-demo", + journey: "GitHub URL → Repository Map → Analyze → source-backed finding → Trace Ripple", + impact: await impact.innerText(), + browserErrors: errors, + }; + await mkdir("docs/verification", { recursive: true }); + await writeFile("docs/verification/live-browser.json", JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report)); +} finally { + await browser.close(); +} diff --git a/scripts/verify-real-repos.ts b/scripts/verify-real-repos.ts new file mode 100644 index 0000000..4919698 --- /dev/null +++ b/scripts/verify-real-repos.ts @@ -0,0 +1,102 @@ +import { writeFile, mkdir } from "node:fs/promises"; +const base = process.env.VERIFY_BASE || "http://127.0.0.1:3000/api/v1"; +const repositories = process.argv.slice(2).length + ? process.argv.slice(2) + : [ + "expressjs/express", + "jagpreetsingh02/MIT", + "npm/cli", + "pallets/flask", + "spring-projects/spring-petclinic", + "GoogleCloudPlatform/microservices-demo", + "octocat/Hello-World", + ]; +async function request(path: string, body?: unknown) { + const response = await fetch(base + path, { + method: body ? "POST" : "GET", + headers: body ? { "content-type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error); + return data; +} +async function wait(id: string, statuses: string[]) { + const started = Date.now(); + let scan; + while (Date.now() - started < 600000) { + scan = await request("/scans/" + id); + if (statuses.includes(scan.status) || scan.status === "failed") return scan; + await new Promise((r) => setTimeout(r, 1700)); + } + throw new Error("Timed out waiting for " + id); +} +let results: any[] = []; +try { + const { readFile } = await import("node:fs/promises"); + results = JSON.parse( + await readFile("docs/verification/live-repositories.json", "utf8"), + ).results.filter((r: any) => !repositories.includes(r.repository)); +} catch {} +for (const repository of repositories) { + const started = Date.now(); + try { + const created = await request("/scans", { mode: "github", repository, ref: "HEAD" }); + let scan = await wait(created.id, ["mapped"]); + if (scan.status === "failed") throw new Error(scan.error); + console.log( + repository + + ": mapped " + + scan.repositoryMap.projects.length + + " projects / " + + scan.repositoryMap.fileCount + + " files", + ); + const candidates = scan.repositoryMap.projects.filter((p: any) => p.selected); + const selected = + repository === "GoogleCloudPlatform/microservices-demo" + ? candidates.filter((p: any) => ["pypi", "maven", "npm"].includes(p.ecosystem)).slice(0, 5) + : candidates.filter((p: any) => p.role !== "workspace").slice(0, 1); + if (selected.length) { + await request("/scans/" + scan.id + "/analyze", { + projects: selected.map((p: any) => ({ id: p.id, name: p.name })), + }); + scan = await wait(scan.id, ["completed"]); + } + const packages = scan.graph?.nodes.filter((n: any) => n.kind === "package") || []; + const unique = [...new Map(packages.map((n: any) => [n.purl, n])).values()]; + const record = { + repository, + ref: scan.ref, + commit: scan.commit, + scanId: scan.id, + projects: scan.repositoryMap.projects.length, + files: scan.repositoryMap.fileCount, + selected: selected.map((p: any) => ({ name: p.name, path: p.path, ecosystem: p.ecosystem })), + status: scan.status, + packageVersions: unique.length, + checked: unique.filter((n) => n.coverage === "checked").length, + advisories: [...new Set(unique.flatMap((n) => n.advisories.map((a: any) => a.id)))], + warnings: scan.graph?.warnings || scan.repositoryMap.warnings, + error: scan.error, + durationSeconds: Math.round((Date.now() - started) / 1000), + }; + results.push(record); + console.log( + JSON.stringify({ + ...record, + warnings: record.warnings.length, + advisories: record.advisories.length, + }), + ); + } catch (error) { + results.push({ repository, error: (error as Error).message }); + console.log(repository + ": " + (error as Error).message); + } + await mkdir("docs/verification", { recursive: true }); + await writeFile( + "docs/verification/live-repositories.json", + JSON.stringify({ testedAt: new Date().toISOString(), results }, null, 2), + ); +} +if (results.some((result) => result.error || result.status === "failed")) process.exitCode = 1; diff --git a/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..4a4f66e 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: 300, + timeWindow: "1 minute", + allowList: (request) => !request.url.startsWith("/api/"), }); - 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.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." }); + } + }); + 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..fc27c7a 100644 --- a/src/server/connectors.ts +++ b/src/server/connectors.ts @@ -1,164 +1,552 @@ -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" && n.versionStatus !== "unresolved" && !!n.version, + )) + groups.set(n.purl, [...(groups.get(n.purl) || []), n]); + const packages = [...groups.values()].map((g) => g[0]); + const configured = Number(process.env.MAX_ENRICH_PACKAGES || 2000); + 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/declarations.ts b/src/server/declarations.ts new file mode 100644 index 0000000..0392853 --- /dev/null +++ b/src/server/declarations.ts @@ -0,0 +1,136 @@ +import { parse as toml } from "smol-toml"; +import { XMLParser } from "fast-xml-parser"; +import semver from "semver"; +import type { DependencyDeclaration, Project } from "../shared/types.js"; +import type { SourceFile } from "./discovery.js"; + +/** Static declarations only. Never resolve a range against the latest registry release. */ +export function declarations(project: Project, files: SourceFile[]) { + const found: DependencyDeclaration[] = []; + const warnings: string[] = []; + for (const file of files) { + const base = file.filename.split("/").pop()!; + const add = ( + name: string, + specifier: unknown, + scope: DependencyDeclaration["scope"] = "runtime", + exactVersion?: string, + ) => { + if (!name || typeof name !== "string") return; + found.push({ + name, + specifier: String(specifier ?? ""), + file: file.filename, + scope, + exactVersion, + }); + }; + const requirement = (raw: unknown, scope: DependencyDeclaration["scope"] = "runtime") => { + const value = String(raw).trim(); + if (!value || value.startsWith("#")) return; + const match = value.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)(?:\[[^\]]+\])?\s*(.*)$/); + if (!match || value.startsWith("-")) { + warnings.push( + `${file.filename}: unsupported requirement directive ${value.slice(0, 120)}; not executed.`, + ); + return; + } + const specifier = match[2].replace(/\s+#.*$/, "").trim(); + const pin = specifier.match(/^==([0-9][\w.!+-]*)(?:\s*(?:;.*|--hash=.*|\\))?$/); + add(match[1], specifier, scope, pin?.[1]); + }; + try { + if (base === "package.json") { + const data = JSON.parse(file.content); + for (const group of [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]) + for (const [name, spec] of Object.entries(data[group] || {})) + add( + name, + spec, + group === "devDependencies" ? "development" : "runtime", + typeof spec === "string" && semver.valid(spec) ? spec : undefined, + ); + } else if (/^requirements.*\.txt$/.test(base)) { + for (const line of file.content.split("\n")) + requirement(line, /dev|test/.test(base) ? "development" : "unknown"); + } else if (base === "pyproject.toml") { + const data = toml(file.content) as any; + for (const value of data.project?.dependencies || []) requirement(value); + for (const [group, values] of Object.entries(data.project?.["optional-dependencies"] || {})) + for (const value of values as any[]) + requirement(value, /^(dev|test|tests)$/.test(group) ? "development" : "unknown"); + for (const [group, values] of Object.entries(data["dependency-groups"] || {})) + for (const value of values as any[]) + if (typeof value === "string") + requirement(value, /dev|test/.test(group) ? "development" : "unknown"); + const poetry = data.tool?.poetry; + const groups = [ + { values: poetry?.dependencies, scope: "runtime" as const }, + { values: poetry?.["dev-dependencies"], scope: "development" as const }, + ...Object.values(poetry?.group || {}).map((g: any) => ({ + values: g.dependencies, + scope: "unknown" as const, + })), + ]; + for (const { values, scope } of groups) + for (const [name, value] of Object.entries(values || {})) { + if (name === "python") continue; + const spec = + typeof value === "string" ? value : (value as any)?.version || JSON.stringify(value); + add(name, spec, scope, /^[0-9][\w.!+-]*$/.test(spec) ? spec : undefined); + } + for (const value of data["build-system"]?.requires || []) requirement(value, "development"); + } else if (base === "Pipfile" || base === "Pipfile.lock") { + const data = base.endsWith(".lock") ? JSON.parse(file.content) : toml(file.content); + for (const group of base.endsWith(".lock") + ? ["default", "develop"] + : ["packages", "dev-packages"]) + for (const [name, value] of Object.entries((data as any)[group] || {})) { + const spec = + typeof value === "string" ? value : (value as any).version || JSON.stringify(value); + const pin = spec.match(/^==([0-9][\w.!+-]*)$/); + add(name, spec, group.includes("dev") ? "development" : "unknown", pin?.[1]); + } + } else if (base === "pom.xml") { + if (/ doc.properties?.[key] || original, + ); + add( + d.groupId + ":" + d.artifactId, + spec, + d.scope === "test" ? "development" : "runtime", + /^[0-9][\w.!+-]*$/.test(version) ? version : undefined, + ); + } + } else if (/^setup\.(py|cfg)$/.test(base)) + warnings.push( + `${file.filename}: static dependency extraction is not supported; setup code was not executed.`, + ); + } catch (error) { + warnings.push(`${file.filename}: declaration parsing failed: ${(error as Error).message}`); + } + } + return { + declarations: [ + ...new Map(found.map((d) => [`${d.name}:${d.specifier}:${d.scope}`, d])).values(), + ], + warnings, + }; +} diff --git a/src/server/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..87b1d90 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -1,50 +1,292 @@ -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(); + 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, + 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" || selected.versionStatus === "unresolved" + ? [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.versionStatus === "unresolved" ? n.id : n.purl, n]), + ).values(), + ]; + return unique + .map((n) => { + if (n.versionStatus === "unresolved") + return { + nodeId: n.id, + score: 0, + level: "low" as const, + factors: { severity: 0, exploit: 0, exposure: 0, blastRadius: 0 }, + ancestors: 0, + services: [], + advisories: 0, + coverage: n.coverage, + reasons: [ + "Exact installed version unresolved; vulnerability matching and priority scoring were skipped.", + ], + }; + const ripple = trace(graph, n.id, index, false); + const instances = index.byPurl.get(n.purl)!.map((id) => index.nodes.get(id)!); + const advisories = instances.flatMap((n) => n.advisories); + 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..ba77cc7 100644 --- a/src/server/jobs.ts +++ b/src/server/jobs.ts @@ -1,49 +1,359 @@ -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, + Project, +} 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); + if (input.mode === "demo") void this.run(scan); + else 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); + if (scan.mode === "demo") void this.run(scan); + else 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 { - 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)}); + 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 loaded = new Map>(); + let repositoryFiles: ReturnType | undefined; + const extract = async (project: Project): Promise => { + const paths = [ + ...new Set( + [...project.files, project.manifest, project.lockfile].filter((p): p is string => !!p), + ), + ]; + const files: SourceFile[] = []; + const failures: string[] = []; + for (const path of paths) { + try { + if (!loaded.has(path)) + loaded.set( + path, + input.mode === "manifest" + ? Promise.resolve({ + filename: path, + content: input.content!, + provenance: { + source: "Uploaded manifest", + url: "", + retrievedAt: scan.createdAt, + }, + }) + : github.file( + input.repository!, + scan.commit!, + (repositoryFiles ??= this.store.files(scan.id)).find((f) => f.path === path)!, + input.installationId, + ), + ); + files.push(await loaded.get(path)!); + } catch (error) { + failures.push(path + ": " + (error as Error).message); + } + } + let g: Graph; + try { + g = resolveProject(project, files); + } catch (error) { + g = resolveProject(project, []); + failures.push("Project extraction failed: " + (error as Error).message); + } + g.warnings.push(...failures); + if (failures.length) { + project.resolution = "partial"; + if (project.exactCount) project.ingestionStatus = "PARTIAL"; + } + project.notes = [...new Set([...project.notes, ...g.warnings])]; + return g; + }; + const persist = () => { + scan.connectors = connectors().map((c) => http.health.get(c.name) || c); + this.store.save(scan); + }; + try { + 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.ref = "demo"; + scan.commit = "offline-fixture"; + scan.repositoryMap = { + repository: scan.name, + ref: "demo", + commit: "offline-fixture", + fileCount: 7, + 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", + ); + if (input.mode !== "demo") { + let cursor = 0, + completed = 0; + const projects = scan.repositoryMap!.projects; + await Promise.all( + Array.from({ length: 6 }, async () => { + while (cursor < projects.length) { + const project = projects[cursor++]; + await extract(project); + completed++; + if (completed % 10 === 0 || completed === projects.length) + this.stage( + scan, + "discover", + "Reading dependency declarations: " + completed + " of " + projects.length, + completed, + projects.length, + ); + } + }), + ); + } + scan.status = "mapped"; + this.stage( + scan, + "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 { + for (let i = 0; i < chosen.length; i++) { + const project = chosen[i]; + this.stage(scan, "extract", "Reading " + project.name, i, chosen.length); + const g = await extract(project); + graph.nodes.push(...g.nodes); + graph.edges.push(...g.edges); + graph.warnings.push(...g.warnings.map((w) => project.name + ": " + w)); + project.analyzed = true; + this.stage( + scan, + "extract", + `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" && n.versionStatus !== "unresolved") + .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..a201704 100644 --- a/src/server/parsers.ts +++ b/src/server/parsers.ts @@ -1,87 +1,203 @@ -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( + `No exact version extracted for requirement: ${value.slice(0, 100)}. Supply an exact name==version pin for vulnerability matching.`, + ); + 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}; excluded from exact-version matching.`, + ); + 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..efa0576 --- /dev/null +++ b/src/server/resolve-project.ts @@ -0,0 +1,542 @@ +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 { declarations } from "./declarations.js"; +import type { Graph, PackageNode, Project, Provenance } from "../shared/types.js"; +import type { SourceFile } from "./discovery.js"; +const hash = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 20); +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 declared = declarations( + project, + files.filter( + (f) => + project.files.includes(f.filename) || + f.filename === project.manifest || + f.filename === project.lockfile, + ), + ); + let graph: Graph; + try { + graph = resolveExact(project, files); + } catch (error) { + graph = { + nodes: [rootFor(project)], + edges: [], + warnings: [`Exact resolution failed: ${(error as Error).message}`], + }; + if (project.ecosystem === "npm") { + try { + const fallback = npmManifest( + project, + files.find((f) => f.filename === project.manifest), + ); + graph.nodes.push(...fallback.nodes.filter((n) => n.kind === "package")); + graph.edges.push(...fallback.edges); + } catch {} + } + } + graph.warnings.push(...declared.warnings); + const normalize = (name: string) => + project.ecosystem === "pypi" ? name.toLowerCase().replace(/[-_.]+/g, "-") : name; + for (const d of declared.declarations) { + if ( + graph.nodes.some( + (n) => + n.kind === "package" && + normalize(n.name) === normalize(d.name) && + (n.versionStatus === "unresolved" + ? !d.exactVersion && n.declaredSpecifier === d.specifier + : !d.exactVersion || n.version === d.exactVersion), + ) + ) + continue; + const n = node(project.ecosystem, d.name, d.exactVersion || ""); + n.id = hash(project.id + ":" + normalize(d.name) + ":" + (d.exactVersion || d.specifier)); + n.projectId = project.id; + n.versionStatus = d.exactVersion ? "exact" : "unresolved"; + n.declaredSpecifier = d.specifier; + n.scope = d.scope; + n.relationship = "listed"; + n.coverage = d.exactVersion ? "unchecked" : "partial"; + n.provenance = files.filter((f) => f.filename === d.file).map((f) => f.provenance); + graph.nodes.push(n); + graph.edges.push({ + from: "service-" + project.id, + to: n.id, + projectId: project.id, + relationship: "listed", + scope: d.scope, + }); + } + graph = finalize(graph); + project.declarations = declared.declarations; + const packages = graph.nodes.filter((n) => n.kind === "package"); + project.packageCount = packages.length; + project.exactCount = packages.filter((n) => n.versionStatus !== "unresolved").length; + project.unresolvedCount = packages.length - project.exactCount; + if (!packages.length) + graph.warnings.push( + files.length + ? "No package declarations or exact installed entries were extracted from the readable files. See format-specific notes; a workspace root may require selecting its child projects." + : "No dependency files could be read for this project.", + ); + if (project.unresolvedCount) + graph.warnings.push( + `${project.unresolvedCount} dependency declarations have no exact installed version. They are retained but excluded from vulnerability matching.`, + ); + project.ingestionStatus = !packages.length + ? "UNSUPPORTED" + : !project.exactCount + ? "DECLARED_ONLY" + : graph.warnings.length || project.unresolvedCount + ? "PARTIAL" + : "EXACT"; + project.relationships = graph.edges.some((e) => e.relationship === "listed") + ? "partial" + : graph.edges.length + ? "recorded" + : "unavailable"; + project.resolution = + project.ingestionStatus === "EXACT" + ? "resolved" + : project.ingestionStatus === "UNSUPPORTED" + ? "unsupported" + : "partial"; + return graph; +} +function resolveExact(project: Project, files: SourceFile[]): Graph { + const lock = files.find((f) => f.filename === project.lockfile); + const manifest = files.find((f) => f.filename === project.manifest); + if (lock && /(?:package-lock|npm-shrinkwrap)\.json$/.test(lock.filename)) + 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 { + let data = JSON.parse(file.content); + const legacyWithoutManifest = data.lockfileVersion === 1 && !manifest; + let info: any = {}; + let manifestError = ""; + try { + info = manifest ? JSON.parse(manifest.content) : {}; + } catch { + manifestError = "Manifest JSON could not be parsed; installed lockfile entries were retained."; + } + if (data.lockfileVersion === 1 && data.dependencies) { + const entries: Record = { + "": { + name: data.name, + version: data.version, + dependencies: Object.fromEntries( + Object.entries(data.dependencies).map(([name, d]: [string, any]) => [name, d.version]), + ), + }, + }; + const walk = (deps: Record, parent = "") => { + for (const [name, d] of Object.entries(deps)) { + const path = (parent ? parent + "/" : "") + "node_modules/" + name; + entries[path] = { ...d, dependencies: d.requires || {} }; + if (d.dependencies) walk(d.dependencies, path); + } + }; + walk(data.dependencies); + data = { ...data, lockfileVersion: 2, packages: entries }; + } + if (![2, 3].includes(data.lockfileVersion) || !data.packages) + return { + ...npmManifest(p, manifest), + warnings: [ + "This npm lockfile version is unsupported; only exact manifest pins were imported.", + ], + }; + const entries = data.packages as Record; + if (!entries[""] && p.path === posix.dirname(file.filename)) + entries[""] = { name: data.name, version: data.version }; + const base = posix.dirname(file.filename); + const entry = posix.relative(base, p.path === "." ? "." : p.path); + if (!Object.hasOwn(entries, entry)) { + 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: manifestError ? [manifestError] : [] }; + 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 { + const missing = node("npm", name, ""); + missing.id = hash(file.filename + ":" + path + ":" + name + ":" + String(requested)); + missing.versionStatus = "unresolved"; + missing.declaredSpecifier = String(requested); + missing.coverage = "partial"; + missing.scope = "unknown"; + missing.projectId = p.id; + missing.relationship = "listed"; + missing.provenance = [file.provenance]; + graph.nodes.push(missing); + graph.edges.push({ + from: parent.id, + to: missing.id, + projectId: p.id, + relationship: "listed", + scope: "unknown", + }); + graph.warnings.push( + `Could not determine the installed dependency ${name} used by ${parent.name}.`, + ); + } + } + } + connect(root, entry, { ...entries[entry], ...info }, true); + if (legacyWithoutManifest) { + for (const edge of graph.edges.filter((e) => e.from === root.id)) edge.relationship = "listed"; + graph.warnings.push( + "Legacy npm lockfile without package.json: installed entries are retained, but root direct dependencies are not known.", + ); + } + if ( + !graph.nodes.some((n) => n.kind === "package") && + entry === "" && + !entries[entry].workspaces && + !info.workspaces + ) { + for (const path of Object.keys(entries).filter((path) => path.startsWith("node_modules/"))) { + const child = add(path); + if (child) + graph.edges.push({ + from: root.id, + to: child.id, + projectId: p.id, + relationship: "listed", + scope: "unknown", + }); + } + if (graph.nodes.length > 1) + graph.warnings.push( + "Lockfile packages were retained, but root dependency declarations were unavailable. Listed links do not establish direct ancestry.", + ); + } + for (let i = 0; i < pending.length; i++) { + if (i >= 10000) { + graph.warnings.push( + "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. Exact direct pins can be checked; other declarations remain unresolved and 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..38a18f7 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,11 +1,165 @@ -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; + versionStatus?: "exact" | "unresolved"; + declaredSpecifier?: 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; + declarations?: DependencyDeclaration[]; + exactCount?: number; + unresolvedCount?: number; + ingestionStatus?: "EXACT" | "PARTIAL" | "DECLARED_ONLY" | "UNSUPPORTED"; + relationships?: "recorded" | "partial" | "unavailable"; +} +export interface DependencyDeclaration { + name: string; + specifier: string; + file: string; + scope: PackageNode["scope"]; + exactVersion?: string; +} +export interface RepositoryMap { + repository: string; + 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/src/web/components/DependencyGraph.tsx b/src/web/components/DependencyGraph.tsx index 26d71c5..037e3d6 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.versionStatus === "unresolved" ? "Version unresolved" : 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..c5b8349 --- /dev/null +++ b/src/web/components/PackageDetails.tsx @@ -0,0 +1,207 @@ +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..c85fb61 --- /dev/null +++ b/src/web/components/RepositoryMap.tsx @@ -0,0 +1,141 @@ +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.packageCount !== undefined && ( +

+ Detected dependencies: {project.packageCount} · Exact versions:{" "} + {project.exactCount ?? 0} · Unresolved: {project.unresolvedCount ?? 0} · Coverage:{" "} + {project.ingestionStatus} +

+ )} + {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..8b246e1 100644 --- a/src/web/main.tsx +++ b/src/web/main.tsx @@ -1,59 +1,994 @@ -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.

:<>