/**
 * http-interceptor.ts  --  RawHttpInterceptor (CrabTrap-style)
 *
 * Defense-in-depth governance net that catches arbitrary outbound HTTP calls
 * from AI agents that bypass the official LLM SDK adapters (e.g. a raw
 * `await fetch('https://api.openai.com/...')` call).
 *
 * Inspired by Brex's open-source "CrabTrap" concept but implemented using
 * Node.js built-ins only (no npm dependencies).
 *
 * Key capabilities:
 *   - Patches globalThis.fetch with a governance-aware wrapper.
 *   - Classifies every outbound request before sending.
 *   - Enforces a configurable default policy: 'audit-only' | 'block' | 'sandbox'.
 *   - Detects common secret patterns in request bodies, URLs, and auth headers.
 *   - Writes structured audit events for every interception.
 *   - Never logs raw Authorization / X-API-Key / Bearer token header values.
 *   - attach() / detach() are idempotent.
 *   - getStats() returns runtime interception counts.
 *
 * Limitations (v1):
 *   - Does NOT intercept Node.js `http` / `https` low-level modules (deferred
 *     to a follow-up PR -- see docs/security/http-interceptor.md).
 *   - Does NOT cover non-Node runtimes (Deno, browsers, Bun).
 *   - Does NOT prevent an agent from importing an alternative HTTP client
 *     before attach() is called.  Attach early in process startup.
 *
 * Pen-tests:
 *   PT-CRABTRAP-1  empty packBinding throws UnboundDataError pre-call
 *   PT-CRABTRAP-2  secret in URL query string detected and redacted in audit
 *   PT-CRABTRAP-3  malformed config rejected at construction
 *   PT-CRABTRAP-4  audit entry never contains raw API key from headers
 *
 * @connexum/ai-governance
 */
import * as crypto from 'node:crypto';
import { BUILT_IN_LLM_HOSTNAMES } from './built-in-llm-providers.js';
import { SecretLeakDetector } from './secret-leak-detector.js';
// ---------------------------------------------------------------------------
// Error types
// ---------------------------------------------------------------------------
/**
 * Thrown synchronously before any network call when packBinding is empty.
 * Enforces the Pack-Binding Primacy invariant (AIAG-MEMORY-04).
 */
export class UnboundHttpRequestError extends Error {
    code = 'UNBOUND_HTTP_REQUEST';
    constructor(url) {
        super(`UnboundHttpRequestError: outbound HTTP request to "${url}" was made with ` +
            'an empty packBinding[].  Every intercepted request must carry at least one ' +
            'compliance pack binding.  Attach a packBinding to the HttpInterceptorContext ' +
            'or configure a default packBinding on the RawHttpInterceptor.');
        this.name = 'UnboundHttpRequestError';
    }
}
/**
 * Thrown when the interceptor blocks a request due to an unknown host or
 * a detected secret leak.
 */
export class HttpRequestBlockedError extends Error {
    code = 'HTTP_REQUEST_BLOCKED';
    reason;
    constructor(url, reason) {
        super(`HttpRequestBlockedError: request to "${url}" was blocked. Reason: ${reason}`);
        this.name = 'HttpRequestBlockedError';
        this.reason = reason;
    }
}
/**
 * Thrown at construction time when the config is invalid.
 */
export class HttpInterceptorConfigError extends Error {
    code = 'HTTP_INTERCEPTOR_CONFIG_ERROR';
    constructor(message) {
        super(`HttpInterceptorConfigError: ${message}`);
        this.name = 'HttpInterceptorConfigError';
    }
}
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
const VALID_POLICIES = ['audit-only', 'block', 'sandbox'];
const VALID_LEAK_POLICIES = ['block', 'redact-and-warn', 'audit-only'];
function validateConfig(cfg) {
    if (cfg.defaultPolicy !== undefined && !VALID_POLICIES.includes(cfg.defaultPolicy)) {
        throw new HttpInterceptorConfigError(`invalid defaultPolicy "${cfg.defaultPolicy}". ` +
            `Valid values: ${VALID_POLICIES.join(', ')}`);
    }
    if (cfg.secretLeakPolicy !== undefined && !VALID_LEAK_POLICIES.includes(cfg.secretLeakPolicy)) {
        throw new HttpInterceptorConfigError(`invalid secretLeakPolicy "${cfg.secretLeakPolicy}". ` +
            `Valid values: ${VALID_LEAK_POLICIES.join(', ')}`);
    }
    if (cfg.additionalAllowedHosts) {
        for (const host of cfg.additionalAllowedHosts) {
            if (typeof host !== 'string' || host.trim() === '') {
                throw new HttpInterceptorConfigError('additionalAllowedHosts entries must be non-empty strings.');
            }
            // Reject obviously invalid patterns
            if (host === '.' || host === '*') {
                throw new HttpInterceptorConfigError(`invalid host pattern "${host}". Use a specific domain or ".suffix" format.`);
            }
        }
    }
    if (cfg.defaultContext !== undefined) {
        if (!Array.isArray(cfg.defaultContext.packBinding) || cfg.defaultContext.packBinding.length === 0) {
            throw new HttpInterceptorConfigError('defaultContext.packBinding must be a non-empty array.');
        }
    }
}
// ---------------------------------------------------------------------------
// Host matching
// ---------------------------------------------------------------------------
/**
 * Returns true if `hostname` matches an entry in `allowedHosts`.
 *
 * Matching rules:
 *   - Exact match:  "api.openai.com" matches "api.openai.com".
 *   - Suffix match: ".googleapis.com" matches "api.googleapis.com" and
 *                   "us-central1-aiplatform.googleapis.com".
 */
function hostMatches(hostname, allowedHosts) {
    const h = hostname.toLowerCase();
    for (const entry of allowedHosts) {
        const e = entry.toLowerCase();
        if (e.startsWith('.')) {
            // Suffix: hostname must end with the suffix OR equal the suffix without leading dot
            const suffix = e.slice(1); // e.g. "googleapis.com"
            if (h === suffix || h.endsWith('.' + suffix))
                return true;
        }
        else {
            if (h === e)
                return true;
        }
    }
    return false;
}
// ---------------------------------------------------------------------------
// Header redaction for audit (PT-CRABTRAP-4)
// ---------------------------------------------------------------------------
const SENSITIVE_HEADER_NAMES = new Set([
    'authorization',
    'x-api-key',
    'x-goog-api-key',
    'x-anthropic-api-key',
    'api-key',
    'openai-api-key',
]);
function isSensitiveHeader(name) {
    return SENSITIVE_HEADER_NAMES.has(name.toLowerCase());
}
// ---------------------------------------------------------------------------
// RawHttpInterceptor
// ---------------------------------------------------------------------------
/**
 * Governance-aware fetch interceptor.
 *
 * Usage:
 *
 *   const interceptor = new RawHttpInterceptor({
 *     defaultPolicy: 'block',
 *     secretLeakPolicy: 'block',
 *     defaultContext: { packBinding: ['hipaa', 'soc2'] },
 *     onAuditEvent: (evt) => eventBus.publish(evt),
 *   });
 *
 *   interceptor.attach();
 *   // ... all subsequent fetch() calls are now governed ...
 *   interceptor.detach();
 */
export class RawHttpInterceptor {
    cfg;
    /** The original globalThis.fetch before patching. */
    originalFetch = null;
    /** Whether the interceptor is currently attached. */
    attached = false;
    /** Configured-allow entries registered via addAllowedOrigin(). */
    configuredAllowList = [];
    /** Flat list of built-in + additionalAllowedHosts. */
    builtInHosts;
    /** Secret leak detector instance. */
    leakDetector = new SecretLeakDetector();
    /** Runtime statistics. */
    stats = {
        totalIntercepted: 0,
        allowListHits: 0,
        configuredAllowHits: 0,
        unknownHostHits: 0,
        blocked: 0,
        secretLeaksDetected: 0,
    };
    constructor(config = {}) {
        validateConfig(config);
        this.cfg = {
            defaultPolicy: config.defaultPolicy ?? 'audit-only',
            secretLeakPolicy: config.secretLeakPolicy ?? 'block',
            additionalAllowedHosts: config.additionalAllowedHosts ?? [],
            sandboxProxy: config.sandboxProxy,
            onAuditEvent: config.onAuditEvent,
            defaultContext: config.defaultContext,
        };
        // Merge built-in + additional into one list
        this.builtInHosts = [
            ...BUILT_IN_LLM_HOSTNAMES,
            ...(config.additionalAllowedHosts ?? []),
        ];
    }
    // ---------------------------------------------------------------------------
    // Lifecycle
    // ---------------------------------------------------------------------------
    /**
     * Patch globalThis.fetch with the governance wrapper.
     * Idempotent: calling attach() when already attached is a no-op.
     */
    attach() {
        if (this.attached)
            return;
        // Capture the true original (not our own wrapper from a previous attach)
        this.originalFetch = globalThis.fetch;
        // We need `this` in the wrapper; bind explicitly
        const self = this;
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        globalThis.fetch = async function governedFetch(input, init) {
            return self._intercept(input, init);
        };
        this.attached = true;
    }
    /**
     * Restore the original globalThis.fetch.
     * Idempotent: calling detach() when not attached is a no-op.
     */
    detach() {
        if (!this.attached || this.originalFetch === null)
            return;
        globalThis.fetch = this.originalFetch;
        this.originalFetch = null;
        this.attached = false;
    }
    /** Whether the interceptor is currently attached. */
    isAttached() {
        return this.attached;
    }
    // ---------------------------------------------------------------------------
    // Allow-list management
    // ---------------------------------------------------------------------------
    /**
     * Register an additional allowed origin at runtime.
     *
     * @param host   Exact hostname or ".suffix" pattern.
     * @param reason Human-readable justification (logged in allow-list).
     */
    addAllowedOrigin(host, reason) {
        if (!host || host.trim() === '') {
            throw new HttpInterceptorConfigError('addAllowedOrigin: host must be a non-empty string.');
        }
        this.configuredAllowList.push({
            host: host.toLowerCase(),
            reason,
            registeredAt: new Date().toISOString(),
        });
    }
    /** Return a copy of the current configured-allow list. */
    getConfiguredAllowList() {
        return [...this.configuredAllowList];
    }
    // ---------------------------------------------------------------------------
    // Stats
    // ---------------------------------------------------------------------------
    /** Return a snapshot of runtime statistics. */
    getStats() {
        return { ...this.stats };
    }
    /** Reset statistics counters to zero. */
    resetStats() {
        this.stats = {
            totalIntercepted: 0,
            allowListHits: 0,
            configuredAllowHits: 0,
            unknownHostHits: 0,
            blocked: 0,
            secretLeaksDetected: 0,
        };
    }
    // ---------------------------------------------------------------------------
    // Core interception logic
    // ---------------------------------------------------------------------------
    /**
     * Internal interception handler.  Called by the patched globalThis.fetch.
     *
     * Visibility: private -- exposed for testing purposes via _intercept.
     */
    async _intercept(input, init) {
        const traceId = crypto.randomUUID();
        const timestamp = new Date().toISOString();
        // Resolve URL string
        const urlStr = typeof input === 'string'
            ? input
            : input instanceof URL
                ? input.toString()
                : input.url;
        // Parse URL for hostname, path extraction
        let parsedUrl;
        try {
            parsedUrl = new URL(urlStr);
        }
        catch {
            // Unparseable URL -- let the original fetch handle it (it will also fail)
            return this.originalFetch(input, init);
        }
        const hostname = parsedUrl.hostname;
        // Resolve context (per-call > default > empty)
        const context = init?.__interceptorContext ?? this.cfg.defaultContext ?? { packBinding: [] };
        // PT-CRABTRAP-1: Enforce pack-binding primacy pre-call
        if (!context.packBinding || context.packBinding.length === 0) {
            throw new UnboundHttpRequestError(urlStr);
        }
        this.stats.totalIntercepted++;
        // Sanitized path for audit (no query string -- may contain secrets)
        const sanitizedPath = parsedUrl.pathname;
        const queryRedacted = parsedUrl.search !== '';
        // Resolve body size
        let bodyText = '';
        let bytesOut = 0;
        if (init?.body) {
            if (typeof init.body === 'string') {
                bodyText = init.body;
                bytesOut = Buffer.byteLength(init.body, 'utf-8');
            }
            else if (init.body instanceof ArrayBuffer) {
                bytesOut = init.body.byteLength;
            }
            else if (ArrayBuffer.isView(init.body)) {
                bytesOut = init.body.byteLength;
                // Try to decode for leak scanning
                try {
                    bodyText = Buffer.from(init.body).toString('utf-8');
                }
                catch {
                    // Non-text binary body -- skip text scanning
                }
            }
        }
        // Resolve headers as plain object for inspection
        const headersObj = {};
        if (init?.headers) {
            if (init.headers instanceof Headers) {
                init.headers.forEach((v, k) => { headersObj[k] = v; });
            }
            else if (Array.isArray(init.headers)) {
                for (const [k, v] of init.headers) {
                    headersObj[k] = v;
                }
            }
            else {
                Object.assign(headersObj, init.headers);
            }
        }
        // Classify the destination host
        const isBuiltIn = hostMatches(hostname, this.builtInHosts);
        const isConfigured = !isBuiltIn && hostMatches(hostname, this.configuredAllowList.map((e) => e.host));
        let classification;
        let policyApplied;
        if (isBuiltIn) {
            classification = 'allowed';
            policyApplied = 'allow-list';
            this.stats.allowListHits++;
        }
        else if (isConfigured) {
            classification = 'allowed-configured';
            policyApplied = 'allow-list';
            this.stats.configuredAllowHits++;
        }
        else {
            classification = 'unknown-host';
            policyApplied = this.cfg.defaultPolicy;
            this.stats.unknownHostHits++;
        }
        // Secret leak detection
        // PT-CRABTRAP-2: scan full URL (includes query string) for secrets
        const leakResult = this.leakDetector.scanAll(bodyText, urlStr, // full URL including query string
        headersObj);
        let bodyRedacted = false;
        if (leakResult.detected) {
            this.stats.secretLeaksDetected++;
            switch (this.cfg.secretLeakPolicy) {
                case 'block': {
                    this.stats.blocked++;
                    // Emit audit event before throwing
                    this._emitAuditEvent({
                        eventType: 'http:intercepted',
                        traceId,
                        timestamp,
                        hostname,
                        method: init?.method ?? (typeof input !== 'string' && !(input instanceof URL) ? input.method : 'GET'),
                        sanitizedPath,
                        queryRedacted,
                        bytesOut,
                        agentIdentity: context.agentIdentity,
                        packBinding: context.packBinding,
                        classification: 'blocked',
                        policyApplied,
                        secretLeakDetected: true,
                        leakCategories: leakResult.findings.map((f) => f.category),
                    });
                    throw new HttpRequestBlockedError(`${parsedUrl.origin}${sanitizedPath}`, `secret leak detected: ${leakResult.findings.map((f) => f.label).join(', ')}`);
                }
                case 'redact-and-warn': {
                    // Attempt body redaction -- replace matched patterns with [REDACTED]
                    if (bodyText && typeof init?.body === 'string') {
                        let redactedBody = bodyText;
                        for (const finding of leakResult.findings.filter((f) => f.surface === 'body')) {
                            // Find the pattern from the detector registry and replace
                            redactedBody = redactBody(redactedBody, finding.patternId);
                        }
                        if (redactedBody !== bodyText) {
                            init = { ...init, body: redactedBody };
                            bodyRedacted = true;
                        }
                    }
                    break;
                }
                case 'audit-only':
                    // Fall through -- let the request proceed
                    break;
            }
        }
        // Apply unknown-host policy
        if (classification === 'unknown-host') {
            const effectivePolicy = this.cfg.defaultPolicy;
            if (effectivePolicy === 'block') {
                this.stats.blocked++;
                this._emitAuditEvent({
                    eventType: 'http:intercepted',
                    traceId,
                    timestamp,
                    hostname,
                    method: init?.method ?? 'GET',
                    sanitizedPath,
                    queryRedacted,
                    bytesOut,
                    agentIdentity: context.agentIdentity,
                    packBinding: context.packBinding,
                    classification: 'blocked',
                    policyApplied: 'block',
                    secretLeakDetected: leakResult.detected,
                    leakCategories: leakResult.detected
                        ? leakResult.findings.map((f) => f.category)
                        : undefined,
                });
                throw new HttpRequestBlockedError(`${parsedUrl.origin}${sanitizedPath}`, `host "${hostname}" is not on the allow-list and defaultPolicy is "block"`);
            }
            if (effectivePolicy === 'sandbox') {
                if (!this.cfg.sandboxProxy) {
                    // No proxy configured -- fall back to block
                    this.stats.blocked++;
                    this._emitAuditEvent({
                        eventType: 'http:intercepted',
                        traceId,
                        timestamp,
                        hostname,
                        method: init?.method ?? 'GET',
                        sanitizedPath,
                        queryRedacted,
                        bytesOut,
                        agentIdentity: context.agentIdentity,
                        packBinding: context.packBinding,
                        classification: 'blocked',
                        policyApplied: 'sandbox',
                        secretLeakDetected: leakResult.detected,
                        leakCategories: leakResult.detected
                            ? leakResult.findings.map((f) => f.category)
                            : undefined,
                    });
                    throw new HttpRequestBlockedError(`${parsedUrl.origin}${sanitizedPath}`, 'sandbox policy active but no sandboxProxy configured -- request blocked');
                }
                // Sandbox routing: replace input URL with proxy URL
                // Pass the original URL as a header for the proxy to forward
                const proxyHeaders = {
                    ...headersObj,
                    'X-Governance-Original-URL': urlStr,
                    'X-Governance-Trace-ID': traceId,
                };
                input = this.cfg.sandboxProxy;
                init = { ...init, headers: proxyHeaders };
            }
            // 'audit-only' falls through to the actual fetch below
        }
        // Emit pre-send audit event
        this._emitAuditEvent({
            eventType: 'http:intercepted',
            traceId,
            timestamp,
            hostname,
            method: init?.method ?? 'GET',
            sanitizedPath,
            queryRedacted,
            bytesOut,
            agentIdentity: context.agentIdentity,
            packBinding: context.packBinding,
            classification,
            policyApplied,
            secretLeakDetected: leakResult.detected,
            leakCategories: leakResult.detected
                ? leakResult.findings.map((f) => f.category)
                : undefined,
            bodyRedacted: bodyRedacted || undefined,
        });
        // Execute the original fetch
        const response = await this.originalFetch(input, init);
        // Update audit event with response status (we cannot mutate the emitted
        // event retroactively here, so we emit a second completion event)
        this._emitAuditEvent({
            eventType: 'http:intercepted',
            traceId,
            timestamp: new Date().toISOString(),
            hostname,
            method: init?.method ?? 'GET',
            sanitizedPath,
            queryRedacted,
            bytesOut,
            responseStatus: response.status,
            agentIdentity: context.agentIdentity,
            packBinding: context.packBinding,
            classification,
            policyApplied,
            secretLeakDetected: leakResult.detected,
            leakCategories: leakResult.detected
                ? leakResult.findings.map((f) => f.category)
                : undefined,
            bodyRedacted: bodyRedacted || undefined,
        });
        return response;
    }
    // ---------------------------------------------------------------------------
    // Internal helpers
    // ---------------------------------------------------------------------------
    _emitAuditEvent(event) {
        if (this.cfg.onAuditEvent) {
            try {
                this.cfg.onAuditEvent(event);
            }
            catch {
                // Audit sink errors must never propagate and break the guarded fetch
            }
        }
    }
}
// ---------------------------------------------------------------------------
// Body redaction helper
// ---------------------------------------------------------------------------
/**
 * Redacts known secret patterns from a body string.
 * Uses the same pattern catalog as SecretLeakDetector.
 *
 * Exposed for testing.
 */
export function redactBody(body, _patternId) {
    // Apply all known patterns (conservative: redact anything that looks like a secret)
    const patterns = [
        /sk-[A-Za-z0-9]{20,}/g,
        /sk-ant-[A-Za-z0-9_-]{20,}/g,
        /xox[bpas]-[A-Za-z0-9-]{10,}/g,
        /gh[psohr]_[A-Za-z0-9]{36,}/g,
        /(?:AKIA|ASIA)[A-Z0-9]{16}/g,
        /Bearer\s+[A-Za-z0-9+/=_.-]{20,}/g,
    ];
    let result = body;
    for (const pat of patterns) {
        result = result.replace(pat, '[REDACTED]');
    }
    return result;
}
// ---------------------------------------------------------------------------
// Module-level singleton helpers
// ---------------------------------------------------------------------------
let _defaultInterceptor = null;
/**
 * Create-or-return a process-scoped default interceptor.
 * Useful for applications that want a single shared interceptor.
 */
export function getDefaultInterceptor(config) {
    if (!_defaultInterceptor) {
        _defaultInterceptor = new RawHttpInterceptor(config);
    }
    return _defaultInterceptor;
}
/** Clear the process-scoped default interceptor (useful in tests). */
export function clearDefaultInterceptor() {
    if (_defaultInterceptor?.isAttached()) {
        _defaultInterceptor.detach();
    }
    _defaultInterceptor = null;
}
//# sourceMappingURL=http-interceptor.js.map