diff --git a/docs/server-hardening.md b/docs/server-hardening.md new file mode 100644 index 0000000..d9cd548 --- /dev/null +++ b/docs/server-hardening.md @@ -0,0 +1,102 @@ +# ChainProof REST Server Hardening & Multi-Tenant Controls + +This document details the production-grade architecture, authentication primitives, durable priority job queue, tenant sandboxing, quota controls, error sanitization, and operational monitoring interfaces added to the ChainProof REST Server. + +--- + +## 1. Security Architecture & Threat Model + +### Security Boundaries +The ChainProof REST Server acts as a multi-tenant analysis platform where untrusted users can submit arbitrary Solidity source files or archive payloads. To protect against malicious or adversarial payloads, the server implements strict security boundaries: + +1. **Authentication Boundary**: Principals are verified via prefixed API keys (`cp_live_...`) hashed with SHA-256 or via OIDC claims (JWTs). +2. **Authorization Boundary**: Scope-based RBAC (`scan:create`, `scan:read`, `scan:cancel`, `scan:delete`, `jobs:manage`, `metrics:read`, `audit:read`, `keys:manage`, `tenant:manage`). +3. **Execution Sandbox Boundary**: Scans run in isolated, single-use per-job file sandboxes (`chainproof-sandboxes/tenant-/job-`) with scrubbed environment variables. +4. **Archive & Bomb Safeguards**: `.zip` archive payloads undergo strict compression ratio checks (max 100:1), uncompressed size limits (max 50MB aggregate, 10MB per file), file count caps (max 500), and path traversal checks (`..`, absolute paths, symlinks). +5. **Data Sanitization & Privacy Boundary**: Errors, stack traces, and findings are scrubbed of host filesystem paths, API keys, and environment secrets before being returned to clients or logged in audit stores. +6. **LLM Transmission Policy Boundary**: Source code transmission to external LLM providers is disabled by default (`allowLLM: false`). + +--- + +## 2. Authentication & Principal RBAC + +### Principals & Roles +- **Roles**: `admin`, `tenant-admin`, `operator`, `viewer`. +- **Scopes**: + - `scan:create`: Submit synchronous or queued scans. + - `scan:read`: Read scan progress, status, and findings. + - `scan:cancel`: Request job cancellation. + - `scan:delete`: Delete job records and results. + - `jobs:manage`: Manage worker queue and job retention. + - `metrics:read`: Read queue, quota, and server metrics. + - `audit:read`: Query tenant audit logs. + - `keys:manage`: Create, rotate, or revoke API keys. + +### API Key Lifecycle & Rotation +- **Prefix**: All production API keys begin with `cp_live_`. +- **Rotation**: Key rotation (`POST /auth/keys/rotate`) issues a new key while maintaining a configurable grace period (default: 24h) during which the retiring key remains valid for smooth client migration. +- **Revocation**: Key revocation (`DELETE /auth/keys/:id`) immediately invalidates a key and logs the revocation reason. + +--- + +## 3. Durable Priority Job Queue & Worker Architecture + +### Job Lifecycle +`queued` ➔ `running` ➔ (`completed` | `failed` | `cancelled` | `timed_out`) + +- **Priorities**: 0 (Critical/Urgent) to 3 (Low). Jobs are dequeued strictly in order of priority, then submission time. +- **Idempotency**: Submitting with an `idempotencyKey` returns the existing job if submitted within the active retention window. +- **Leases & Heartbeats**: Workers acquire a timed lease (default: 30s) and send periodic heartbeats (default: 5s). +- **Crash Recovery**: Stale leases (due to worker crash or unhandled process termination) are automatically detected during periodic sweeps and requeued if `attempts < maxRetries`. +- **Cancellation**: Jobs can be cancelled at any point (`POST /jobs/:id/cancel`). AbortSignals terminate running worker tasks and clean up sandbox temp directories. +- **Status Streaming**: Server-Sent Events (SSE) stream progress (`GET /jobs/:id/stream`) in real-time. + +--- + +## 4. Multi-Tenant Quota & Resource Controls + +Per-tenant resource quotas are actively enforced with structured JSON error responses: + +- **Max Concurrent Jobs**: Default 2 active jobs per tenant. +- **Submission Rate Limits**: Default 100 job submissions per hour. +- **Storage Limits**: Default 500MB total stored results per tenant. +- **Compute Time Limits**: Cumulative CPU scan duration limit per tenant window. + +When a quota is exceeded, the server returns HTTP `429 Too Many Requests` or `413 Payload Too Large` with a structured payload: + +```json +{ + "error": "Tenant 'tenant-acme' has reached maximum concurrent job limit (2/2)", + "code": "QUOTA_EXCEEDED_CONCURRENCY", + "tenantId": "tenant-acme", + "metric": "concurrency", + "limit": 2, + "current": 2, + "retryAfterSeconds": 15 +} +``` + +--- + +## 5. API Reference + +### Health & Readiness +- `GET /health/live`: Liveness check. +- `GET /health/ready`: System readiness check, returning queue health and Slither status. + +### Scan & Job Management +- `POST /scan`: Synchronous scan with inline files or zip payload. +- `POST /jobs`: Queue scan job (accepts `files` array or `archiveBase64`, `priority`, `idempotencyKey`). +- `GET /jobs`: Paginated job list (filtered by tenant, status, search query). +- `GET /jobs/:id`: Single job status and result. +- `GET /jobs/:id/stream`: SSE progress and completion stream. +- `POST /jobs/:id/cancel`: Cancel job execution. +- `DELETE /jobs/:id`: Delete job result and record. + +### Key Management & Operations +- `POST /auth/keys`: Generate API key. +- `GET /auth/keys`: List API keys for tenant. +- `POST /auth/keys/rotate`: Rotate API key with grace period. +- `DELETE /auth/keys/:id`: Revoke API key. +- `GET /audit`: Query audit logs. +- `GET /metrics`: Fetch queue, quota usage, and process metrics. diff --git a/packages/core/src/audit/audit-logger.ts b/packages/core/src/audit/audit-logger.ts new file mode 100644 index 0000000..b59b3c1 --- /dev/null +++ b/packages/core/src/audit/audit-logger.ts @@ -0,0 +1,100 @@ +import * as crypto from "crypto"; +import { AuditEvent, AuditEventType, AuditFilter } from "./types"; +import { ErrorSanitizer } from "../isolation/sanitizer"; + +export class AuditLogger { + private events: AuditEvent[] = []; + private maxMemoryEvents: number; + + constructor(maxMemoryEvents = 10000) { + this.maxMemoryEvents = maxMemoryEvents; + } + + public record(params: { + type: AuditEventType; + tenantId: string; + projectId?: string; + principalId: string; + action: string; + status: AuditEvent["status"]; + ipAddress?: string; + details?: Record; + }): AuditEvent { + const id = `evt_${crypto.randomBytes(8).toString("hex")}`; + const timestamp = Date.now(); + + let sanitizedDetails: Record | undefined; + if (params.details) { + sanitizedDetails = JSON.parse( + ErrorSanitizer.sanitizePath(JSON.stringify(params.details)) + ); + } + + const event: AuditEvent = { + id, + timestamp, + type: params.type, + tenantId: params.tenantId, + projectId: params.projectId, + principalId: params.principalId, + action: params.action, + status: params.status, + ipAddress: params.ipAddress, + details: sanitizedDetails, + }; + + this.events.push(event); + + if (this.events.length > this.maxMemoryEvents) { + this.events.shift(); + } + + return event; + } + + public query(filter: AuditFilter = {}): { events: AuditEvent[]; total: number; offset: number; limit: number } { + let filtered = [...this.events]; + + if (filter.tenantId) { + filtered = filtered.filter((e) => e.tenantId === filter.tenantId); + } + + if (filter.projectId) { + filtered = filtered.filter((e) => e.projectId === filter.projectId); + } + + if (filter.principalId) { + filtered = filtered.filter((e) => e.principalId === filter.principalId); + } + + if (filter.type) { + const types = Array.isArray(filter.type) ? filter.type : [filter.type]; + filtered = filtered.filter((e) => types.includes(e.type)); + } + + if (filter.status) { + filtered = filtered.filter((e) => e.status === filter.status); + } + + if (filter.sinceTimestamp) { + filtered = filtered.filter((e) => e.timestamp >= filter.sinceTimestamp!); + } + + if (filter.untilTimestamp) { + filtered = filtered.filter((e) => e.timestamp <= filter.untilTimestamp!); + } + + filtered.sort((a, b) => b.timestamp - a.timestamp); + + const total = filtered.length; + const offset = filter.offset ?? 0; + const limit = filter.limit ?? 100; + const page = filtered.slice(offset, offset + limit); + + return { events: page, total, offset, limit }; + } + + public clear(): void { + this.events = []; + } +} diff --git a/packages/core/src/audit/index.ts b/packages/core/src/audit/index.ts new file mode 100644 index 0000000..95167e8 --- /dev/null +++ b/packages/core/src/audit/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./audit-logger"; diff --git a/packages/core/src/audit/types.ts b/packages/core/src/audit/types.ts new file mode 100644 index 0000000..fc75d41 --- /dev/null +++ b/packages/core/src/audit/types.ts @@ -0,0 +1,41 @@ +export type AuditEventType = + | "auth.login_success" + | "auth.login_failure" + | "auth.key_created" + | "auth.key_rotated" + | "auth.key_revoked" + | "job.submitted" + | "job.started" + | "job.completed" + | "job.failed" + | "job.cancelled" + | "job.deleted" + | "quota.exceeded" + | "policy.violation" + | "system.startup" + | "system.shutdown"; + +export interface AuditEvent { + id: string; + timestamp: number; + type: AuditEventType; + tenantId: string; + projectId?: string; + principalId: string; + ipAddress?: string; + action: string; + status: "success" | "failure" | "denied"; + details?: Record; +} + +export interface AuditFilter { + tenantId?: string; + projectId?: string; + principalId?: string; + type?: AuditEventType | AuditEventType[]; + status?: "success" | "failure" | "denied"; + sinceTimestamp?: number; + untilTimestamp?: number; + limit?: number; + offset?: number; +} diff --git a/packages/core/src/auth/apikey.ts b/packages/core/src/auth/apikey.ts new file mode 100644 index 0000000..31ac29e --- /dev/null +++ b/packages/core/src/auth/apikey.ts @@ -0,0 +1,144 @@ +import * as crypto from "crypto"; +import { ApiKeyRecord, CreateApiKeyOptions, Principal, RotateApiKeyOptions } from "./types"; +import { createPrincipal } from "./principal"; + +export class ApiKeyManager { + private keysById: Map = new Map(); + private keysByHash: Map = new Map(); + + constructor(initialKeys: ApiKeyRecord[] = []) { + for (const key of initialKeys) { + this.registerKeyRecord(key); + } + } + + public static hashKey(rawKey: string): string { + return crypto.createHash("sha256").update(rawKey).digest("hex"); + } + + public static generateRawKey(prefix = "cp_live_"): string { + const bytes = crypto.randomBytes(24).toString("hex"); + return `${prefix}${bytes}`; + } + + private registerKeyRecord(record: ApiKeyRecord): void { + this.keysById.set(record.id, record); + this.keysByHash.set(record.keyHash, record); + } + + public createApiKey(options: CreateApiKeyOptions): { rawKey: string; record: ApiKeyRecord } { + const rawKey = ApiKeyManager.generateRawKey(); + const keyHash = ApiKeyManager.hashKey(rawKey); + const id = `key_${crypto.randomBytes(8).toString("hex")}`; + const prefix = rawKey.slice(0, 12); + + const record: ApiKeyRecord = { + id, + tenantId: options.tenantId, + projectId: options.projectId, + name: options.name, + keyHash, + prefix, + roles: options.roles ?? ["operator"], + scopes: options.scopes ?? [], + createdAt: Date.now(), + expiresAt: options.expiresInMs ? Date.now() + options.expiresInMs : undefined, + rateLimitTier: options.rateLimitTier ?? "standard", + }; + + this.registerKeyRecord(record); + return { rawKey, record }; + } + + public authenticateApiKey(rawKey: string): { principal?: Principal; error?: string } { + if (!rawKey || typeof rawKey !== "string") { + return { error: "API key is required" }; + } + + const keyHash = ApiKeyManager.hashKey(rawKey); + const record = this.keysByHash.get(keyHash); + + if (!record) { + return { error: "Invalid API key" }; + } + + const now = Date.now(); + + if (record.revokedAt) { + return { error: `API key revoked: ${record.revocationReason ?? "No reason given"}` }; + } + + if (record.expiresAt && record.expiresAt < now) { + if (record.gracePeriodExpiresAt && record.gracePeriodExpiresAt >= now) { + // Valid during grace period + } else { + return { error: "API key expired" }; + } + } + + record.lastUsedAt = now; + + const principal = createPrincipal({ + id: `usr_${record.tenantId}_${record.id}`, + tenantId: record.tenantId, + projectId: record.projectId, + roles: record.roles, + scopes: record.scopes, + authMethod: "api-key", + keyId: record.id, + rateLimitTier: record.rateLimitTier, + metadata: { keyName: record.name }, + }); + + return { principal }; + } + + public rotateKey(options: RotateApiKeyOptions): { newRawKey: string; newRecord: ApiKeyRecord; oldRecord: ApiKeyRecord } { + const oldRecord = this.keysById.get(options.keyId); + if (!oldRecord) { + throw new Error(`API key not found: ${options.keyId}`); + } + + const gracePeriodMs = options.gracePeriodMs ?? 86400000; + const now = Date.now(); + + oldRecord.gracePeriodExpiresAt = now + gracePeriodMs; + oldRecord.expiresAt = now; + + const { rawKey: newRawKey, record: newRecord } = this.createApiKey({ + tenantId: oldRecord.tenantId, + projectId: oldRecord.projectId, + name: `${oldRecord.name} (Rotated)`, + roles: [...oldRecord.roles], + scopes: [...oldRecord.scopes], + rateLimitTier: oldRecord.rateLimitTier, + }); + + return { newRawKey, newRecord, oldRecord }; + } + + public revokeKey(keyId: string, reason?: string): ApiKeyRecord { + const record = this.keysById.get(keyId); + if (!record) { + throw new Error(`API key not found: ${keyId}`); + } + + record.revokedAt = Date.now(); + record.revocationReason = reason ?? "Revoked by administrator"; + return record; + } + + public getKey(keyId: string): ApiKeyRecord | undefined { + return this.keysById.get(keyId); + } + + public listKeysForTenant(tenantId: string): ApiKeyRecord[] { + const results: ApiKeyRecord[] = []; + for (const key of this.keysById.values()) { + if (key.tenantId === tenantId) { + results.push(key); + } + } + return results; + } +} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts new file mode 100644 index 0000000..4031552 --- /dev/null +++ b/packages/core/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./principal"; +export * from "./apikey"; +export * from "./oidc"; diff --git a/packages/core/src/auth/oidc.ts b/packages/core/src/auth/oidc.ts new file mode 100644 index 0000000..7ab9eaa --- /dev/null +++ b/packages/core/src/auth/oidc.ts @@ -0,0 +1,98 @@ +import { OIDCConfig, OIDCPayload, Principal, Role, Scope } from "./types"; +import { createPrincipal } from "./principal"; + +export class OIDCVerifier { + private config: OIDCConfig; + + constructor(config: OIDCConfig) { + this.config = config; + } + + public static parseUnverifiedToken(jwtToken: string): { header: Record; payload: OIDCPayload } { + const parts = jwtToken.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT token structure: expected 3 header.payload.signature parts"); + } + + try { + const headerJson = Buffer.from(parts[0], "base64url").toString("utf-8"); + const payloadJson = Buffer.from(parts[1], "base64url").toString("utf-8"); + return { + header: JSON.parse(headerJson), + payload: JSON.parse(payloadJson), + }; + } catch { + throw new Error("Failed to decode JWT base64url payload"); + } + } + + public verifyClaims(payload: OIDCPayload): { principal?: Principal; error?: string } { + const nowSec = Math.floor(Date.now() / 1000); + + if (payload.exp && payload.exp < nowSec) { + return { error: "OIDC token has expired" }; + } + + if (payload.nbf && payload.nbf > nowSec) { + return { error: "OIDC token is not valid yet (nbf)" }; + } + + if (this.config.issuer && payload.iss !== this.config.issuer) { + return { error: `Invalid OIDC token issuer: expected ${this.config.issuer}, got ${payload.iss}` }; + } + + if (this.config.audience) { + const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (!auds.includes(this.config.audience)) { + return { error: `Invalid OIDC token audience: expected ${this.config.audience}` }; + } + } + + const mapping = this.config.claimMapping ?? {}; + + const tenantId = (mapping.tenantClaim ? payload[mapping.tenantClaim] : payload.tenant_id) as string ?? "default-tenant"; + const projectId = (mapping.projectClaim ? payload[mapping.projectClaim] : payload.project_id) as string | undefined; + const rawRoles = (mapping.rolesClaim ? payload[mapping.rolesClaim] : payload.roles) as string[] | undefined; + const rawScopes = (mapping.scopesClaim ? payload[mapping.scopesClaim] : payload.scopes) as string[] | undefined; + + const roles: Role[] = (rawRoles ?? ["operator"]).filter( + (r): r is Role => ["admin", "tenant-admin", "operator", "viewer"].includes(r) + ); + + const validScopes: Scope[] = [ + "scan:create", + "scan:read", + "scan:cancel", + "scan:delete", + "jobs:manage", + "metrics:read", + "audit:read", + "tenant:manage", + "keys:manage", + ]; + + const scopes: Scope[] = (rawScopes ?? []).filter((s): s is Scope => validScopes.includes(s as Scope)); + + const principal = createPrincipal({ + id: `oidc_${payload.sub}`, + tenantId, + projectId, + roles: roles.length > 0 ? roles : ["operator"], + scopes, + authMethod: "oidc-claim", + metadata: { issuer: payload.iss, sub: payload.sub }, + }); + + return { principal }; + } + + public authenticateToken(jwtToken: string): { principal?: Principal; error?: string } { + try { + const { payload } = OIDCVerifier.parseUnverifiedToken(jwtToken); + return this.verifyClaims(payload); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { error: `OIDC authentication failed: ${message}` }; + } + } +} diff --git a/packages/core/src/auth/principal.ts b/packages/core/src/auth/principal.ts new file mode 100644 index 0000000..d45705b --- /dev/null +++ b/packages/core/src/auth/principal.ts @@ -0,0 +1,91 @@ +import { Principal, Role, Scope } from "./types"; + +export const DEFAULT_ROLE_SCOPES: Record = { + admin: [ + "scan:create", + "scan:read", + "scan:cancel", + "scan:delete", + "jobs:manage", + "metrics:read", + "audit:read", + "tenant:manage", + "keys:manage", + ], + "tenant-admin": [ + "scan:create", + "scan:read", + "scan:cancel", + "scan:delete", + "jobs:manage", + "metrics:read", + "audit:read", + "keys:manage", + ], + operator: ["scan:create", "scan:read", "scan:cancel", "metrics:read"], + viewer: ["scan:read", "metrics:read"], +}; + +export const SYSTEM_PRINCIPAL: Principal = { + id: "system-internal", + tenantId: "system", + roles: ["admin"], + scopes: DEFAULT_ROLE_SCOPES.admin, + authMethod: "system", +}; + +export function createPrincipal(params: { + id: string; + tenantId: string; + projectId?: string; + roles?: Role[]; + scopes?: Scope[]; + authMethod: Principal["authMethod"]; + keyId?: string; + rateLimitTier?: Principal["rateLimitTier"]; + metadata?: Record; +}): Principal { + const roles = params.roles ?? ["operator"]; + const scopeSet = new Set(params.scopes ?? []); + + for (const role of roles) { + const defaultScopes = DEFAULT_ROLE_SCOPES[role] ?? []; + for (const scope of defaultScopes) { + scopeSet.add(scope); + } + } + + return { + id: params.id, + tenantId: params.tenantId, + projectId: params.projectId, + roles, + scopes: Array.from(scopeSet), + authMethod: params.authMethod, + keyId: params.keyId, + rateLimitTier: params.rateLimitTier ?? "standard", + metadata: params.metadata, + }; +} + +export function hasScope(principal: Principal, scope: Scope): boolean { + if (principal.scopes.includes(scope)) return true; + if (principal.roles.includes("admin")) return true; + return false; +} + +export function hasRole(principal: Principal, role: Role): boolean { + if (principal.roles.includes("admin")) return true; + return principal.roles.includes(role); +} + +export function canAccessTenant(principal: Principal, tenantId: string): boolean { + if (principal.roles.includes("admin")) return true; + return principal.tenantId === tenantId; +} + +export function canAccessProject(principal: Principal, tenantId: string, projectId?: string): boolean { + if (!canAccessTenant(principal, tenantId)) return false; + if (!principal.projectId || !projectId) return true; + return principal.projectId === projectId; +} diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts new file mode 100644 index 0000000..9bee675 --- /dev/null +++ b/packages/core/src/auth/types.ts @@ -0,0 +1,89 @@ +export type Role = "admin" | "tenant-admin" | "operator" | "viewer"; + +export type Scope = + | "scan:create" + | "scan:read" + | "scan:cancel" + | "scan:delete" + | "jobs:manage" + | "metrics:read" + | "audit:read" + | "tenant:manage" + | "keys:manage"; + +export type AuthMethod = "api-key" | "oidc-claim" | "bearer-token" | "system"; + +export interface Principal { + id: string; + tenantId: string; + projectId?: string; + roles: Role[]; + scopes: Scope[]; + authMethod: AuthMethod; + keyId?: string; + metadata?: Record; + rateLimitTier?: "free" | "standard" | "enterprise"; +} + +export interface ApiKeyRecord { + id: string; + tenantId: string; + projectId?: string; + name: string; + keyHash: string; + prefix: string; + roles: Role[]; + scopes: Scope[]; + createdAt: number; + expiresAt?: number; + lastUsedAt?: number; + revokedAt?: number; + revocationReason?: string; + rateLimitTier?: "free" | "standard" | "enterprise"; + gracePeriodExpiresAt?: number; +} + +export interface CreateApiKeyOptions { + tenantId: string; + projectId?: string; + name: string; + roles?: Role[]; + scopes?: Scope[]; + expiresInMs?: number; + rateLimitTier?: "free" | "standard" | "enterprise"; +} + +export interface RotateApiKeyOptions { + keyId: string; + gracePeriodMs?: number; +} + +export interface OIDCClaimMapping { + subClaim?: string; + tenantClaim?: string; + projectClaim?: string; + rolesClaim?: string; + scopesClaim?: string; +} + +export interface OIDCConfig { + issuer: string; + audience: string; + jwksUri?: string; + claimMapping?: OIDCClaimMapping; + allowUnverifiedInDev?: boolean; +} + +export interface OIDCPayload { + iss: string; + sub: string; + aud: string | string[]; + exp: number; + nbf?: number; + iat?: number; + tenant_id?: string; + project_id?: string; + roles?: string[]; + scopes?: string[]; + [key: string]: unknown; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d002a1..7634a6d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -210,11 +210,13 @@ export type { ValidatedStakingConfig, } from "./staking"; -// ─── Governance / timelock safety analysis ────────────────────────────────── -export * from "./governance"; +// ─── Auth, Queue, Isolation, Quota, Audit ──────────────────────────────────── +export * from "./auth"; +export * from "./queue"; +export * from "./isolation"; +export * from "./quota"; +export * from "./audit"; -// ─── Cross-chain bridge and message verification analysis ─────────────────── -export * from "./bridge"; export type { ParseSpecResult, MigrationResult, diff --git a/packages/core/src/isolation/archive.ts b/packages/core/src/isolation/archive.ts new file mode 100644 index 0000000..d0351ce --- /dev/null +++ b/packages/core/src/isolation/archive.ts @@ -0,0 +1,100 @@ +import * as zlib from "zlib"; +import { ArchiveExtractResult, IsolationLimits } from "./types"; +import { DEFAULT_ISOLATION_LIMITS } from "./sandbox"; + +export class SafeArchiveExtractor { + private limits: IsolationLimits; + + constructor(customLimits?: Partial) { + this.limits = { + ...DEFAULT_ISOLATION_LIMITS, + ...customLimits, + }; + } + + public extractZipBuffer(buffer: Buffer): ArchiveExtractResult { + let uncompressedTotalSize = 0; + const files: Array<{ path: string; content: string }> = []; + + let offset = 0; + const bufferLength = buffer.length; + + while (offset < bufferLength - 4) { + const signature = buffer.readUInt32LE(offset); + + if (signature === 0x04034b50) { + if (files.length >= this.limits.maxFileCount) { + throw new Error(`Zip bomb defense: file count exceeds max limit (${this.limits.maxFileCount})`); + } + + const compressionMethod = buffer.readUInt16LE(offset + 8); + const compressedSize = buffer.readUInt32LE(offset + 18); + const uncompressedSize = buffer.readUInt32LE(offset + 22); + const fileNameLength = buffer.readUInt16LE(offset + 26); + const extraFieldLength = buffer.readUInt16LE(offset + 28); + + const fileName = buffer.toString("utf-8", offset + 30, offset + 30 + fileNameLength); + const dataStart = offset + 30 + fileNameLength + extraFieldLength; + + if (fileName.includes("..") || fileName.startsWith("/") || fileName.startsWith("\\")) { + throw new Error(`Illegal path traversal attempt in zip archive entry: '${fileName}'`); + } + + if (compressedSize > 0 && uncompressedSize > 0) { + const ratio = uncompressedSize / compressedSize; + if (ratio > this.limits.maxCompressionRatio) { + throw new Error( + `Zip bomb defense: compression ratio (${ratio.toFixed(1)}:1) exceeds max allowed limit (${this.limits.maxCompressionRatio}:1)` + ); + } + } + + if (uncompressedSize > this.limits.maxSingleFileSizeBytes) { + throw new Error( + `Zip entry '${fileName}' uncompressed size (${uncompressedSize} bytes) exceeds single file limit (${this.limits.maxSingleFileSizeBytes} bytes)` + ); + } + + uncompressedTotalSize += uncompressedSize; + if (uncompressedTotalSize > this.limits.maxTotalSizeBytes) { + throw new Error( + `Zip bomb defense: total extracted size (${uncompressedTotalSize} bytes) exceeds limit (${this.limits.maxTotalSizeBytes} bytes)` + ); + } + + if (!fileName.endsWith("/") && !fileName.endsWith("\\")) { + const compressedData = buffer.subarray(dataStart, dataStart + compressedSize); + let uncompressedData: Buffer; + + if (compressionMethod === 0) { + uncompressedData = compressedData; + } else if (compressionMethod === 8) { + try { + uncompressedData = zlib.inflateRawSync(compressedData); + } catch (inflateErr) { + throw new Error(`Failed to decompress zip entry '${fileName}': ${inflateErr}`); + } + } else { + offset += 30 + fileNameLength + extraFieldLength + compressedSize; + continue; + } + + files.push({ + path: fileName, + content: uncompressedData.toString("utf-8"), + }); + } + + offset = dataStart + compressedSize; + } else { + offset++; + } + } + + return { + files, + totalSizeBytes: uncompressedTotalSize, + fileCount: files.length, + }; + } +} diff --git a/packages/core/src/isolation/index.ts b/packages/core/src/isolation/index.ts new file mode 100644 index 0000000..1b949d5 --- /dev/null +++ b/packages/core/src/isolation/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./sandbox"; +export * from "./archive"; +export * from "./sanitizer"; +export * from "./llm-policy"; diff --git a/packages/core/src/isolation/llm-policy.ts b/packages/core/src/isolation/llm-policy.ts new file mode 100644 index 0000000..21b37e8 --- /dev/null +++ b/packages/core/src/isolation/llm-policy.ts @@ -0,0 +1,39 @@ +import { TenantPolicy } from "./types"; +import { ScanConfig } from "../types"; + +export class TenantPolicyEnforcer { + private policy: TenantPolicy; + + constructor(policy?: Partial) { + this.policy = { + tenantId: policy?.tenantId ?? "default", + allowLLM: policy?.allowLLM ?? false, + allowSlither: policy?.allowSlither ?? true, + maxFilesPerScan: policy?.maxFilesPerScan ?? 100, + maxFileSize: policy?.maxFileSize ?? 10 * 1024 * 1024, + ...policy, + }; + } + + public enforceScanConfig(config: ScanConfig): ScanConfig { + const enforced = { ...config }; + + if (!this.policy.allowLLM && enforced.useLLM) { + console.warn( + `[TenantPolicyEnforcer] LLM transmission denied by tenant policy for tenant '${this.policy.tenantId}'. Disabling LLM.` + ); + enforced.useLLM = false; + enforced.apiKey = undefined; + } + + if (!this.policy.allowSlither && enforced.useSlither) { + enforced.useSlither = false; + } + + return enforced; + } + + public getPolicy(): TenantPolicy { + return { ...this.policy }; + } +} diff --git a/packages/core/src/isolation/sandbox.ts b/packages/core/src/isolation/sandbox.ts new file mode 100644 index 0000000..c3df475 --- /dev/null +++ b/packages/core/src/isolation/sandbox.ts @@ -0,0 +1,129 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { IsolationLimits, SandboxConfig } from "./types"; +import { JobInputFile } from "../queue/types"; + +export const DEFAULT_ISOLATION_LIMITS: IsolationLimits = { + maxTotalSizeBytes: 50 * 1024 * 1024, + maxSingleFileSizeBytes: 10 * 1024 * 1024, + maxFileCount: 500, + maxCompressionRatio: 100, + maxDepth: 10, +}; + +export class TenantSandbox { + private tenantId: string; + private jobId: string; + private sandboxDir: string; + private limits: IsolationLimits; + private writtenFiles: string[] = []; + + constructor(config: SandboxConfig) { + this.tenantId = config.tenantId.replace(/[^a-zA-Z0-9_-]/g, "_"); + this.jobId = config.jobId.replace(/[^a-zA-Z0-9_-]/g, "_"); + + this.limits = { + ...DEFAULT_ISOLATION_LIMITS, + ...config.limits, + }; + + const base = config.baseDir ?? path.join(os.tmpdir(), "chainproof-sandboxes"); + this.sandboxDir = path.join(base, `tenant-${this.tenantId}`, `job-${this.jobId}`); + + if (!fs.existsSync(this.sandboxDir)) { + fs.mkdirSync(this.sandboxDir, { recursive: true, mode: 0o700 }); + } + } + + public getSandboxDir(): string { + return this.sandboxDir; + } + + public getSanitizedEnv(): Record { + const cleanEnv: Record = {}; + const safeVars = [ + "PATH", + "HOME", + "USER", + "TMPDIR", + "LANG", + "LC_ALL", + "NODE_ENV", + "PYTHONPATH", + "SOLC_VERSION", + ]; + + for (const v of safeVars) { + if (process.env[v]) { + cleanEnv[v] = process.env[v]!; + } + } + + cleanEnv["CHAINPROOF_SANDBOX"] = "true"; + cleanEnv["CHAINPROOF_TENANT_ID"] = this.tenantId; + cleanEnv["CHAINPROOF_JOB_ID"] = this.jobId; + + return cleanEnv; + } + + public writeFiles(files: JobInputFile[]): string[] { + if (files.length > this.limits.maxFileCount) { + throw new Error(`Exceeded maximum file count limit: ${files.length} > ${this.limits.maxFileCount}`); + } + + let totalSize = 0; + const writtenPaths: string[] = []; + + for (const file of files) { + const contentBuffer = Buffer.from(file.content, "utf-8"); + const fileSize = contentBuffer.length; + + if (fileSize > this.limits.maxSingleFileSizeBytes) { + throw new Error( + `File '${file.path}' exceeds single file size limit (${fileSize} > ${this.limits.maxSingleFileSizeBytes} bytes)` + ); + } + + totalSize += fileSize; + if (totalSize > this.limits.maxTotalSizeBytes) { + throw new Error( + `Total file size exceeds limit (${totalSize} > ${this.limits.maxTotalSizeBytes} bytes)` + ); + } + + const normalizedPath = path.normalize(file.path).replace(/^(\.\.[/\\])+/, ""); + if (path.isAbsolute(normalizedPath) || normalizedPath.startsWith("..")) { + throw new Error(`Illegal path traversal detected in file path: '${file.path}'`); + } + + const fullPath = path.join(this.sandboxDir, normalizedPath); + + const relative = path.relative(this.sandboxDir, fullPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Path traversal escape detected: '${file.path}'`); + } + + const dirName = path.dirname(fullPath); + if (!fs.existsSync(dirName)) { + fs.mkdirSync(dirName, { recursive: true, mode: 0o700 }); + } + + fs.writeFileSync(fullPath, contentBuffer, { mode: 0o600 }); + writtenPaths.push(fullPath); + this.writtenFiles.push(fullPath); + } + + return writtenPaths; + } + + public cleanup(): void { + try { + if (fs.existsSync(this.sandboxDir)) { + fs.rmSync(this.sandboxDir, { recursive: true, force: true }); + } + } catch { + // Ignore + } + } +} diff --git a/packages/core/src/isolation/sanitizer.ts b/packages/core/src/isolation/sanitizer.ts new file mode 100644 index 0000000..db385c2 --- /dev/null +++ b/packages/core/src/isolation/sanitizer.ts @@ -0,0 +1,28 @@ +export class ErrorSanitizer { + public static sanitizePath(text: string, sandboxBaseDir?: string): string { + if (!text) return ""; + + let result = text; + + if (sandboxBaseDir) { + result = result.split(sandboxBaseDir).join("[sandbox]"); + } + + result = result.replace(/(\/tmp\/[a-zA-Z0-9_-]+|\/private\/var\/folders\/[^\s:]+)/g, "[temp_dir]"); + result = result.replace(/(\/Users\/[a-zA-Z0-9_-]+|\/home\/[a-zA-Z0-9_-]+|C:\\Users\\[a-zA-Z0-9_-]+)/g, "[user_home]"); + result = result.replace(/(sk-ant-[a-zA-Z0-9_-]{10,}|cp_live_[a-zA-Z0-9]{10,}|bearer\s+[a-zA-Z0-9._-]{10,})/gi, "[REDACTED_SECRET]"); + + return result; + } + + public static sanitizeError(err: unknown, sandboxBaseDir?: string): Error { + const rawMessage = err instanceof Error ? err.message : String(err); + const cleanMessage = ErrorSanitizer.sanitizePath(rawMessage, sandboxBaseDir); + + const cleanErr = new Error(cleanMessage); + if (err instanceof Error && err.stack) { + cleanErr.stack = ErrorSanitizer.sanitizePath(err.stack, sandboxBaseDir); + } + return cleanErr; + } +} diff --git a/packages/core/src/isolation/types.ts b/packages/core/src/isolation/types.ts new file mode 100644 index 0000000..b46851c --- /dev/null +++ b/packages/core/src/isolation/types.ts @@ -0,0 +1,30 @@ +export interface IsolationLimits { + maxTotalSizeBytes: number; + maxSingleFileSizeBytes: number; + maxFileCount: number; + maxCompressionRatio: number; + maxDepth: number; +} + +export interface TenantPolicy { + tenantId: string; + allowLLM: boolean; + allowSlither: boolean; + maxFilesPerScan: number; + maxFileSize: number; + allowedImports?: string[]; + customLimits?: Partial; +} + +export interface SandboxConfig { + tenantId: string; + jobId: string; + baseDir?: string; + limits?: Partial; +} + +export interface ArchiveExtractResult { + files: Array<{ path: string; content: string }>; + totalSizeBytes: number; + fileCount: number; +} diff --git a/packages/core/src/queue/index.ts b/packages/core/src/queue/index.ts new file mode 100644 index 0000000..16173ab --- /dev/null +++ b/packages/core/src/queue/index.ts @@ -0,0 +1,5 @@ +export * from "./types"; +export * from "./persistence"; +export * from "./lease-manager"; +export * from "./job-queue"; +export * from "./worker"; diff --git a/packages/core/src/queue/job-queue.ts b/packages/core/src/queue/job-queue.ts new file mode 100644 index 0000000..80170f0 --- /dev/null +++ b/packages/core/src/queue/job-queue.ts @@ -0,0 +1,254 @@ +import * as crypto from "crypto"; +import { DurableJobStore } from "./persistence"; +import { JobFilter, JobPriority, QueueStats, ScanJob, SubmitJobOptions } from "./types"; + +export interface JobQueueManagerOptions { + store?: DurableJobStore; + defaultTimeoutMs?: number; + defaultMaxRetries?: number; + retentionPeriodMs?: number; +} + +export class JobQueueManager { + private store: DurableJobStore; + private defaultTimeoutMs: number; + private defaultMaxRetries: number; + private retentionPeriodMs: number; + + constructor(options: JobQueueManagerOptions = {}) { + this.store = options.store ?? new DurableJobStore(); + this.defaultTimeoutMs = options.defaultTimeoutMs ?? 60000; + this.defaultMaxRetries = options.defaultMaxRetries ?? 2; + this.retentionPeriodMs = options.retentionPeriodMs ?? 86400000 * 7; + } + + public getStore(): DurableJobStore { + return this.store; + } + + public submitJob(options: SubmitJobOptions): { job: ScanJob; deduplicated: boolean } { + const now = Date.now(); + + if (options.idempotencyKey) { + const existing = this.store + .values() + .find( + (j) => + j.tenantId === options.tenantId && + j.idempotencyKey === options.idempotencyKey && + j.status !== "cancelled" && + now - j.createdAt < this.retentionPeriodMs + ); + + if (existing) { + return { job: existing, deduplicated: true }; + } + } + + const jobId = `job_${crypto.randomBytes(10).toString("hex")}`; + const priority: JobPriority = options.priority ?? 2; + + const job: ScanJob = { + id: jobId, + tenantId: options.tenantId, + projectId: options.projectId, + principalId: options.principalId, + priority, + idempotencyKey: options.idempotencyKey, + files: options.files, + config: options.config ?? {}, + status: "queued", + progress: { + percentage: 0, + step: "queued", + message: "Job queued for processing", + timestamp: now, + }, + attempts: 0, + maxRetries: options.maxRetries ?? this.defaultMaxRetries, + timeoutMs: options.timeoutMs ?? this.defaultTimeoutMs, + cancelRequested: false, + createdAt: now, + updatedAt: now, + tags: options.tags, + }; + + this.store.set(job); + return { job, deduplicated: false }; + } + + public getJob(jobId: string): ScanJob | undefined { + return this.store.get(jobId); + } + + public dequeueNextJob(): ScanJob | undefined { + const queuedJobs = this.store + .values() + .filter((j) => j.status === "queued" && !j.cancelRequested); + + if (queuedJobs.length === 0) return undefined; + + queuedJobs.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority; + } + return a.createdAt - b.createdAt; + }); + + return queuedJobs[0]; + } + + public cancelJob(jobId: string, tenantId?: string): { success: boolean; job?: ScanJob; error?: string } { + const job = this.store.get(jobId); + if (!job) { + return { success: false, error: "Job not found" }; + } + + if (tenantId && job.tenantId !== tenantId) { + return { success: false, error: "Access denied to job" }; + } + + if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") { + return { success: false, error: `Cannot cancel job in terminal status '${job.status}'`, job }; + } + + job.cancelRequested = true; + job.updatedAt = Date.now(); + + if (job.status === "queued") { + job.status = "cancelled"; + job.progress = { + percentage: 100, + step: "cancelled", + message: "Job cancelled before execution", + timestamp: Date.now(), + }; + job.completedAt = Date.now(); + } + + this.store.set(job); + return { success: true, job }; + } + + public deleteJob(jobId: string, tenantId?: string): { success: boolean; error?: string } { + const job = this.store.get(jobId); + if (!job) { + return { success: false, error: "Job not found" }; + } + + if (tenantId && job.tenantId !== tenantId) { + return { success: false, error: "Access denied to job" }; + } + + this.store.delete(jobId); + return { success: true }; + } + + public listJobs(filter: JobFilter = {}): { jobs: ScanJob[]; total: number; offset: number; limit: number } { + let allJobs = this.store.values(); + + if (filter.tenantId) { + allJobs = allJobs.filter((j) => j.tenantId === filter.tenantId); + } + + if (filter.projectId) { + allJobs = allJobs.filter((j) => j.projectId === filter.projectId); + } + + if (filter.status) { + const statuses = Array.isArray(filter.status) ? filter.status : [filter.status]; + allJobs = allJobs.filter((j) => statuses.includes(j.status)); + } + + if (filter.search) { + const q = filter.search.toLowerCase(); + allJobs = allJobs.filter( + (j) => j.id.toLowerCase().includes(q) || j.files.some((f) => f.path.toLowerCase().includes(q)) + ); + } + + allJobs.sort((a, b) => b.createdAt - a.createdAt); + + const total = allJobs.length; + const offset = filter.offset ?? 0; + const limit = filter.limit ?? 50; + const page = allJobs.slice(offset, offset + limit); + + return { jobs: page, total, offset, limit }; + } + + public getStats(tenantId?: string): QueueStats { + let jobs = this.store.values(); + if (tenantId) { + jobs = jobs.filter((j) => j.tenantId === tenantId); + } + + let totalDurationMs = 0; + let completedCount = 0; + const activeTenants = new Set(); + + let queued = 0; + let running = 0; + let completed = 0; + let failed = 0; + let cancelled = 0; + let timedOut = 0; + + for (const job of jobs) { + activeTenants.add(job.tenantId); + switch (job.status) { + case "queued": + queued++; + break; + case "running": + running++; + break; + case "completed": + completed++; + if (job.startedAt && job.completedAt) { + totalDurationMs += job.completedAt - job.startedAt; + completedCount++; + } + break; + case "failed": + failed++; + break; + case "cancelled": + cancelled++; + break; + case "timed_out": + timedOut++; + break; + } + } + + return { + totalJobs: jobs.length, + queued, + running, + completed, + failed, + cancelled, + timedOut, + activeTenants: activeTenants.size, + avgDurationMs: completedCount > 0 ? Math.round(totalDurationMs / completedCount) : 0, + }; + } + + public purgeExpiredJobs(maxAgeMs?: number): number { + const ageCutoff = Date.now() - (maxAgeMs ?? this.retentionPeriodMs); + let purgedCount = 0; + + for (const job of this.store.values()) { + if ( + (job.status === "completed" || job.status === "failed" || job.status === "cancelled" || job.status === "timed_out") && + job.createdAt < ageCutoff + ) { + this.store.delete(job.id); + purgedCount++; + } + } + + return purgedCount; + } +} diff --git a/packages/core/src/queue/lease-manager.ts b/packages/core/src/queue/lease-manager.ts new file mode 100644 index 0000000..f6427f1 --- /dev/null +++ b/packages/core/src/queue/lease-manager.ts @@ -0,0 +1,62 @@ +import * as crypto from "crypto"; +import { ScanJob } from "./types"; + +export interface LeaseManagerOptions { + leaseDurationMs?: number; + heartbeatTimeoutMs?: number; +} + +export class LeaseManager { + private leaseDurationMs: number; + private heartbeatTimeoutMs: number; + + constructor(options: LeaseManagerOptions = {}) { + this.leaseDurationMs = options.leaseDurationMs ?? 30000; + this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 15000; + } + + public grantLease(job: ScanJob): string { + const leaseId = `lease_${crypto.randomBytes(8).toString("hex")}`; + const now = Date.now(); + + job.leaseId = leaseId; + job.leaseExpiresAt = now + this.leaseDurationMs; + job.heartbeatAt = now; + job.status = "running"; + job.startedAt = job.startedAt ?? now; + + return leaseId; + } + + public heartbeat(job: ScanJob, leaseId: string): boolean { + if (job.leaseId !== leaseId) { + return false; + } + + const now = Date.now(); + job.leaseExpiresAt = now + this.leaseDurationMs; + job.heartbeatAt = now; + return true; + } + + public releaseLease(job: ScanJob): void { + job.leaseId = undefined; + job.leaseExpiresAt = undefined; + job.heartbeatAt = undefined; + } + + public isLeaseExpired(job: ScanJob): boolean { + if (job.status !== "running") return false; + if (!job.leaseExpiresAt || !job.heartbeatAt) return true; + + const now = Date.now(); + if (now > job.leaseExpiresAt) return true; + if (now - job.heartbeatAt > this.heartbeatTimeoutMs) return true; + + return false; + } + + public findStaleJobs(jobs: ScanJob[]): ScanJob[] { + return jobs.filter((job) => this.isLeaseExpired(job)); + } +} diff --git a/packages/core/src/queue/persistence.ts b/packages/core/src/queue/persistence.ts new file mode 100644 index 0000000..a9ab826 --- /dev/null +++ b/packages/core/src/queue/persistence.ts @@ -0,0 +1,105 @@ +import * as fs from "fs"; +import * as path from "path"; +import { ScanJob } from "./types"; + +export interface DurableJobStoreOptions { + storageDir?: string; + autoSave?: boolean; +} + +export class DurableJobStore { + private jobs: Map = new Map(); + private storageDir?: string; + private autoSave: boolean; + + constructor(options: DurableJobStoreOptions = {}) { + this.storageDir = options.storageDir; + this.autoSave = options.autoSave ?? true; + + if (this.storageDir) { + if (!fs.existsSync(this.storageDir)) { + fs.mkdirSync(this.storageDir, { recursive: true }); + } + this.loadFromDisk(); + } + } + + public get(jobId: string): ScanJob | undefined { + return this.jobs.get(jobId); + } + + public set(job: ScanJob): void { + job.updatedAt = Date.now(); + this.jobs.set(job.id, job); + if (this.autoSave) { + this.saveJobToDisk(job); + } + } + + public delete(jobId: string): boolean { + const deleted = this.jobs.delete(jobId); + if (this.storageDir) { + const filePath = path.join(this.storageDir, `${jobId}.json`); + if (fs.existsSync(filePath)) { + try { + fs.unlinkSync(filePath); + } catch { + // ignore + } + } + } + return deleted; + } + + public values(): ScanJob[] { + return Array.from(this.jobs.values()); + } + + public clear(): void { + this.jobs.clear(); + if (this.storageDir && fs.existsSync(this.storageDir)) { + const files = fs.readdirSync(this.storageDir); + for (const file of files) { + if (file.endsWith(".json")) { + try { + fs.unlinkSync(path.join(this.storageDir, file)); + } catch { + // ignore + } + } + } + } + } + + private saveJobToDisk(job: ScanJob): void { + if (!this.storageDir) return; + try { + const filePath = path.join(this.storageDir, `${job.id}.json`); + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(job, null, 2), "utf-8"); + fs.renameSync(tmpPath, filePath); + } catch (err) { + console.error(`[DurableJobStore] Failed to persist job ${job.id}:`, err); + } + } + + private loadFromDisk(): void { + if (!this.storageDir || !fs.existsSync(this.storageDir)) return; + try { + const files = fs.readdirSync(this.storageDir); + for (const file of files) { + if (file.endsWith(".json")) { + try { + const content = fs.readFileSync(path.join(this.storageDir, file), "utf-8"); + const job = JSON.parse(content) as ScanJob; + this.jobs.set(job.id, job); + } catch { + // ignore + } + } + } + } catch (err) { + console.error("[DurableJobStore] Error loading jobs from disk:", err); + } + } +} diff --git a/packages/core/src/queue/types.ts b/packages/core/src/queue/types.ts new file mode 100644 index 0000000..7a9e1b7 --- /dev/null +++ b/packages/core/src/queue/types.ts @@ -0,0 +1,100 @@ +import { ScanConfig, ScanResult } from "../types"; + +export type JobStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled" + | "timed_out"; + +export type JobPriority = 0 | 1 | 2 | 3; + +export interface JobProgress { + percentage: number; + step: string; + message?: string; + timestamp: number; +} + +export interface JobInputFile { + path: string; + content: string; +} + +export interface ScanJob { + id: string; + tenantId: string; + projectId?: string; + principalId: string; + priority: JobPriority; + idempotencyKey?: string; + + files: JobInputFile[]; + config: Partial; + + status: JobStatus; + progress: JobProgress; + + leaseId?: string; + leaseExpiresAt?: number; + heartbeatAt?: number; + + attempts: number; + maxRetries: number; + timeoutMs: number; + cancelRequested: boolean; + + result?: ScanResult; + error?: string; + + createdAt: number; + updatedAt: number; + startedAt?: number; + completedAt?: number; + + tags?: Record; +} + +export interface SubmitJobOptions { + tenantId: string; + projectId?: string; + principalId: string; + priority?: JobPriority; + idempotencyKey?: string; + files: JobInputFile[]; + config?: Partial; + timeoutMs?: number; + maxRetries?: number; + tags?: Record; +} + +export interface JobFilter { + tenantId?: string; + projectId?: string; + status?: JobStatus | JobStatus[]; + limit?: number; + offset?: number; + search?: string; +} + +export interface QueueStats { + totalJobs: number; + queued: number; + running: number; + completed: number; + failed: number; + cancelled: number; + timedOut: number; + activeTenants: number; + avgDurationMs: number; +} + +export interface StreamEvent { + type: "progress" | "status" | "error" | "complete" | "heartbeat"; + jobId: string; + status: JobStatus; + progress: JobProgress; + data?: unknown; + timestamp: number; +} diff --git a/packages/core/src/queue/worker.ts b/packages/core/src/queue/worker.ts new file mode 100644 index 0000000..26da41d --- /dev/null +++ b/packages/core/src/queue/worker.ts @@ -0,0 +1,334 @@ +import { scan } from "../scanner"; +import { ScanConfig, ScanResult } from "../types"; +import { JobQueueManager } from "./job-queue"; +import { LeaseManager } from "./lease-manager"; +import { JobProgress, ScanJob, StreamEvent } from "./types"; +import { TenantSandbox } from "../isolation/sandbox"; +import { ErrorSanitizer } from "../isolation/sanitizer"; +import { TenantPolicyEnforcer } from "../isolation/llm-policy"; + +export type EventCallback = (event: StreamEvent) => void; + +export interface QueueWorkerOptions { + queueManager: JobQueueManager; + leaseManager?: LeaseManager; + concurrency?: number; + pollIntervalMs?: number; + heartbeatIntervalMs?: number; + sweepIntervalMs?: number; +} + +export class QueueWorker { + private queueManager: JobQueueManager; + private leaseManager: LeaseManager; + private concurrency: number; + private pollIntervalMs: number; + private heartbeatIntervalMs: number; + private sweepIntervalMs: number; + + private activeJobsCount = 0; + private running = false; + private pollTimer?: NodeJS.Timeout; + private sweepTimer?: NodeJS.Timeout; + + private listeners: Map> = new Map(); + private globalListeners: Set = new Set(); + private abortControllers: Map = new Map(); + + constructor(options: QueueWorkerOptions) { + this.queueManager = options.queueManager; + this.leaseManager = options.leaseManager ?? new LeaseManager(); + this.concurrency = options.concurrency ?? 2; + this.pollIntervalMs = options.pollIntervalMs ?? 500; + this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 5000; + this.sweepIntervalMs = options.sweepIntervalMs ?? 10000; + } + + public start(): void { + if (this.running) return; + this.running = true; + + this.pollTimer = setInterval(() => { + this.pollAndProcess(); + }, this.pollIntervalMs); + + this.sweepTimer = setInterval(() => { + this.sweepStaleLeases(); + }, this.sweepIntervalMs); + } + + public stop(): void { + this.running = false; + if (this.pollTimer) clearInterval(this.pollTimer); + if (this.sweepTimer) clearInterval(this.sweepTimer); + + for (const controller of this.abortControllers.values()) { + controller.abort(); + } + this.abortControllers.clear(); + } + + public subscribe(jobId: string, callback: EventCallback): () => void { + if (!this.listeners.has(jobId)) { + this.listeners.set(jobId, new Set()); + } + this.listeners.get(jobId)!.add(callback); + + return () => { + const set = this.listeners.get(jobId); + if (set) { + set.delete(callback); + if (set.size === 0) this.listeners.delete(jobId); + } + }; + } + + public subscribeAll(callback: EventCallback): () => void { + this.globalListeners.add(callback); + return () => { + this.globalListeners.delete(callback); + }; + } + + private emitEvent(event: StreamEvent): void { + const set = this.listeners.get(event.jobId); + if (set) { + for (const callback of set) { + try { + callback(event); + } catch { + // ignore + } + } + } + + for (const callback of this.globalListeners) { + try { + callback(event); + } catch { + // ignore + } + } + } + + private async pollAndProcess(): Promise { + if (!this.running || this.activeJobsCount >= this.concurrency) return; + + const job = this.queueManager.dequeueNextJob(); + if (!job) return; + + this.activeJobsCount++; + this.processJob(job).finally(() => { + this.activeJobsCount--; + }); + } + + public async processJob(job: ScanJob): Promise { + const leaseId = this.leaseManager.grantLease(job); + this.queueManager.getStore().set(job); + + const abortController = new AbortController(); + this.abortControllers.set(job.id, abortController); + + const heartbeatTimer = setInterval(() => { + const ok = this.leaseManager.heartbeat(job, leaseId); + if (!ok) { + abortController.abort(); + } else { + this.queueManager.getStore().set(job); + this.emitEvent({ + type: "heartbeat", + jobId: job.id, + status: job.status, + progress: job.progress, + timestamp: Date.now(), + }); + } + }, this.heartbeatIntervalMs); + + const timeoutTimer = setTimeout(() => { + job.status = "timed_out"; + job.error = `Job exceeded maximum allowed execution time of ${job.timeoutMs}ms`; + abortController.abort(); + }, job.timeoutMs); + + let sandbox: TenantSandbox | undefined; + + try { + this.updateProgress(job, 10, "preparing", "Creating tenant isolation sandbox..."); + + sandbox = new TenantSandbox({ + tenantId: job.tenantId, + jobId: job.id, + }); + + const sandboxPaths = sandbox.writeFiles(job.files); + + this.updateProgress(job, 30, "scanning", "Executing ChainProof contract analyzer..."); + + let scanConfig: ScanConfig = { + targets: sandboxPaths, + useSlither: job.config.useSlither ?? false, + useLLM: job.config.useLLM ?? false, + useMetrics: job.config.useMetrics ?? true, + minSeverity: job.config.minSeverity ?? "low", + apiKey: job.config.apiKey, + llmProvider: job.config.llmProvider, + llmModel: job.config.llmModel, + }; + + const policyEnforcer = new TenantPolicyEnforcer({ tenantId: job.tenantId }); + scanConfig = policyEnforcer.enforceScanConfig(scanConfig); + + if (job.cancelRequested || abortController.signal.aborted) { + throw new Error("Job execution cancelled"); + } + + const rawResult = await scan(scanConfig); + + if (job.cancelRequested || abortController.signal.aborted) { + throw new Error("Job execution cancelled"); + } + + this.updateProgress(job, 90, "remapping", "Remapping file paths and formatting result..."); + + const sandboxDir = sandbox.getSandboxDir(); + + const remappedResult: ScanResult = { + ...rawResult, + files: rawResult.files.map((fileResult) => { + const idx = sandboxPaths.findIndex((p) => p === fileResult.file); + const originalPath = idx !== -1 ? job.files[idx].path : fileResult.file; + + return { + ...fileResult, + file: originalPath, + findings: fileResult.findings.map((finding) => ({ + ...finding, + file: originalPath, + evidence: finding.evidence?.map((ev) => ({ + ...ev, + file: ev.file ? ErrorSanitizer.sanitizePath(ev.file, sandboxDir) : originalPath, + })), + })), + gasHints: fileResult.gasHints.map((hint) => ({ + ...hint, + file: originalPath, + })), + }; + }), + }; + + job.result = remappedResult; + job.status = "completed"; + job.completedAt = Date.now(); + this.updateProgress(job, 100, "completed", "Scan completed successfully"); + + this.emitEvent({ + type: "complete", + jobId: job.id, + status: job.status, + progress: job.progress, + data: remappedResult, + timestamp: Date.now(), + }); + } catch (err) { + const sandboxDir = sandbox?.getSandboxDir(); + const sanitizedErr = ErrorSanitizer.sanitizeError(err, sandboxDir); + const errMessage = sanitizedErr.message; + + if (job.cancelRequested || errMessage.includes("cancelled")) { + job.status = "cancelled"; + job.error = "Job was cancelled by user"; + this.updateProgress(job, 100, "cancelled", job.error); + } else if (job.status === "timed_out") { + this.updateProgress(job, 100, "timed_out", job.error ?? "Execution timed out"); + } else { + job.attempts++; + if (job.attempts < job.maxRetries) { + job.status = "queued"; + this.updateProgress( + job, + 0, + "queued", + `Scan attempt ${job.attempts} failed (${errMessage}). Requeued for retry.` + ); + } else { + job.status = "failed"; + job.error = errMessage; + job.completedAt = Date.now(); + this.updateProgress(job, 100, "failed", `Scan failed after ${job.attempts} attempt(s): ${errMessage}`); + } + } + + this.emitEvent({ + type: "error", + jobId: job.id, + status: job.status, + progress: job.progress, + data: { error: job.error }, + timestamp: Date.now(), + }); + } finally { + clearInterval(heartbeatTimer); + clearTimeout(timeoutTimer); + this.leaseManager.releaseLease(job); + this.abortControllers.delete(job.id); + this.queueManager.getStore().set(job); + + if (sandbox) { + sandbox.cleanup(); + } + } + } + + private updateProgress(job: ScanJob, percentage: number, step: string, message?: string): void { + const progress: JobProgress = { + percentage, + step, + message, + timestamp: Date.now(), + }; + job.progress = progress; + job.updatedAt = Date.now(); + this.queueManager.getStore().set(job); + + this.emitEvent({ + type: "progress", + jobId: job.id, + status: job.status, + progress, + timestamp: Date.now(), + }); + } + + public sweepStaleLeases(): number { + const allJobs = this.queueManager.getStore().values(); + const staleJobs = this.leaseManager.findStaleJobs(allJobs); + + let recovered = 0; + for (const job of staleJobs) { + this.leaseManager.releaseLease(job); + job.attempts++; + + if (job.attempts < job.maxRetries) { + job.status = "queued"; + job.progress = { + percentage: 0, + step: "queued", + message: "Stale worker lease detected. Job requeued automatically.", + timestamp: Date.now(), + }; + } else { + job.status = "failed"; + job.error = "Worker crash / stale lease timeout exceeded max retries"; + job.completedAt = Date.now(); + } + + this.queueManager.getStore().set(job); + recovered++; + } + + return recovered; + } +} diff --git a/packages/core/src/quota/index.ts b/packages/core/src/quota/index.ts new file mode 100644 index 0000000..2f937f4 --- /dev/null +++ b/packages/core/src/quota/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./quota-manager"; diff --git a/packages/core/src/quota/quota-manager.ts b/packages/core/src/quota/quota-manager.ts new file mode 100644 index 0000000..75ab45e --- /dev/null +++ b/packages/core/src/quota/quota-manager.ts @@ -0,0 +1,139 @@ +import { StructuredQuotaErrorPayload, TenantQuotaLimits, TenantQuotaUsage } from "./types"; +import { JobQueueManager } from "../queue/job-queue"; + +export const DEFAULT_TENANT_LIMITS: Omit = { + maxConcurrentJobs: 2, + maxJobsPerWindow: 100, + windowDurationMs: 3600000, + maxStorageBytes: 500 * 1024 * 1024, + maxComputeTimeMsPerWindow: 300000, +}; + +export class StructuredQuotaError extends Error { + public payload: StructuredQuotaErrorPayload; + + constructor(payload: StructuredQuotaErrorPayload) { + super(payload.error); + this.name = "StructuredQuotaError"; + this.payload = payload; + } +} + +export class QuotaManager { + private tenantLimits: Map = new Map(); + private tenantUsage: Map = new Map(); + + public setTenantLimits(tenantId: string, limits: Partial): TenantQuotaLimits { + const fullLimits: TenantQuotaLimits = { + tenantId, + ...DEFAULT_TENANT_LIMITS, + ...limits, + }; + this.tenantLimits.set(tenantId, fullLimits); + return fullLimits; + } + + public getTenantLimits(tenantId: string): TenantQuotaLimits { + return ( + this.tenantLimits.get(tenantId) ?? { + tenantId, + ...DEFAULT_TENANT_LIMITS, + } + ); + } + + public getTenantUsage(tenantId: string, queueManager?: JobQueueManager): TenantQuotaUsage { + const limits = this.getTenantLimits(tenantId); + const now = Date.now(); + + let usage = this.tenantUsage.get(tenantId); + + if (!usage || now >= usage.windowResetAt) { + usage = { + tenantId, + concurrentJobs: 0, + jobsInWindow: 0, + storageBytes: 0, + computeTimeMsInWindow: 0, + windowResetAt: now + limits.windowDurationMs, + }; + this.tenantUsage.set(tenantId, usage); + } + + if (queueManager) { + const activeJobs = queueManager + .getStore() + .values() + .filter((j) => j.tenantId === tenantId && (j.status === "queued" || j.status === "running")); + usage.concurrentJobs = activeJobs.length; + + const completedJobs = queueManager + .getStore() + .values() + .filter((j) => j.tenantId === tenantId && j.result); + + let totalStorage = 0; + for (const job of completedJobs) { + totalStorage += JSON.stringify(job.result).length; + } + usage.storageBytes = totalStorage; + } + + return usage; + } + + public checkAndRecordJobSubmission(tenantId: string, queueManager?: JobQueueManager): void { + const limits = this.getTenantLimits(tenantId); + const usage = this.getTenantUsage(tenantId, queueManager); + + if (usage.concurrentJobs >= limits.maxConcurrentJobs) { + throw new StructuredQuotaError({ + error: `Tenant '${tenantId}' has reached maximum concurrent job limit (${usage.concurrentJobs}/${limits.maxConcurrentJobs})`, + code: "QUOTA_EXCEEDED_CONCURRENCY", + tenantId, + metric: "concurrency", + limit: limits.maxConcurrentJobs, + current: usage.concurrentJobs, + retryAfterSeconds: 15, + }); + } + + if (usage.jobsInWindow >= limits.maxJobsPerWindow) { + const retryAfterSeconds = Math.max(1, Math.ceil((usage.windowResetAt - Date.now()) / 1000)); + throw new StructuredQuotaError({ + error: `Tenant '${tenantId}' exceeded job submission rate limit (${usage.jobsInWindow}/${limits.maxJobsPerWindow} per window)`, + code: "QUOTA_EXCEEDED_RATE_LIMIT", + tenantId, + metric: "rate_limit", + limit: limits.maxJobsPerWindow, + current: usage.jobsInWindow, + retryAfterSeconds, + }); + } + + if (usage.storageBytes >= limits.maxStorageBytes) { + throw new StructuredQuotaError({ + error: `Tenant '${tenantId}' exceeded storage quota limit (${(usage.storageBytes / 1024 / 1024).toFixed(1)}MB/${(limits.maxStorageBytes / 1024 / 1024).toFixed(1)}MB)`, + code: "QUOTA_EXCEEDED_STORAGE", + tenantId, + metric: "storage", + limit: limits.maxStorageBytes, + current: usage.storageBytes, + }); + } + + usage.jobsInWindow++; + } + + public recordComputeTime(tenantId: string, computeTimeMs: number): void { + const limits = this.getTenantLimits(tenantId); + const usage = this.getTenantUsage(tenantId); + usage.computeTimeMsInWindow += computeTimeMs; + + if (usage.computeTimeMsInWindow > limits.maxComputeTimeMsPerWindow) { + console.warn( + `[QuotaManager] Tenant '${tenantId}' exceeded compute time quota window limit (${usage.computeTimeMsInWindow}ms > ${limits.maxComputeTimeMsPerWindow}ms)` + ); + } + } +} diff --git a/packages/core/src/quota/types.ts b/packages/core/src/quota/types.ts new file mode 100644 index 0000000..b00a662 --- /dev/null +++ b/packages/core/src/quota/types.ts @@ -0,0 +1,27 @@ +export interface TenantQuotaLimits { + tenantId: string; + maxConcurrentJobs: number; + maxJobsPerWindow: number; + windowDurationMs: number; + maxStorageBytes: number; + maxComputeTimeMsPerWindow: number; +} + +export interface TenantQuotaUsage { + tenantId: string; + concurrentJobs: number; + jobsInWindow: number; + storageBytes: number; + computeTimeMsInWindow: number; + windowResetAt: number; +} + +export interface StructuredQuotaErrorPayload { + error: string; + code: string; + tenantId: string; + metric: "concurrency" | "rate_limit" | "storage" | "compute_time"; + limit: number; + current: number; + retryAfterSeconds?: number; +} diff --git a/packages/server/jest.config.js b/packages/server/jest.config.js new file mode 100644 index 0000000..674ee29 --- /dev/null +++ b/packages/server/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src"], + testMatch: ["**/__tests__/**/*.test.ts"], + globals: { + "ts-jest": { + tsconfig: { + types: ["jest", "node"], + }, + }, + }, +}; diff --git a/packages/server/package.json b/packages/server/package.json index dac4862..d87130f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "test": "jest", "start": "node dist/server.js" }, "dependencies": { @@ -18,6 +19,9 @@ "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/node": "^20.0.0" + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0" } } diff --git a/packages/server/src/__tests__/server-hardening.test.ts b/packages/server/src/__tests__/server-hardening.test.ts new file mode 100644 index 0000000..beca14c --- /dev/null +++ b/packages/server/src/__tests__/server-hardening.test.ts @@ -0,0 +1,218 @@ +import * as http from "http"; +import { AddressInfo } from "net"; +import { ApiKeyManager } from "@chainproof/core"; +import { startServer, ServerInstance } from "../server"; + +describe("REST Server Hardening Integration Tests", () => { + let serverInstance: ServerInstance; + let baseUrl: string; + let apiKeyManager: ApiKeyManager; + let adminRawKey: string; + let operatorRawKey: string; + + beforeAll(async () => { + apiKeyManager = new ApiKeyManager(); + + const admin = apiKeyManager.createApiKey({ + tenantId: "acme-corp", + name: "Admin Key", + roles: ["admin"], + scopes: [ + "scan:create", + "scan:read", + "scan:cancel", + "scan:delete", + "jobs:manage", + "metrics:read", + "audit:read", + "keys:manage", + ], + }); + adminRawKey = admin.rawKey; + + const operator = apiKeyManager.createApiKey({ + tenantId: "acme-corp", + name: "Operator Key", + roles: ["operator"], + scopes: ["scan:create", "scan:read", "scan:cancel"], + }); + operatorRawKey = operator.rawKey; + + serverInstance = await startServer({ + port: 0, + host: "127.0.0.1", + enableMultiTenant: true, + apiKeyManager, + }); + + const addr = serverInstance.server?.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterAll(async () => { + if (serverInstance) { + await serverInstance.close(); + } + }); + + function makeRequest( + method: string, + path: string, + apiKey?: string, + body?: Record + ): Promise<{ status: number; body: any }> { + return new Promise((resolve, reject) => { + const url = new URL(path, baseUrl); + const payload = body ? JSON.stringify(body) : undefined; + + const headers: Record = {}; + if (payload) { + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = String(Buffer.byteLength(payload)); + } + + if (apiKey) { + headers["X-API-Key"] = apiKey; + } + + const req = http.request( + url, + { + method, + headers, + }, + (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => { + try { + resolve({ + status: res.statusCode ?? 500, + body: data ? JSON.parse(data) : {}, + }); + } catch { + resolve({ + status: res.statusCode ?? 500, + body: data, + }); + } + }); + } + ); + + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); + } + + describe("Authentication & Authorization", () => { + it("should reject unauthenticated access to protected endpoints", async () => { + const res = await makeRequest("GET", "/jobs"); + expect(res.status).toBe(401); + expect(res.body.error).toContain("Unauthorized"); + }); + + it("should allow public health checks without auth", async () => { + const res = await makeRequest("GET", "/health/ready"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ready"); + }); + + it("should allow access with valid API key", async () => { + const res = await makeRequest("GET", "/jobs", operatorRawKey); + expect(res.status).toBe(200); + expect(res.body.jobs).toBeDefined(); + }); + }); + + describe("Job Queue Lifecycle", () => { + it("should submit, process, and query a scan job", async () => { + const submitRes = await makeRequest("POST", "/jobs", operatorRawKey, { + files: [ + { + path: "contracts/Simple.sol", + content: "pragma solidity ^0.8.0; contract Simple { uint256 public x; }", + }, + ], + }); + + expect(submitRes.status).toBe(202); + expect(submitRes.body.jobId).toBeDefined(); + + const jobId = submitRes.body.jobId; + + // Poll until completed + let status = "queued"; + let retries = 20; + let jobResult: any; + + while ((status === "queued" || status === "running") && retries > 0) { + await new Promise((r) => setTimeout(r, 200)); + const checkRes = await makeRequest("GET", `/jobs/${jobId}`, operatorRawKey); + status = checkRes.body.status; + jobResult = checkRes.body; + retries--; + } + + expect(status).toBe("completed"); + expect(jobResult.result).toBeDefined(); + expect(jobResult.result.files.length).toBe(1); + }); + + it("should cancel a queued/running job", async () => { + const submitRes = await makeRequest("POST", "/jobs", operatorRawKey, { + files: [ + { + path: "contracts/Long.sol", + content: "pragma solidity ^0.8.0; contract Long {}", + }, + ], + }); + + const jobId = submitRes.body.jobId; + + const cancelRes = await makeRequest("POST", `/jobs/${jobId}/cancel`, operatorRawKey); + expect(cancelRes.status).toBe(200); + expect(cancelRes.body.job.status).toBe("cancelled"); + }); + }); + + describe("API Key Management Routes", () => { + it("should create, rotate, and list API keys", async () => { + const createRes = await makeRequest("POST", "/auth/keys", adminRawKey, { + name: "Dev Key", + roles: ["operator"], + }); + + expect(createRes.status).toBe(201); + expect(createRes.body.rawKey).toBeDefined(); + + const createdKeyId = createRes.body.key.id; + + const listRes = await makeRequest("GET", "/auth/keys", adminRawKey); + expect(listRes.status).toBe(200); + expect(listRes.body.keys.length).toBeGreaterThan(0); + + const rotateRes = await makeRequest("POST", "/auth/keys/rotate", adminRawKey, { + keyId: createdKeyId, + }); + + expect(rotateRes.status).toBe(200); + expect(rotateRes.body.newRawKey).toBeDefined(); + }); + }); + + describe("Metrics and Audit Logs", () => { + it("should expose metrics and queryable audit logs", async () => { + const metricsRes = await makeRequest("GET", "/metrics", adminRawKey); + expect(metricsRes.status).toBe(200); + expect(metricsRes.body.queue).toBeDefined(); + expect(metricsRes.body.quota).toBeDefined(); + + const auditRes = await makeRequest("GET", "/audit", adminRawKey); + expect(auditRes.status).toBe(200); + expect(auditRes.body.events.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts new file mode 100644 index 0000000..ff03204 --- /dev/null +++ b/packages/server/src/middleware/auth.ts @@ -0,0 +1,128 @@ +import { Request, Response, NextFunction } from "express"; +import { + ApiKeyManager, + OIDCVerifier, + Principal, + Scope, + createPrincipal, + hasScope, + canAccessTenant, +} from "@chainproof/core"; + +export interface AuthenticatedRequest extends Request { + principal?: Principal; +} + +export function createAuthMiddleware(options: { + apiKeyManager?: ApiKeyManager; + oidcVerifier?: OIDCVerifier; + bearerToken?: string; + enableMultiTenant?: boolean; +}) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (req.path === "/health" || req.path === "/health/live" || req.path === "/health/ready") { + req.principal = createPrincipal({ + id: "public-anonymous", + tenantId: "public", + roles: ["viewer"], + scopes: ["metrics:read"], + authMethod: "bearer-token", + }); + return next(); + } + + const authHeader = req.headers["authorization"] ?? ""; + const apiKeyHeader = (req.headers["x-api-key"] as string) ?? ""; + + if (options.bearerToken && authHeader === `Bearer ${options.bearerToken}`) { + req.principal = createPrincipal({ + id: "legacy-bearer-user", + tenantId: (req.headers["x-tenant-id"] as string) ?? "default-tenant", + roles: ["admin"], + scopes: [ + "scan:create", + "scan:read", + "scan:cancel", + "scan:delete", + "jobs:manage", + "metrics:read", + "audit:read", + "keys:manage", + ], + authMethod: "bearer-token", + }); + return next(); + } + + if (apiKeyHeader || (authHeader && authHeader.startsWith("Bearer cp_live_"))) { + const rawKey = apiKeyHeader || authHeader.slice(7); + if (options.apiKeyManager) { + const { principal, error } = options.apiKeyManager.authenticateApiKey(rawKey); + if (error || !principal) { + res.status(401).json({ error: error ?? "Unauthorized API key" }); + return; + } + req.principal = principal; + return next(); + } + } + + if (authHeader && authHeader.startsWith("Bearer ey")) { + const jwtToken = authHeader.slice(7); + if (options.oidcVerifier) { + const { principal, error } = options.oidcVerifier.authenticateToken(jwtToken); + if (error || !principal) { + res.status(401).json({ error: error ?? "Unauthorized OIDC token" }); + return; + } + req.principal = principal; + return next(); + } + } + + if (!options.bearerToken && !options.enableMultiTenant) { + req.principal = createPrincipal({ + id: "anonymous-default", + tenantId: (req.headers["x-tenant-id"] as string) ?? "default-tenant", + roles: ["admin"], + authMethod: "bearer-token", + }); + return next(); + } + + res.status(401).json({ error: "Unauthorized. Valid API key or Bearer token required." }); + }; +} + +export function requireScope(scope: Scope) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (!req.principal) { + res.status(401).json({ error: "Unauthenticated" }); + return; + } + + if (!hasScope(req.principal, scope)) { + res.status(403).json({ error: `Forbidden. Required scope: '${scope}'` }); + return; + } + + next(); + }; +} + +export function requireTenantAccess(getTenantId: (req: AuthenticatedRequest) => string) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (!req.principal) { + res.status(401).json({ error: "Unauthenticated" }); + return; + } + + const targetTenantId = getTenantId(req); + if (!canAccessTenant(req.principal, targetTenantId)) { + res.status(403).json({ error: `Forbidden. Tenant access denied for '${targetTenantId}'` }); + return; + } + + next(); + }; +} diff --git a/packages/server/src/middleware/error-handler.ts b/packages/server/src/middleware/error-handler.ts new file mode 100644 index 0000000..53c1b0a --- /dev/null +++ b/packages/server/src/middleware/error-handler.ts @@ -0,0 +1,17 @@ +import { Request, Response, NextFunction } from "express"; +import { ErrorSanitizer } from "@chainproof/core"; + +export function globalErrorHandler( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +): void { + const cleanErr = ErrorSanitizer.sanitizeError(err); + console.error("[ChainProof REST Server Error]", cleanErr.message); + + res.status(500).json({ + error: "Internal server error", + message: cleanErr.message, + }); +} diff --git a/packages/server/src/middleware/quota.ts b/packages/server/src/middleware/quota.ts new file mode 100644 index 0000000..4d8c701 --- /dev/null +++ b/packages/server/src/middleware/quota.ts @@ -0,0 +1,29 @@ +import { Response, NextFunction } from "express"; +import { JobQueueManager, QuotaManager, StructuredQuotaError } from "@chainproof/core"; +import { AuthenticatedRequest } from "./auth"; + +export function createQuotaMiddleware(quotaManager: QuotaManager, queueManager: JobQueueManager) { + return (req: AuthenticatedRequest, res: Response, next: NextFunction): void => { + if (!req.principal) { + return next(); + } + + if (req.method !== "POST" || (!req.path.startsWith("/scan") && !req.path.startsWith("/jobs"))) { + return next(); + } + + try { + quotaManager.checkAndRecordJobSubmission(req.principal.tenantId, queueManager); + next(); + } catch (err) { + if (err instanceof StructuredQuotaError) { + if (err.payload.retryAfterSeconds) { + res.setHeader("Retry-After", String(err.payload.retryAfterSeconds)); + } + res.status(err.payload.metric === "concurrency" ? 429 : err.payload.metric === "rate_limit" ? 429 : 413).json(err.payload); + return; + } + next(err); + } + }; +} diff --git a/packages/server/src/routes/audit.ts b/packages/server/src/routes/audit.ts new file mode 100644 index 0000000..e6ad08a --- /dev/null +++ b/packages/server/src/routes/audit.ts @@ -0,0 +1,30 @@ +import { Router, Response } from "express"; +import { AuditLogger } from "@chainproof/core"; +import { AuthenticatedRequest, requireScope } from "../middleware/auth"; + +export function createAuditRouter(auditLogger: AuditLogger): Router { + const router = Router(); + + router.get("/", requireScope("audit:read"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const { type, status, principalId, since, until, limit, offset } = req.query; + + const tenantId = principal.roles.includes("admin") && req.query.tenantId ? (req.query.tenantId as string) : principal.tenantId; + + const result = auditLogger.query({ + tenantId, + projectId: req.query.projectId as string, + principalId: principalId as string, + type: type as any, + status: status as any, + sinceTimestamp: since ? parseInt(since as string, 10) : undefined, + untilTimestamp: until ? parseInt(until as string, 10) : undefined, + limit: limit ? parseInt(limit as string, 10) : 50, + offset: offset ? parseInt(offset as string, 10) : 0, + }); + + res.json(result); + }); + + return router; +} diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts new file mode 100644 index 0000000..9804441 --- /dev/null +++ b/packages/server/src/routes/auth.ts @@ -0,0 +1,134 @@ +import { Router, Response } from "express"; +import { ApiKeyManager, AuditLogger } from "@chainproof/core"; +import { AuthenticatedRequest, requireScope } from "../middleware/auth"; + +export interface AuthRouterOptions { + apiKeyManager: ApiKeyManager; + auditLogger?: AuditLogger; +} + +export function createAuthRouter(options: AuthRouterOptions): Router { + const router = Router(); + const { apiKeyManager, auditLogger } = options; + + router.post("/keys", requireScope("keys:manage"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const body = req.body ?? {}; + + const name = body.name ?? "API Key"; + const tenantId = principal.roles.includes("admin") && body.tenantId ? body.tenantId : principal.tenantId; + + const { rawKey, record } = apiKeyManager.createApiKey({ + tenantId, + projectId: body.projectId ?? principal.projectId, + name, + roles: body.roles, + scopes: body.scopes, + expiresInMs: body.expiresInMs, + rateLimitTier: body.rateLimitTier, + }); + + if (auditLogger) { + auditLogger.record({ + type: "auth.key_created", + tenantId, + principalId: principal.id, + action: "key.create", + status: "success", + details: { keyId: record.id, keyName: name }, + }); + } + + res.status(201).json({ + rawKey, + key: record, + }); + }); + + router.get("/keys", requireScope("keys:manage"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const tenantId = principal.roles.includes("admin") && req.query.tenantId ? (req.query.tenantId as string) : principal.tenantId; + + const keys = apiKeyManager.listKeysForTenant(tenantId); + res.json({ keys, total: keys.length }); + }); + + router.post("/keys/rotate", requireScope("keys:manage"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const { keyId, gracePeriodMs } = req.body ?? {}; + + if (!keyId) { + res.status(400).json({ error: "Missing required field: keyId" }); + return; + } + + const key = apiKeyManager.getKey(keyId); + if (!key) { + res.status(404).json({ error: `API key not found: ${keyId}` }); + return; + } + + if (!principal.roles.includes("admin") && key.tenantId !== principal.tenantId) { + res.status(403).json({ error: "Access denied to API key" }); + return; + } + + try { + const { newRawKey, newRecord, oldRecord } = apiKeyManager.rotateKey({ keyId, gracePeriodMs }); + + if (auditLogger) { + auditLogger.record({ + type: "auth.key_rotated", + tenantId: key.tenantId, + principalId: principal.id, + action: "key.rotate", + status: "success", + details: { oldKeyId: oldRecord.id, newKeyId: newRecord.id }, + }); + } + + res.json({ + newRawKey, + newKey: newRecord, + oldKey: oldRecord, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: msg }); + } + }); + + router.delete("/keys/:id", requireScope("keys:manage"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const keyId = req.params.id; + const reason = req.body?.reason ?? "Revoked by user"; + + const key = apiKeyManager.getKey(keyId); + if (!key) { + res.status(404).json({ error: `API key not found: ${keyId}` }); + return; + } + + if (!principal.roles.includes("admin") && key.tenantId !== principal.tenantId) { + res.status(403).json({ error: "Access denied to API key" }); + return; + } + + const revokedKey = apiKeyManager.revokeKey(keyId, reason); + + if (auditLogger) { + auditLogger.record({ + type: "auth.key_revoked", + tenantId: key.tenantId, + principalId: principal.id, + action: "key.revoke", + status: "success", + details: { keyId, reason }, + }); + } + + res.json({ message: `API key ${keyId} revoked successfully`, key: revokedKey }); + }); + + return router; +} diff --git a/packages/server/src/routes/health.ts b/packages/server/src/routes/health.ts index 56afb47..c0be88b 100644 --- a/packages/server/src/routes/health.ts +++ b/packages/server/src/routes/health.ts @@ -1,19 +1,40 @@ import { Router, Request, Response } from "express"; -import { isSlitherAvailable } from "@chainproof/core"; - -const router = Router(); - -/** - * GET /health - * - * Liveness probe — returns server version and optional dependency status. - */ -router.get("/", (_req: Request, res: Response) => { - res.json({ - status: "ok", - version: "0.1.0", - slitherAvailable: isSlitherAvailable(), +import { isSlitherAvailable, JobQueueManager, QueueWorker } from "@chainproof/core"; + +export interface HealthRouterOptions { + queueManager?: JobQueueManager; + worker?: QueueWorker; +} + +export function createHealthRouter(options: HealthRouterOptions = {}): Router { + const router = Router(); + + const handleLiveness = (_req: Request, res: Response) => { + res.json({ + status: "ok", + version: "0.1.0", + slitherAvailable: isSlitherAvailable(), + timestamp: Date.now(), + }); + }; + + router.get("/", handleLiveness); + router.get("/live", handleLiveness); + + router.get("/ready", (_req: Request, res: Response) => { + const queueStats = options.queueManager ? options.queueManager.getStats() : undefined; + const isReady = true; + + res.status(isReady ? 200 : 503).json({ + status: isReady ? "ready" : "not_ready", + version: "0.1.0", + slitherAvailable: isSlitherAvailable(), + queue: queueStats, + timestamp: Date.now(), + }); }); -}); -export default router; + return router; +} + +export default createHealthRouter(); diff --git a/packages/server/src/routes/jobs.ts b/packages/server/src/routes/jobs.ts new file mode 100644 index 0000000..23c8b81 --- /dev/null +++ b/packages/server/src/routes/jobs.ts @@ -0,0 +1,252 @@ +import { Router, Response } from "express"; +import { + AuditLogger, + JobPriority, + JobQueueManager, + QueueWorker, + SafeArchiveExtractor, + StreamEvent, + SubmitJobOptions, +} from "@chainproof/core"; +import { AuthenticatedRequest, requireScope } from "../middleware/auth"; + +export interface JobsRouterOptions { + queueManager: JobQueueManager; + worker: QueueWorker; + auditLogger?: AuditLogger; +} + +export function createJobsRouter(options: JobsRouterOptions): Router { + const router = Router(); + const { queueManager, worker, auditLogger } = options; + + router.post("/", requireScope("scan:create"), async (req: AuthenticatedRequest, res: Response): Promise => { + const principal = req.principal!; + const body = req.body ?? {}; + + let files = body.files ?? []; + + if (body.archiveBase64 && typeof body.archiveBase64 === "string") { + try { + const zipBuffer = Buffer.from(body.archiveBase64, "base64"); + const extractor = new SafeArchiveExtractor(); + const extracted = extractor.extractZipBuffer(zipBuffer); + files = extracted.files; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + res.status(400).json({ error: `Archive extraction failed: ${msg}` }); + return; + } + } + + if (!Array.isArray(files) || files.length === 0) { + res.status(400).json({ + error: "Missing required scan input: 'files' array or 'archiveBase64'", + }); + return; + } + + for (const f of files) { + if (typeof f.path !== "string" || typeof f.content !== "string") { + res.status(400).json({ + error: 'Each file entry must have string fields "path" and "content"', + }); + return; + } + } + + const priority: JobPriority = typeof body.priority === "number" ? (body.priority as JobPriority) : 2; + + const submitOpts: SubmitJobOptions = { + tenantId: principal.tenantId, + projectId: body.projectId ?? principal.projectId, + principalId: principal.id, + priority, + idempotencyKey: body.idempotencyKey ?? (req.headers["x-idempotency-key"] as string), + files, + config: body.config ?? {}, + timeoutMs: body.timeoutMs, + maxRetries: body.maxRetries, + tags: body.tags, + }; + + const { job, deduplicated } = queueManager.submitJob(submitOpts); + + if (auditLogger) { + auditLogger.record({ + type: "job.submitted", + tenantId: principal.tenantId, + projectId: job.projectId, + principalId: principal.id, + action: "job.submit", + status: "success", + details: { jobId: job.id, fileCount: files.length, deduplicated }, + }); + } + + res.status(deduplicated ? 200 : 202).json({ + jobId: job.id, + status: job.status, + deduplicated, + createdAt: job.createdAt, + progress: job.progress, + links: { + self: `/jobs/${job.id}`, + stream: `/jobs/${job.id}/stream`, + cancel: `/jobs/${job.id}/cancel`, + }, + }); + }); + + router.get("/", requireScope("scan:read"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const { status, projectId, search, limit, offset } = req.query; + + const tenantId = principal.roles.includes("admin") && req.query.tenantId ? (req.query.tenantId as string) : principal.tenantId; + + const filterResult = queueManager.listJobs({ + tenantId, + projectId: projectId as string, + status: status as any, + search: search as string, + limit: limit ? parseInt(limit as string, 10) : 50, + offset: offset ? parseInt(offset as string, 10) : 0, + }); + + res.json(filterResult); + }); + + router.get("/:id", requireScope("scan:read"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const jobId = req.params.id; + + const job = queueManager.getJob(jobId); + if (!job) { + res.status(404).json({ error: `Job not found: ${jobId}` }); + return; + } + + if (!principal.roles.includes("admin") && job.tenantId !== principal.tenantId) { + res.status(403).json({ error: "Access denied to job" }); + return; + } + + res.json(job); + }); + + router.get("/:id/stream", requireScope("scan:read"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const jobId = req.params.id; + + const job = queueManager.getJob(jobId); + if (!job) { + res.status(404).json({ error: `Job not found: ${jobId}` }); + return; + } + + if (!principal.roles.includes("admin") && job.tenantId !== principal.tenantId) { + res.status(403).json({ error: "Access denied to job stream" }); + return; + } + + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + + res.write( + `data: ${JSON.stringify({ + type: "status", + jobId: job.id, + status: job.status, + progress: job.progress, + timestamp: Date.now(), + })}\n\n` + ); + + if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") { + res.write( + `data: ${JSON.stringify({ + type: "complete", + jobId: job.id, + status: job.status, + progress: job.progress, + data: job.result ?? { error: job.error }, + timestamp: Date.now(), + })}\n\n` + ); + res.end(); + return; + } + + const unsubscribe = worker.subscribe(jobId, (event: StreamEvent) => { + res.write(`data: ${JSON.stringify(event)}\n\n`); + + if (event.type === "complete" || event.type === "error") { + unsubscribe(); + res.end(); + } + }); + + req.on("close", () => { + unsubscribe(); + }); + }); + + router.post("/:id/cancel", requireScope("scan:cancel"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const jobId = req.params.id; + + const tenantId = principal.roles.includes("admin") ? undefined : principal.tenantId; + + const { success, job, error } = queueManager.cancelJob(jobId, tenantId); + + if (!success) { + res.status(400).json({ error: error ?? "Failed to cancel job" }); + return; + } + + if (auditLogger) { + auditLogger.record({ + type: "job.cancelled", + tenantId: job!.tenantId, + projectId: job!.projectId, + principalId: principal.id, + action: "job.cancel", + status: "success", + details: { jobId }, + }); + } + + res.json({ message: "Job cancellation requested", job }); + }); + + router.delete("/:id", requireScope("scan:delete"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const jobId = req.params.id; + + const tenantId = principal.roles.includes("admin") ? undefined : principal.tenantId; + + const { success, error } = queueManager.deleteJob(jobId, tenantId); + + if (!success) { + res.status(404).json({ error: error ?? "Job not found" }); + return; + } + + if (auditLogger) { + auditLogger.record({ + type: "job.deleted", + tenantId: principal.tenantId, + principalId: principal.id, + action: "job.delete", + status: "success", + details: { jobId }, + }); + } + + res.json({ message: `Job ${jobId} deleted successfully` }); + }); + + return router; +} diff --git a/packages/server/src/routes/metrics.ts b/packages/server/src/routes/metrics.ts new file mode 100644 index 0000000..4423919 --- /dev/null +++ b/packages/server/src/routes/metrics.ts @@ -0,0 +1,32 @@ +import { Router, Response } from "express"; +import { JobQueueManager, QuotaManager } from "@chainproof/core"; +import { AuthenticatedRequest, requireScope } from "../middleware/auth"; + +export function createMetricsRouter(queueManager: JobQueueManager, quotaManager: QuotaManager): Router { + const router = Router(); + + router.get("/", requireScope("metrics:read"), (req: AuthenticatedRequest, res: Response): void => { + const principal = req.principal!; + const tenantId = principal.roles.includes("admin") && req.query.tenantId ? (req.query.tenantId as string) : principal.tenantId; + + const queueStats = queueManager.getStats(principal.roles.includes("admin") ? undefined : tenantId); + const quotaUsage = quotaManager.getTenantUsage(tenantId, queueManager); + const quotaLimits = quotaManager.getTenantLimits(tenantId); + + res.json({ + tenantId, + queue: queueStats, + quota: { + usage: quotaUsage, + limits: quotaLimits, + }, + system: { + uptimeSeconds: Math.floor(process.uptime()), + memoryUsage: process.memoryUsage(), + }, + timestamp: Date.now(), + }); + }); + + return router; +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 7272d2d..68fe376 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -2,111 +2,177 @@ import "dotenv/config"; import express from "express"; import cors from "cors"; import rateLimit from "express-rate-limit"; - -import healthRouter from "./routes/health"; +import * as http from "http"; + +import { + ApiKeyManager, + AuditLogger, + DurableJobStore, + JobQueueManager, + OIDCConfig, + OIDCVerifier, + QueueWorker, + QuotaManager, +} from "@chainproof/core"; + +import { createAuthMiddleware } from "./middleware/auth"; +import { createQuotaMiddleware } from "./middleware/quota"; +import { globalErrorHandler } from "./middleware/error-handler"; + +import { createHealthRouter } from "./routes/health"; import scanRouter from "./routes/scan"; import rulesRouter from "./routes/rules"; - -// ─── Configuration (can be overridden by env vars or programmatic start) ────── +import { createJobsRouter } from "./routes/jobs"; +import { createAuthRouter } from "./routes/auth"; +import { createAuditRouter } from "./routes/audit"; +import { createMetricsRouter } from "./routes/metrics"; export interface ServerOptions { port?: number; host?: string; - /** Bearer token for auth. If empty, auth is disabled. */ token?: string; - /** Max concurrent scan requests in the rate-limit window. */ maxRequests?: number; - /** Max request body size (e.g. "5mb"). */ bodySizeLimit?: string; - /** Allow /scan/file endpoint (server filesystem access). */ allowFs?: boolean; + + // Multi-tenant & Durable Queue options + enableMultiTenant?: boolean; + storageDir?: string; + workerConcurrency?: number; + apiKeyManager?: ApiKeyManager; + oidcConfig?: OIDCConfig; + quotaManager?: QuotaManager; + auditLogger?: AuditLogger; + queueManager?: JobQueueManager; + worker?: QueueWorker; } -// ─── Build the Express app ──────────────────────────────────────────────────── +export interface ServerInstance { + app: express.Application; + server?: http.Server; + queueManager: JobQueueManager; + worker: QueueWorker; + apiKeyManager: ApiKeyManager; + quotaManager: QuotaManager; + auditLogger: AuditLogger; + close: () => Promise; +} -export function createApp(opts: ServerOptions = {}): express.Application { +export function createServerInstance(opts: ServerOptions = {}): ServerInstance { const app = express(); - // ── Request size limit ─────────────────────────────────────────────────── - const sizeLimit = opts.bodySizeLimit ?? process.env.CHAINPROOF_BODY_LIMIT ?? "5mb"; + const apiKeyManager = opts.apiKeyManager ?? new ApiKeyManager(); + const oidcVerifier = opts.oidcConfig ? new OIDCVerifier(opts.oidcConfig) : undefined; + const auditLogger = opts.auditLogger ?? new AuditLogger(); + const quotaManager = opts.quotaManager ?? new QuotaManager(); + + const jobStore = new DurableJobStore({ storageDir: opts.storageDir }); + const queueManager = opts.queueManager ?? new JobQueueManager({ store: jobStore }); + const worker = + opts.worker ?? + new QueueWorker({ + queueManager, + concurrency: opts.workerConcurrency ?? 2, + }); + + // Start queue worker + worker.start(); + + // Size limit & CORS + const sizeLimit = opts.bodySizeLimit ?? process.env.CHAINPROOF_BODY_LIMIT ?? "50mb"; app.use(express.json({ limit: sizeLimit })); app.use(cors()); - // ── Rate limiting ──────────────────────────────────────────────────────── - const maxRequests = opts.maxRequests ?? Number(process.env.CHAINPROOF_MAX_REQUESTS ?? 10); + // Rate Limiting + const maxRequests = opts.maxRequests ?? Number(process.env.CHAINPROOF_MAX_REQUESTS ?? 100); const limiter = rateLimit({ - windowMs: 60 * 1000, // 1-minute window + windowMs: 60 * 1000, max: maxRequests, standardHeaders: true, legacyHeaders: false, - message: { - error: `Rate limit exceeded. Max ${maxRequests} requests per minute.`, - }, + message: { error: `Rate limit exceeded. Max ${maxRequests} requests per minute.` }, }); app.use("/scan", limiter); + app.use("/jobs", limiter); + + // Authentication Middleware + const authMiddleware = createAuthMiddleware({ + apiKeyManager, + oidcVerifier, + bearerToken: opts.token ?? process.env.CHAINPROOF_TOKEN ?? "", + enableMultiTenant: opts.enableMultiTenant ?? false, + }); + app.use(authMiddleware); - // ── Optional bearer token auth ─────────────────────────────────────────── - const token = opts.token ?? process.env.CHAINPROOF_TOKEN ?? ""; - if (token) { - app.use((req, res, next) => { - // Health endpoint is public even when auth is on - if (req.path === "/health") return next(); - - const authHeader = req.headers["authorization"] ?? ""; - if (authHeader !== `Bearer ${token}`) { - res.status(401).json({ error: "Unauthorized. Provide a valid Bearer token." }); - return; - } - next(); - }); - } + // Quota Middleware + app.use(createQuotaMiddleware(quotaManager, queueManager)); - // ── Propagate flags to routes via env ──────────────────────────────────── if (opts.allowFs) { process.env.CHAINPROOF_ALLOW_FS = "true"; } - // ── Routes ─────────────────────────────────────────────────────────────── - app.use("/health", healthRouter); + // Routes + app.use("/health", createHealthRouter({ queueManager, worker })); app.use("/scan", scanRouter); + app.use("/jobs", createJobsRouter({ queueManager, worker, auditLogger })); + app.use("/auth", createAuthRouter({ apiKeyManager, auditLogger })); + app.use("/audit", createAuditRouter(auditLogger)); + app.use("/metrics", createMetricsRouter(queueManager, quotaManager)); app.use("/rules", rulesRouter); - // ── 404 handler ────────────────────────────────────────────────────────── + // 404 handler app.use((_req, res) => { res.status(404).json({ error: "Not found" }); }); - // ── Global error handler ───────────────────────────────────────────────── - app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error("[ChainProof Server] Unhandled error:", err.message); - res.status(500).json({ error: "Internal server error" }); - }); + // Global error handler + app.use(globalErrorHandler); + + const instance: ServerInstance = { + app, + queueManager, + worker, + apiKeyManager, + quotaManager, + auditLogger, + close: async () => { + worker.stop(); + if (instance.server) { + await new Promise((resolve) => { + instance.server!.close(() => resolve()); + }); + } + }, + }; - return app; + return instance; } -// ─── Standalone entry-point (called by CLI `chainproof serve`) ─────────────── +export function createApp(opts: ServerOptions = {}): express.Application { + const instance = createServerInstance(opts); + return instance.app; +} -export async function startServer(opts: ServerOptions = {}): Promise { +export async function startServer(opts: ServerOptions = {}): Promise { const port = opts.port ?? Number(process.env.PORT ?? 4243); const host = opts.host ?? process.env.HOST ?? "127.0.0.1"; - const app = createApp(opts); + const instance = createServerInstance(opts); await new Promise((resolve) => { - app.listen(port, host, () => { - console.log(`\n 🚀 ChainProof server running at http://${host}:${port}`); - console.log(` POST http://${host}:${port}/scan`); - console.log(` GET http://${host}:${port}/health`); - console.log(` GET http://${host}:${port}/rules`); - if (opts.token) { - console.log(" 🔐 Bearer token authentication enabled"); - } - if (opts.allowFs) { - console.log(" 📁 Filesystem access enabled (POST /scan/file)"); - } + const server = instance.app.listen(port, host, () => { + instance.server = server; + console.log(`\n 🚀 ChainProof REST Server running at http://${host}:${port}`); + console.log(` POST http://${host}:${port}/jobs (Async durable priority queue)`); + console.log(` POST http://${host}:${port}/scan (Synchronous scan)`); + console.log(` GET http://${host}:${port}/health (Health & Readiness probes)`); + console.log(` GET http://${host}:${port}/metrics (Metrics & Quota status)`); + console.log(` GET http://${host}:${port}/audit (Audit logs)`); + console.log(` POST http://${host}:${port}/auth/keys (API key management)`); console.log(); resolve(); }); }); + + return instance; }