Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Custom resolvers for operations not auto-generated by Neo4j GraphQL:
- Express router for blob downloads at `/blob/:hash`
- Uses S3 SDK to fetch blobs from MinIO with authenticated requests
- No per-blob access control: every stored blob is downloadable by hash
- Winbindex fast path (`src/winbindex.ts`): when `?filename=` names a Windows PE
file (`.exe`/`.dll`/`.sys`), the blob is resolved on Winbindex and streamed
verified from Microsoft's symbol server instead of MinIO; every miss or failure
falls back to MinIO unchanged. See `docs/reference/winbindex-source.md`.

#### Data Processing

Expand Down Expand Up @@ -119,6 +123,7 @@ See README.md for complete `.env` setup. Key variables:
- **Neo4j**: `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`
- **Storage**: `OBJECT_STORAGE_URI`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_OBJECTS_BUCKET_NAME`
- **Optional**: `NODE_ENV`, `SENSITIVE_REGISTRY_VALUES`
- **Winbindex fast path** (optional, all defaulted): `WINBINDEX_ENABLED`, `WINBINDEX_DATA_URL`, `WINBINDEX_SYMBOL_SERVER_URL`, `WINBINDEX_FETCH_TIMEOUT_MS` — see `docs/reference/winbindex-source.md`

## Testing Strategy
- Jest 30 with ts-jest for TypeScript support
Expand All @@ -135,6 +140,7 @@ The project includes comprehensive documentation in the `docs/` directory follow
- **Reference** (`docs/reference/`) - API specifications
- [Data Model](docs/reference/data-model.md) - **Core graph structure, merkle tree architecture, CRITICAL for Cypher queries**
- [Blob Download API](docs/reference/blob-api.md) - Complete REST API spec with error codes, rate limits, examples
- [Winbindex Blob Source](docs/reference/winbindex-source.md) - Winbindex + symbol-server fast path for Windows PE blob downloads
- [Access Restrictions](docs/reference/access-restrictions.md) - Security controls and filtering mechanisms
- **How-To Guides** (`docs/how-to/`) - Step-by-step task instructions
- [Integrate Blob API](docs/how-to/integrate-blob-api.md) - Frontend integration guide
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ MINIO_OBJECTS_BUCKET_NAME=objects
# Optional - Environment
NODE_ENV=development

# Optional - Winbindex fast path for Windows PE blob downloads
# (all defaulted; see docs/reference/winbindex-source.md)
WINBINDEX_ENABLED=true
WINBINDEX_DATA_URL=https://winbindex.m417z.com/data/by_filename_compressed
WINBINDEX_SYMBOL_SERVER_URL=https://msdl.microsoft.com/download/symbols
WINBINDEX_FETCH_TIMEOUT_MS=15000

# Optional - Registry Filtering
# Unused unless the plugin commented out in src/index.ts is re-enabled
# (see docs/reference/access-restrictions.md)
Expand Down
10 changes: 8 additions & 2 deletions docs/how-to/integrate-blob-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ export class BlobService {
hash: string,
filename?: string
): Promise<void> {
const url = `${this.blobBaseUrl}/${hash}`;
// Pass `filename` as a query param so the API can serve Windows PE files
// from the Winbindex fast path. It is ignored for non-PE names.
const url = filename
? `${this.blobBaseUrl}/${hash}?filename=${encodeURIComponent(filename)}`
: `${this.blobBaseUrl}/${hash}`;

try {
const response = await fetch(url);
Expand Down Expand Up @@ -115,7 +119,9 @@ export class BlobService {
onProgress: (percent: number) => void,
filename?: string
): Promise<void> {
const url = `${this.blobBaseUrl}/${hash}`;
const url = filename
? `${this.blobBaseUrl}/${hash}?filename=${encodeURIComponent(filename)}`
: `${this.blobBaseUrl}/${hash}`;

const response = await fetch(url);

Expand Down
6 changes: 6 additions & 0 deletions docs/reference/blob-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Downloads a blob by its SHA-1 hash.
|------|------|----------|-------------|
| `hash` | string | Yes | SHA-1 hash of the blob (40 hexadecimal characters) |

### Query Parameters

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `filename` | string | No | The file's basename (e.g. `kernel32.dll`). Used **only** to look the blob up on Winbindex for the Windows-PE fast path (see [Winbindex Blob Source](./winbindex-source.md)). Ignored for non-PE names, and by the MinIO path, which always keys on `hash`. The server takes the basename and lowercases it (so `windows/system32/KERNEL32.DLL` resolves as `kernel32.dll`); if that basename then contains anything outside `[a-z0-9._+-]` (`?`, `#`, `%`, whitespace, ...) the fast path is skipped. |

## Request Headers

The endpoint is unauthenticated: no headers are required.
Expand Down
93 changes: 93 additions & 0 deletions docs/reference/winbindex-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Winbindex Blob Source Reference

## What it is

A fast path in front of the `GET /blob/:hash` handler (`src/rest-routes.ts`).
For Windows PE files the API resolves the file on
[Winbindex](https://winbindex.m417z.com/) and streams the verified bytes
straight from Microsoft's public symbol server, instead of proxying them from
MinIO. The public corpus therefore never has to re-host Windows binaries.

Implementation: `src/winbindex.ts`.

## When it engages

All of the following must hold, otherwise the request is served from MinIO
exactly as before:

| Condition | Detail |
|-----------|--------|
| `WINBINDEX_ENABLED` is true | Global kill switch. |
| The request carries `?filename=` | A non-string or absent `filename` skips the fast path with no external call. |
| `filename` is a Windows PE file | The lowercased basename ends with `.exe`, `.dll` or `.sys`. Detection is purely by extension. |
| `filename` is a plain safe name | The lowercased basename matches `^[a-z0-9._+-]{1,255}$`. Names with `?`, `#`, whitespace or percent-encoding are rejected (they would otherwise steer the outbound request), and the request goes to MinIO. |
| Winbindex resolves the hash | The per-filename index contains an entry whose `fileInfo.sha1` equals the requested `hash`, with numeric `timestamp` and `virtualSize`. |
| The symbol server returns 2xx | A non-2xx response leaves the HTTP response untouched. |

`filename` drives Winbindex only. The MinIO path always keys on `hash` and
ignores `filename`.

## Request flow

1. `GET /blob/<sha1>?filename=kernel32.dll`
2. `GET <WINBINDEX_DATA_URL>/kernel32.dll.json.gz` — gzipped JSON, top-level keys
are SHA-256, each value carries `fileInfo.timestamp`, `fileInfo.virtualSize`
and (about 70% of the time) `fileInfo.sha1`. The index is decompressed off the
event loop (async gunzip). The parsed result is cached in memory for 24h,
keyed by lowercased filename, bounded to 200 entries evicted least-recently-used.
A 404 is
cached as a negative result so a missing filename is not re-fetched on every
request. This in-memory negative cache is best-effort only: it is per-process,
unbounded in eviction pressure, and can be flushed by requests for 200 other
filenames, so a cache hit is a latency optimisation, not a reliability or
rate-limit guarantee.
3. The entry whose `fileInfo.sha1` matches `<sha1>` yields `timestamp` and
`virtualSize`.
4. `GET <WINBINDEX_SYMBOL_SERVER_URL>/<name>/<TS><VS>/<name>` where
`TS = timestamp.toString(16).toUpperCase().padStart(8, "0")` and
`VS = virtualSize.toString(16)` (lowercase, unpadded). Sent with
`User-Agent: Microsoft-Symbol-Server/10.0.0.0`, redirects followed.
5. The body is streamed to the client as
`Content-Type: application/octet-stream`,
`Content-Disposition: attachment; filename="<name>"`, `ETag: "<sha1>"`, and
`Content-Length` when the upstream provides it and did not content-encode the
body (undici may have transparently decompressed it). `res.write()`
backpressure is honoured so a slow client cannot force the whole PE to buffer
in memory. The SHA-1 is recomputed on the fly; if the final digest does not
match `<sha1>`, the response is destroyed mid-transfer so the client sees a
failed download. A stream error *before* the first byte falls back to MinIO;
an error after bytes were sent ends the response as a failure.

The `symbols` plugin in the OSWatcher collector already downloads PDBs from this
same server, so no new external trust boundary is introduced.

## Configuration

All four variables are optional and have defaults (`src/index.ts`).

| Variable | Default | Purpose |
|----------|---------|---------|
| `WINBINDEX_ENABLED` | `true` | Enable the Winbindex fast path for Windows PE blob downloads. |
| `WINBINDEX_DATA_URL` | `https://winbindex.m417z.com/data/by_filename_compressed` | Winbindex per-filename JSON index host. |
| `WINBINDEX_SYMBOL_SERVER_URL` | `https://msdl.microsoft.com/download/symbols` | Microsoft public symbol server (serves PE binaries by timestamp+size). |
| `WINBINDEX_FETCH_TIMEOUT_MS` | `15000` | Timeout for each Winbindex index / symbol-server request. |

## Failure modes

| Situation | Behaviour |
|-----------|-----------|
| Feature disabled, no `filename`, or non-PE extension | MinIO, no external call. |
| Winbindex index 404 / network error / timeout / bad JSON | MinIO. |
| No matching `fileInfo.sha1`, or match missing `timestamp` / `virtualSize` | MinIO. |
| Symbol server returns non-2xx, the fetch fails, or the stream errors before any byte is sent | MinIO; nothing was written to the response. |
| Symbol-server stream errors after bytes were sent | Response destroyed; MinIO is **not** retried. |
| Downloaded bytes hash to something other than `<sha1>` | Response destroyed after the fact; a warning is logged; MinIO is **not** retried. |

MinIO is never written to by this feature — it is a pure proxy, `GetObject`
only.

## Source Code

- **Winbindex path**: `src/winbindex.ts` (`tryServeFromWinbindex`)
- **Integration point**: `src/rest-routes.ts` (`GET /:hash` handler)
- **Configuration**: `src/index.ts`
26 changes: 25 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHt
import { readFileSync } from "fs";
import neo4j from "neo4j-driver";
import * as dotenv from "dotenv";
import { cleanEnv, str, url } from "envalid";
import { cleanEnv, str, url, bool, num } from "envalid";
import { createConstraintsIfNotExists } from "./constraints.js";
import { resolvers } from "./resolvers.js";
import { createRestRouter } from "./rest-routes.js";
Expand Down Expand Up @@ -55,6 +55,24 @@ const env = cleanEnv(process.env, {
default: "",
desc: "Comma-separated list of sensitive registry value names to redact",
}),
// Winbindex fast path for Windows PE blob downloads -- see
// docs/reference/winbindex-source.md
WINBINDEX_ENABLED: bool({
default: true,
desc: "Enable the Winbindex fast path for Windows PE blob downloads",
}),
WINBINDEX_DATA_URL: url({
default: "https://winbindex.m417z.com/data/by_filename_compressed",
desc: "Winbindex per-filename JSON index host",
}),
WINBINDEX_SYMBOL_SERVER_URL: url({
default: "https://msdl.microsoft.com/download/symbols",
desc: "Microsoft public symbol server (serves PE binaries by timestamp+size)",
}),
WINBINDEX_FETCH_TIMEOUT_MS: num({
default: 15000,
desc: "Timeout for each Winbindex index / symbol-server request",
}),
});

// Neo4j driver instance
Expand Down Expand Up @@ -322,6 +340,12 @@ async function main() {
env.MINIO_ACCESS_KEY,
env.MINIO_SECRET_KEY,
env.MINIO_OBJECTS_BUCKET_NAME,
{
enabled: env.WINBINDEX_ENABLED,
dataUrl: env.WINBINDEX_DATA_URL,
symbolServerUrl: env.WINBINDEX_SYMBOL_SERVER_URL,
timeoutMs: env.WINBINDEX_FETCH_TIMEOUT_MS,
},
),
);

Expand Down
33 changes: 33 additions & 0 deletions src/rest-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { Router, Request, Response } from "express";
import { BlobHashParamSchema } from "./validation.js";
import { ZodError } from "zod";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { WinbindexConfig, tryServeFromWinbindex } from "./winbindex.js";

export const createRestRouter = (
objectStorageUri: string,
minioAccessKey: string,
minioSecretKey: string,
minioObjectsBucketName: string,
winbindexConfig: WinbindexConfig,
) => {
const router = Router();

Expand All @@ -30,6 +32,37 @@ export const createRestRouter = (

console.log(`Blob download requested: ${hash}`);

// Winbindex fast path: for Windows PE files the frontend passes
// `?filename=`, letting us resolve the file on Winbindex and stream
// verified bytes from Microsoft's symbol server instead of MinIO.
// Any miss or failure falls through to MinIO unchanged; a failure
// after bytes were already streamed ends the response here.
const filename =
typeof req.query.filename === "string"
? req.query.filename
: undefined;
if (filename) {
try {
const outcome = await tryServeFromWinbindex(
winbindexConfig,
hash,
filename,
res,
);
if (
outcome === "served" ||
outcome === "failed_after_send"
) {
return;
}
} catch (winbindexError) {
console.warn(
"Winbindex fast path threw, falling back to MinIO:",
winbindexError,
);
}
}

// Fetch blob from MinIO using S3 SDK
const command = new GetObjectCommand({
Bucket: minioObjectsBucketName,
Expand Down
Loading
Loading