/healthLiveness check.
Response · 200
{ "status": "ok" }Try it
curl "$CHAINTRACE_API/health"
Reference
A supply-chain security platform that answers one question the usual tools cannot: when a package version is compromised, what does it actually reach? Lockfiles become a dependency graph in HydraDB, and every query walks it backwards — from a bad version out to the services that ship it, with hop counts, chains and a production-weighted score.
| part | what it is | stack |
|---|---|---|
| CLI | Reads a lockfile, resolves ranges, ingests, and fails your build on CRITICAL findings. | Bun · TypeScript |
| Backend API | 16 HTTP endpoints over the graph: traversal, blast radius, attack paths, risk, typosquat, co-maintainers. | Bun · OpenCypher |
| HydraDB | Object-store-native distributed graph database. Every query reads one pinned snapshot. | Rust · S3 · SlateDB |
| Console | A UI over every endpoint, including the graph in 3D. | Next.js · three.js |
npm:axios@1.7.2 and pypi:requests@2.32.3. Every version-scoped endpoint takes either.Four processes. HydraDB holds the graph, the API serves it, the CLI fills it, and the console reads it.
1 · HydraDB
docker run -p 8443:8443 ghcr.io/hydra-db/hydradb:latest
Releases up to 0.1.0 were linux/amd64 only — on Apple Silicon use a later tag, or add --platform linux/amd64 to run it emulated.
2 · Backend
cd backend bun install cat > .env <<'EOF' HYDRA_URL=http://127.0.0.1:8443 HYDRA_TOKEN=your-token HYDRA_NAMESPACE=default HYDRA_CELL_ID=cell-0 EOF PORT=4000 bun run src/server.ts
The server exits immediately if HYDRA_URL or HYDRA_TOKEN is missing. It defaults to port 3001, which collides with next dev — move one of them.
3 · Ingest something
export CHAINTRACE_API=http://localhost:4000
curl "$CHAINTRACE_API/packages/axios/1.7.2/ingest?depth=2"
curl -X POST "$CHAINTRACE_API/services" \
-H 'Content-Type: application/json' \
-d '{
"name": "payment-api",
"repo": "acme/payment-api",
"team": "payments",
"environment": "production",
"dependencies": [{ "name": "axios", "version": "1.7.2" }]
}'
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/blast-radius"Without the service registration the graph knows packages but not consequences — blast radius, attack paths and risk all return empty.
4 · Console
cd front-end pnpm install echo 'NEXT_PUBLIC_CHAINTRACE_API=http://localhost:4000' > .env.local pnpm dev
Then open /console. If the API is unreachable every view falls back to a sample dataset and says so in the status chip.
Four vertex types and four edge types. Every endpoint in the API is a walk over this.
(:Package) ──[:HAS_VERSION]──▶ (:Version) (:Version) ──[:DEPENDS_ON]───▶ (:Version) (:Service) ──[:DEPENDS_ON_VERSION]──▶ (:Version) (:Maintainer) ──[:MAINTAINS]──▶ (:Package)
Vertices
| label | properties | what it is |
|---|---|---|
| Package | id · name · ecosystem | One package name in one registry. npm and PyPI both land here. |
| Version | id · key · packageName · version · ecosystem | A concrete released version. The key is what every version-scoped endpoint takes. |
| Service | id · name · repo · team · environment | Something you deploy. Without these the graph knows packages but not consequences. |
| Maintainer | id · username | A publishing account. Written during ingest. |
Edges
| type | direction | carries |
|---|---|---|
HAS_VERSION | Package → Version | Every released version of a package. |
DEPENDS_ON | Version → Version | Carries the dependency type and the range that pulled it in. |
DEPENDS_ON_VERSION | Service → Version | What a service actually ships. The edge that turns packages into impact. |
MAINTAINS | Maintainer → Package | Who can publish to it. |
npm:<name>@<version> or pypi:<name>@<version>. Scoped npm packages work too — npm:@scope/pkg@1.0.0.parseVersionKey in graph-service.ts only strips the npm: prefix, so /packages/:name/graph returns PyPI node names still carrying pypi:. The console strips either prefix for display; raw responses do not.16 endpoints. All responses are JSON and carry Access-Control-Allow-Origin: *, so a browser can call the API directly. A preflight OPTIONS returns 204.
export CHAINTRACE_API=http://localhost:4000
Liveness, and the service registry that every impact answer is ultimately counting.
/healthLiveness check.
Response · 200
{ "status": "ok" }Try it
curl "$CHAINTRACE_API/health"
/servicesServicesEvery registered service.
Response · 200
{
"success": true,
"count": 1,
"services": [
{
"id": 2076504302,
"name": "payment-api",
"repo": "acme/payment-api",
"team": "payments",
"environment": "production"
}
]
}Try it
curl "$CHAINTRACE_API/services"
/serviceswritesRegister a service and the versions it ships.
This is the write that makes blast radius, attack paths and risk mean anything. Run it from your deploy pipeline, not by hand.
Body
| name | type | default | notes |
|---|---|---|---|
name* | string | — | Service name. |
repo | string | — | Source repository. |
team | string | — | Owning team. |
environment | string | development | production, staging, or anything else. Drives 60 / 30 / 10 points of risk. |
dependencies* | { name, version }[] | — | Resolved versions this service depends on. |
Response · 200
{ "success": true, "serviceId": 2076504302, "dependencyCount": 12 }Try it
curl -X POST "$CHAINTRACE_API/services" \
-H 'Content-Type: application/json' \
-d '{
"name": "payment-api",
"repo": "acme/payment-api",
"team": "payments",
"environment": "production",
"dependencies": [{ "name": "axios", "version": "1.7.2" }]
}'Reading the dependency graph forwards, from a package outwards.
/packages/:packageNamePackage info and every version the graph knows.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | URL-encoded package name. |
Response · 200
{
"package": "axios",
"versions": [{ "key": "npm:axios@1.7.2", "version": "1.7.2" }]
}Errors
| status | body |
|---|---|
404 | Package not found in the graph |
Try it
curl "$CHAINTRACE_API/packages/axios"
/packages/:packageName/graph3D graphThe dependency graph, level by level, with a hop depth on every node.
Traversal runs one level at a time rather than as a variable-length Cypher path, which is why each node and edge carries an explicit depth. Ask for depth 2 and you get depth 2.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | URL-encoded package name. |
depth | query · integer | 1 | 1–5. Levels of transitive dependencies to walk. |
Response · 200
{
"package": "axios",
"depth": 2,
"nodes": [
{ "id": "npm:axios@1.7.2", "packageName": "axios", "version": "1.7.2", "depth": 0 },
{ "id": "npm:form-data@4.0.0", "packageName": "form-data", "version": "4.0.0", "depth": 1 }
],
"edges": [
{
"source": "npm:axios@1.7.2",
"target": "npm:form-data@4.0.0",
"packageName": "form-data",
"versionRange": "^4.0.0",
"dependencyType": "runtime",
"depth": 1
}
]
}Try it
curl "$CHAINTRACE_API/packages/axios/graph?depth=2"
/packages/:packageName/:version/analysisAnalysisRisk, blast radius and attack paths in a single request.
The triage call. Everything the three dedicated endpoints return, for one version, in one round trip.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | Package name. |
version* | path · string | — | Exact version. |
depth | query · integer | 5 | Traversal depth. The API caps it at 5 — an unbounded walk over a real registry graph is not a query anyone should trigger by accident. |
Response · 200
{
"packageName": "axios",
"version": "1.7.2",
"versionKey": "npm:axios@1.7.2",
"risk": { "score": 100, "severity": "CRITICAL", "services": [ … ] },
"blastRadius": { "affectedServices": 4, "productionServices": 3, "services": [ … ] },
"attackPaths": { "affectedServices": 4, "paths": [ … ] },
"maxDepth": 5
}Try it
curl "$CHAINTRACE_API/packages/axios/1.7.2/analysis?depth=5"
/packages/:packageName/:version/riskRiskRisk for a version, addressed by package and version instead of key.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | Package name. |
version* | path · string | — | Exact version. |
depth | query · integer | 5 | Traversal depth. The API caps it at 5 — an unbounded walk over a real registry graph is not a query anyone should trigger by accident. |
Response · 200
{ "version": "npm:axios@1.7.2", "score": 100, "severity": "CRITICAL", … }Try it
curl "$CHAINTRACE_API/packages/axios/1.7.2/risk"
Reading the graph backwards, from one compromised version to whoever is exposed to it. Every path here takes a full version key.
/versions/:versionKey/dependenciesDirect dependencies of one version.
Parameters
| name | type | default | notes |
|---|---|---|---|
versionKey* | path · string | — | URL-encoded key, e.g. npm:axios@1.7.2. |
Response · 200
{
"version": "npm:axios@1.7.2",
"dependencies": [
{ "key": "npm:form-data@4.0.0", "packageName": "form-data", "version": "4.0.0" }
]
}Try it
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/dependencies"
/versions/:versionKey/blast-radiusBlast radiusWhich services reach this version, and from how many hops away.
Reverse DEPENDS_ON traversal. The hop count is the difference between an upgrade you schedule and one you page for.
Parameters
| name | type | default | notes |
|---|---|---|---|
versionKey* | path · string | — | URL-encoded version key. |
depth | query · integer | 5 | Traversal depth. The API caps it at 5 — an unbounded walk over a real registry graph is not a query anyone should trigger by accident. |
Response · 200
{
"version": "npm:axios@1.7.2",
"maxDepth": 5,
"affectedServices": 1,
"services": [
{
"id": 2076504302,
"name": "payment-api",
"repo": "acme/payment-api",
"team": "payments",
"environment": "production",
"hops": 0
}
]
}Try it
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/blast-radius?depth=5"
/versions/:versionKey/attack-pathAttack pathsThe shortest chain from each affected service to the version.
A score nobody can audit is a score nobody acts on. This returns the actual ordered links.
Parameters
| name | type | default | notes |
|---|---|---|---|
versionKey* | path · string | — | URL-encoded version key. |
depth | query · integer | 5 | Traversal depth. The API caps it at 5 — an unbounded walk over a real registry graph is not a query anyone should trigger by accident. |
Response · 200
{
"version": "npm:axios@1.7.2",
"maxDepth": 5,
"affectedServices": 1,
"attackPaths": [
{
"serviceId": 2076504302,
"serviceName": "payment-api",
"environment": "production",
"hops": 0,
"path": ["npm:axios@1.7.2"]
}
]
}Try it
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/attack-path?depth=5"
/versions/:versionKey/riskRiskA 0–100 score per affected service, rolled up to the version.
Every score arrives with the reasons that produced it, so the ranking is arguable — which is the point.
Parameters
| name | type | default | notes |
|---|---|---|---|
versionKey* | path · string | — | URL-encoded version key. |
depth | query · integer | 5 | Traversal depth. The API caps it at 5 — an unbounded walk over a real registry graph is not a query anyone should trigger by accident. |
Response · 200
{
"version": "npm:axios@1.7.2",
"score": 100,
"severity": "CRITICAL",
"affectedServices": 4,
"productionServices": 3,
"services": [
{
"serviceId": 2076504302,
"name": "payment-api",
"environment": "production",
"hops": 0,
"score": 90,
"severity": "CRITICAL",
"reasons": ["Affected production service", "Direct dependency"]
}
],
"maxDepth": 5
}Try it
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/risk?depth=5"
/versions/:versionKey/co-maintainersCo-maintainersPackages sharing at least one maintainer with this one.
A stolen publish token is not scoped to the package you noticed. Sorted by shared count, then alphabetically.
Parameters
| name | type | default | notes |
|---|---|---|---|
versionKey* | path · string | — | URL-encoded version key. |
Response · 200
{
"version": "npm:axios@1.7.2",
"coMaintainerCount": 2,
"packages": [
{ "packageName": "follow-redirects", "sharedMaintainers": ["nick"], "sharedCount": 1 }
]
}Errors
| status | body |
|---|---|
404 | Version not found |
500 | Failed to query co-maintainers |
Try it
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/co-maintainers"
Two questions that are not traversals: which lockfiles took the bad version, and which names are close enough to be mistaken for it.
/lockfiles/resolveLockfile resolveWhich lockfile entries resolved to the compromised version.
Ranges do not answer this — only a resolved entry does. Each entry is checked against the graph, and the ones a service reaches through are reported with hop counts.
Body
| name | type | default | notes |
|---|---|---|---|
compromisedVersion* | string | — | Full version key, e.g. npm:axios@1.7.2. |
entries* | { name, version }[] | — | Resolved lockfile entries to check. |
Response · 200
{
"compromisedVersion": "npm:axios@1.7.2",
"compromisedPackage": "axios",
"checkedEntries": 3,
"resolvedToCompromised": 1,
"matches": [
{
"name": "axios",
"version": "1.7.2",
"inGraph": true,
"services": [
{ "serviceName": "payment-api", "environment": "production", "hops": 0 }
]
}
]
}Errors
| status | body |
|---|---|
400 | compromisedVersion is required / entries array is required |
404 | Version not found |
Try it
curl -X POST "$CHAINTRACE_API/lockfiles/resolve" \
-H 'Content-Type: application/json' \
-d '{
"compromisedVersion": "npm:axios@1.7.2",
"entries": [{ "name": "axios", "version": "1.7.2" }]
}'/typosquat/:packageNameTyposquatPackage names within a few edits of a target.
Levenshtein distance over every Package name in the graph. Distance alone is noisy, so a shared prefix or suffix and a popularity band come back alongside it.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | Name to check. |
threshold | query · integer | 2 | Maximum edit distance, 1–5. |
Response · 200
{
"targetPackage": "axios",
"threshold": 2,
"candidates": [
{
"packageName": "axio",
"editDistance": 1,
"sharedPrefix": true,
"sharedSuffix": false,
"popularity": "unknown",
"riskSignal": "Edit distance 1 — shared prefix"
}
]
}Try it
curl "$CHAINTRACE_API/typosquat/axios?threshold=2"
The writes. Both are GETs on this API but they crawl a registry and mutate the graph, so treat them accordingly.
/packages/:packageName/:version/ingestwritesCrawl an npm package and write it into the graph.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | npm package name. |
version* | path · string | — | Exact version. |
depth | query · integer | 2 | Transitive depth to crawl. |
Response · 200
{
"success": true,
"packageName": "axios",
"version": "1.7.2",
"versionKey": "npm:axios@1.7.2",
"stats": { "packages": 5, "versions": 5, "dependencyEdges": 4, "maintainers": 1 }
}Try it
curl "$CHAINTRACE_API/packages/axios/1.7.2/ingest?depth=1"
/pypi/:packageName/:version/ingestwritesCrawl a PyPI package and write it into the graph.
Fetches from pypi.org, normalises PEP 508 dependency strings into the same shape as npm's, resolves specifiers to concrete versions, and writes everything under the pypi: prefix.
Parameters
| name | type | default | notes |
|---|---|---|---|
packageName* | path · string | — | PyPI package name. |
version* | path · string | — | Exact version. |
depth | query · integer | 2 | Transitive depth to crawl. |
Response · 200
{
"success": true,
"packageName": "requests",
"version": "2.32.3",
"versionKey": "pypi:requests@2.32.3",
"stats": { "packages": 5, "versions": 5, "dependencyEdges": 4 }
}Errors
| status | body |
|---|---|
400 | Package name / version required, or depth not a non-negative integer |
500 | PyPI package ingestion failed |
Try it
curl "$CHAINTRACE_API/pypi/requests/2.32.3/ingest?depth=1"
Risk is computed per affected service and then rolled up to the version. These are the rules as implemented in backend/src/graph/query/risk.ts — a score is only useful with its reasons attached, and every response carries them.
Per service, out of 100
| points | condition |
|---|---|
+60 | environment is production |
+30 | environment is staging |
+10 | any other environment |
+30 | direct dependency (0 hops) |
+20 | one hop away |
+10 | within three hops |
Environment and hop distance both contribute, so a production service holding a direct dependency scores 90 and a development service four hops out scores 10.
Rolled up to the version
| points | condition |
|---|---|
max | the worst affected service sets the floor |
+10 | two or more production services affected |
+10 | five or more production services affected |
Severity bands
| score | severity |
|---|---|
≥ 80 | CRITICAL |
≥ 60 | HIGH |
≥ 30 | MEDIUM |
< 30 | LOW |
The CLI is what puts your project in the graph. It parses the lockfile locally and sends package coordinates — name, version, range. It does not read or upload source files.
Install
cd cli bun install # run from source bun run dev # or build a standalone binary bun run build ln -s "$PWD/dist/chaintrace" /usr/local/bin/chaintrace chaintrace --version
Configuration
| variable | default | what it does |
|---|---|---|
CHAINTRACE_API_URL | http://localhost:3001 | Backend API base URL |
GITHUB_CLIENT_ID | — | GitHub OAuth app client ID, required for github login |
CHAINTRACE_DEBUG | disabled | Verbose logging |
CHAINTRACE_API_URL=http://localhost:4000 GITHUB_CLIENT_ID=your_client_id CHAINTRACE_DEBUG=true
chaintrace scanScan a project's lockfile and analyse every dependency.
Detects the lockfile, resolves every range to the version the installer would actually pick, analyses each dependency, and sets an exit code for CI.
Usage
chaintrace scan chaintrace scan --path ./my-project chaintrace scan --path ./backend --depth 3 chaintrace scan -p ./frontend -d 5
Flags
| flag | alias | default | notes |
|---|---|---|---|
--path <path> | -p | "." | Project directory to scan |
--depth <number> | -d | "5" | Traversal depth for the dependency graph (0–5) |
What it does
chaintrace check <package@version>Analyse one package version.
Usage
chaintrace check axios@1.7.2 chaintrace check react@19.2.8 --depth 5 chaintrace check lodash@4.17.21 -d 3
Flags
| flag | alias | default | notes |
|---|---|---|---|
--depth <number> | -d | "5" | Traversal depth (0–5) |
chaintrace github loginAuthenticate against GitHub with the OAuth device flow.
Requests a device code, prints the verification URL and user code, then polls until you approve it. Needs GITHUB_CLIENT_ID set.
Usage
chaintrace github login
Detected in this order. Parsed means the dependencies are read out; detected means the file is recognised but not yet understood.
| tool | file | status | notes |
|---|---|---|---|
| npm | package-lock.json | parsed | v1, v2 and v3 formats |
| npm | npm-shrinkwrap.json | parsed | Same format as package-lock |
| Bun | bun.lock | parsed | JSON |
| Bun | bun.lockb | parsed | Binary, detected |
| pip | requirements.txt | parsed | PEP 508; exact == pins only |
| Poetry | poetry.lock | parsed | TOML |
| Pipenv | Pipfile.lock | parsed | JSON |
| pnpm | pnpm-lock.yaml | detected | Parsing not implemented yet |
| Yarn | yarn.lock | detected | Parsing not implemented yet |
| code | meaning | when |
|---|---|---|
0 | Success | No CRITICAL or HIGH findings |
1 | Warning | HIGH findings present |
2 | Failure | CRITICAL findings present |
- name: ChainTrace security scan
run: chaintrace scan --path . --depth 5
env:
CHAINTRACE_API_URL: ${{ secrets.CHAINTRACE_API_URL }}Every view is one endpoint, rendered the way that endpoint's answer is shaped. Depth-based traversals get a 3D graph; ordered chains stay flat and readable.
| route | endpoint | view |
|---|---|---|
| /console | GET /health · /services | API map and graph summary |
| /console/graph | /packages/:n/graph | 3D graph, one sphere shell per hop |
| /console/analysis | /:n/:v/analysis | Risk, blast radius and paths together |
| /console/blast | /blast-radius | Services by hop distance, coloured by severity |
| /console/paths | /attack-path | Each service→version chain, in order |
| /console/risk | /risk | Score per service with reasons and the rules |
| /console/maintainers | /co-maintainers | Packages sharing a publishing account |
| /console/lockfile | POST /lockfiles/resolve | Which pasted entries took the bad version |
| /console/typosquat | /typosquat/:n | Names within N edits, with signals |
| /console/services | GET /services | The service registry |
3D graph controls
| action | result |
|---|---|
| drag | orbit |
| scroll | zoom |
| click a node | fly to it and defocus everything off its path to hop 0 |
| click empty space, or Escape | clear the selection |
Every error returns the same shape.
{ "error": "Human-readable error message" }| status | meaning |
|---|---|
400 | Bad request — missing or invalid parameters |
404 | Package or version not found in the graph |
405 | Method not allowed |
500 | Internal server error |