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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions docs/server-hardening.md
Original file line number Diff line number Diff line change
@@ -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-<id>/job-<id>`) 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.
100 changes: 100 additions & 0 deletions packages/core/src/audit/audit-logger.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}): AuditEvent {
const id = `evt_${crypto.randomBytes(8).toString("hex")}`;
const timestamp = Date.now();

let sanitizedDetails: Record<string, unknown> | 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 = [];
}
}
2 changes: 2 additions & 0 deletions packages/core/src/audit/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types";
export * from "./audit-logger";
41 changes: 41 additions & 0 deletions packages/core/src/audit/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

export interface AuditFilter {
tenantId?: string;
projectId?: string;
principalId?: string;
type?: AuditEventType | AuditEventType[];
status?: "success" | "failure" | "denied";
sinceTimestamp?: number;
untilTimestamp?: number;
limit?: number;
offset?: number;
}
144 changes: 144 additions & 0 deletions packages/core/src/auth/apikey.ts
Original file line number Diff line number Diff line change
@@ -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<string, ApiKeyRecord> = new Map();
private keysByHash: Map<string, ApiKeyRecord> = 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;
}
}
4 changes: 4 additions & 0 deletions packages/core/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./types";
export * from "./principal";
export * from "./apikey";
export * from "./oidc";
Loading