Docs

Reference

ChainTrace

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.

The pieces

partwhat it isstack
CLIReads a lockfile, resolves ranges, ingests, and fails your build on CRITICAL findings.Bun · TypeScript
Backend API16 HTTP endpoints over the graph: traversal, blast radius, attack paths, risk, typosquat, co-maintainers.Bun · OpenCypher
HydraDBObject-store-native distributed graph database. Every query reads one pinned snapshot.Rust · S3 · SlateDB
ConsoleA UI over every endpoint, including the graph in 3D.Next.js · three.js
Two ecosystems, one graph. npm and PyPI live side by side, separated only by the version-key prefix — npm:axios@1.7.2 and pypi:requests@2.32.3. Every version-scoped endpoint takes either.

Quick start

Four processes. HydraDB holds the graph, the API serves it, the CLI fills it, and the console reads it.

1 · HydraDB

bash
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

bash
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

bash
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

bash
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.

Graph model

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

labelpropertieswhat it is
Packageid · name · ecosystemOne package name in one registry. npm and PyPI both land here.
Versionid · key · packageName · version · ecosystemA concrete released version. The key is what every version-scoped endpoint takes.
Serviceid · name · repo · team · environmentSomething you deploy. Without these the graph knows packages but not consequences.
Maintainerid · usernameA publishing account. Written during ingest.

Edges

typedirectioncarries
HAS_VERSIONPackage → VersionEvery released version of a package.
DEPENDS_ONVersion → VersionCarries the dependency type and the range that pulled it in.
DEPENDS_ON_VERSIONService → VersionWhat a service actually ships. The edge that turns packages into impact.
MAINTAINSMaintainer → PackageWho can publish to it.
Version keys. Every version-scoped path takes a whole key, URL-encoded: npm:<name>@<version> or pypi:<name>@<version>. Scoped npm packages work too — npm:@scope/pkg@1.0.0.
Known gap. 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.

HTTP API

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.

bash
export CHAINTRACE_API=http://localhost:4000

System

Liveness, and the service registry that every impact answer is ultimately counting.

GET/health

Liveness check.

Response · 200

json
{ "status": "ok" }

Try it

bash
curl "$CHAINTRACE_API/health"
GET/servicesServices

Every registered service.

Response · 200

json
{
  "success": true,
  "count": 1,
  "services": [
    {
      "id": 2076504302,
      "name": "payment-api",
      "repo": "acme/payment-api",
      "team": "payments",
      "environment": "production"
    }
  ]
}

Try it

bash
curl "$CHAINTRACE_API/services"
POST/serviceswrites

Register 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

nametypedefaultnotes
name*stringService name.
repostringSource repository.
teamstringOwning team.
environmentstringdevelopmentproduction, staging, or anything else. Drives 60 / 30 / 10 points of risk.
dependencies*{ name, version }[]Resolved versions this service depends on.

Response · 200

json
{ "success": true, "serviceId": 2076504302, "dependencyCount": 12 }

Try it

bash
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" }]
  }'

Packages

Reading the dependency graph forwards, from a package outwards.

GET/packages/:packageName

Package info and every version the graph knows.

Parameters

nametypedefaultnotes
packageName*path · stringURL-encoded package name.

Response · 200

json
{
  "package": "axios",
  "versions": [{ "key": "npm:axios@1.7.2", "version": "1.7.2" }]
}

Errors

statusbody
404Package not found in the graph

Try it

bash
curl "$CHAINTRACE_API/packages/axios"
GET/packages/:packageName/graph3D graph

The 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

nametypedefaultnotes
packageName*path · stringURL-encoded package name.
depthquery · integer11–5. Levels of transitive dependencies to walk.

Response · 200

json
{
  "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

bash
curl "$CHAINTRACE_API/packages/axios/graph?depth=2"
GET/packages/:packageName/:version/analysisAnalysis

Risk, 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

nametypedefaultnotes
packageName*path · stringPackage name.
version*path · stringExact version.
depthquery · integer5Traversal 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

json
{
  "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

bash
curl "$CHAINTRACE_API/packages/axios/1.7.2/analysis?depth=5"
GET/packages/:packageName/:version/riskRisk

Risk for a version, addressed by package and version instead of key.

Parameters

nametypedefaultnotes
packageName*path · stringPackage name.
version*path · stringExact version.
depthquery · integer5Traversal 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

json
{ "version": "npm:axios@1.7.2", "score": 100, "severity": "CRITICAL", … }

Try it

bash
curl "$CHAINTRACE_API/packages/axios/1.7.2/risk"

Versions

Reading the graph backwards, from one compromised version to whoever is exposed to it. Every path here takes a full version key.

GET/versions/:versionKey/dependencies

Direct dependencies of one version.

Parameters

nametypedefaultnotes
versionKey*path · stringURL-encoded key, e.g. npm:axios@1.7.2.

Response · 200

json
{
  "version": "npm:axios@1.7.2",
  "dependencies": [
    { "key": "npm:form-data@4.0.0", "packageName": "form-data", "version": "4.0.0" }
  ]
}

Try it

bash
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/dependencies"
GET/versions/:versionKey/blast-radiusBlast radius

Which 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

nametypedefaultnotes
versionKey*path · stringURL-encoded version key.
depthquery · integer5Traversal 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

json
{
  "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

bash
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/blast-radius?depth=5"
GET/versions/:versionKey/attack-pathAttack paths

The 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

nametypedefaultnotes
versionKey*path · stringURL-encoded version key.
depthquery · integer5Traversal 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

json
{
  "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

bash
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/attack-path?depth=5"
GET/versions/:versionKey/riskRisk

A 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

nametypedefaultnotes
versionKey*path · stringURL-encoded version key.
depthquery · integer5Traversal 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

json
{
  "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

bash
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/risk?depth=5"
GET/versions/:versionKey/co-maintainersCo-maintainers

Packages 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

nametypedefaultnotes
versionKey*path · stringURL-encoded version key.

Response · 200

json
{
  "version": "npm:axios@1.7.2",
  "coMaintainerCount": 2,
  "packages": [
    { "packageName": "follow-redirects", "sharedMaintainers": ["nick"], "sharedCount": 1 }
  ]
}

Errors

statusbody
404Version not found
500Failed to query co-maintainers

Try it

bash
curl "$CHAINTRACE_API/versions/npm:axios@1.7.2/co-maintainers"

Detection

Two questions that are not traversals: which lockfiles took the bad version, and which names are close enough to be mistaken for it.

POST/lockfiles/resolveLockfile resolve

Which 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

nametypedefaultnotes
compromisedVersion*stringFull version key, e.g. npm:axios@1.7.2.
entries*{ name, version }[]Resolved lockfile entries to check.

Response · 200

json
{
  "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

statusbody
400compromisedVersion is required / entries array is required
404Version not found

Try it

bash
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" }]
  }'
GET/typosquat/:packageNameTyposquat

Package 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

nametypedefaultnotes
packageName*path · stringName to check.
thresholdquery · integer2Maximum edit distance, 1–5.

Response · 200

json
{
  "targetPackage": "axios",
  "threshold": 2,
  "candidates": [
    {
      "packageName": "axio",
      "editDistance": 1,
      "sharedPrefix": true,
      "sharedSuffix": false,
      "popularity": "unknown",
      "riskSignal": "Edit distance 1 — shared prefix"
    }
  ]
}

Try it

bash
curl "$CHAINTRACE_API/typosquat/axios?threshold=2"

Ingest

The writes. Both are GETs on this API but they crawl a registry and mutate the graph, so treat them accordingly.

GET/packages/:packageName/:version/ingestwrites

Crawl an npm package and write it into the graph.

Parameters

nametypedefaultnotes
packageName*path · stringnpm package name.
version*path · stringExact version.
depthquery · integer2Transitive depth to crawl.

Response · 200

json
{
  "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

bash
curl "$CHAINTRACE_API/packages/axios/1.7.2/ingest?depth=1"
GET/pypi/:packageName/:version/ingestwrites

Crawl 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

nametypedefaultnotes
packageName*path · stringPyPI package name.
version*path · stringExact version.
depthquery · integer2Transitive depth to crawl.

Response · 200

json
{
  "success": true,
  "packageName": "requests",
  "version": "2.32.3",
  "versionKey": "pypi:requests@2.32.3",
  "stats": { "packages": 5, "versions": 5, "dependencyEdges": 4 }
}

Errors

statusbody
400Package name / version required, or depth not a non-negative integer
500PyPI package ingestion failed

Try it

bash
curl "$CHAINTRACE_API/pypi/requests/2.32.3/ingest?depth=1"

Risk scoring

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

pointscondition
+60environment is production
+30environment is staging
+10any other environment
+30direct dependency (0 hops)
+20one hop away
+10within 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

pointscondition
maxthe worst affected service sets the floor
+10two or more production services affected
+10five or more production services affected

Severity bands

scoreseverity
≥ 80CRITICAL
≥ 60HIGH
≥ 30MEDIUM
< 30LOW

CLI

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

bash
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

variabledefaultwhat it does
CHAINTRACE_API_URLhttp://localhost:3001Backend API base URL
GITHUB_CLIENT_IDGitHub OAuth app client ID, required for github login
CHAINTRACE_DEBUGdisabledVerbose logging
env
CHAINTRACE_API_URL=http://localhost:4000
GITHUB_CLIENT_ID=your_client_id
CHAINTRACE_DEBUG=true
chaintrace scan

Scan 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

bash
chaintrace scan
chaintrace scan --path ./my-project
chaintrace scan --path ./backend --depth 3
chaintrace scan -p ./frontend -d 5

Flags

flagaliasdefaultnotes
--path <path>-p"."Project directory to scan
--depth <number>-d"5"Traversal depth for the dependency graph (0–5)

What it does

  1. Detect the lockfile type
  2. Parse it and extract every dependency
  3. Per dependency: analysis, auto-ingesting anything the graph has not seen
  4. Sort by severity, CRITICAL first
  5. Print the summary and set the exit code
chaintrace check <package@version>

Analyse one package version.

Usage

bash
chaintrace check axios@1.7.2
chaintrace check react@19.2.8 --depth 5
chaintrace check lodash@4.17.21 -d 3

Flags

flagaliasdefaultnotes
--depth <number>-d"5"Traversal depth (0–5)
chaintrace github login

Authenticate 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

bash
chaintrace github login

Lockfiles

Detected in this order. Parsed means the dependencies are read out; detected means the file is recognised but not yet understood.

toolfilestatusnotes
npmpackage-lock.jsonparsedv1, v2 and v3 formats
npmnpm-shrinkwrap.jsonparsedSame format as package-lock
Bunbun.lockparsedJSON
Bunbun.lockbparsedBinary, detected
piprequirements.txtparsedPEP 508; exact == pins only
Poetrypoetry.lockparsedTOML
PipenvPipfile.lockparsedJSON
pnpmpnpm-lock.yamldetectedParsing not implemented yet
Yarnyarn.lockdetectedParsing not implemented yet

Exit codes

codemeaningwhen
0SuccessNo CRITICAL or HIGH findings
1WarningHIGH findings present
2FailureCRITICAL findings present
yaml
- name: ChainTrace security scan
  run: chaintrace scan --path . --depth 5
  env:
    CHAINTRACE_API_URL: ${{ secrets.CHAINTRACE_API_URL }}

Console

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.

routeendpointview
/consoleGET /health · /servicesAPI map and graph summary
/console/graph/packages/:n/graph3D graph, one sphere shell per hop
/console/analysis/:n/:v/analysisRisk, blast radius and paths together
/console/blast/blast-radiusServices by hop distance, coloured by severity
/console/paths/attack-pathEach service→version chain, in order
/console/risk/riskScore per service with reasons and the rules
/console/maintainers/co-maintainersPackages sharing a publishing account
/console/lockfilePOST /lockfiles/resolveWhich pasted entries took the bad version
/console/typosquat/typosquat/:nNames within N edits, with signals
/console/servicesGET /servicesThe service registry
One target, every page. Ecosystem, package, version and depth are console-wide and persisted. Set them anywhere and every other view is already asking about the same thing.

3D graph controls

actionresult
dragorbit
scrollzoom
click a nodefly to it and defocus everything off its path to hop 0
click empty space, or Escapeclear the selection

Errors

Every error returns the same shape.

json
{ "error": "Human-readable error message" }
statusmeaning
400Bad request — missing or invalid parameters
404Package or version not found in the graph
405Method not allowed
500Internal server error