From: maximiliancw Date: Fri, 9 Jan 2026 13:44:59 +0000 (+0100) Subject: Enhance secrets detection functionality in proxy routes: X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=a14b737678bdf94bb3f326f2baa67798c9b320bd;p=sgasser-llm-shield.git Enhance secrets detection functionality in proxy routes: - Integrate secrets detection logic into the proxy request handling. - Add configuration checks for enabling/disabling secrets detection. - Implement logging for detected secrets and their types. - Update the logger to accommodate new fields for secrets detection. - Refactor related functions for improved clarity and maintainability --- diff --git a/src/config.ts b/src/config.ts index b6ac5f4..21c9517 100644 --- a/src/config.ts +++ b/src/config.ts @@ -95,9 +95,7 @@ const DashboardSchema = z.object({ const SecretsDetectionSchema = z.object({ enabled: z.boolean().default(true), action: z.enum(["block", "redact", "route_local"]).default("block"), - entities: z - .array(z.string()) - .default(["OPENSSH_PRIVATE_KEY", "PEM_PRIVATE_KEY"]), + entities: z.array(z.string()).default(["OPENSSH_PRIVATE_KEY", "PEM_PRIVATE_KEY"]), max_scan_chars: z.coerce.number().int().min(0).default(200000), redact_placeholder: z.string().default(""), log_detected_types: z.boolean().default(true), diff --git a/src/routes/proxy.ts b/src/routes/proxy.ts index f3a10c7..3466f2f 100644 --- a/src/routes/proxy.ts +++ b/src/routes/proxy.ts @@ -4,7 +4,8 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { proxy } from "hono/proxy"; import { z } from "zod"; -import type { MaskingConfig } from "../config"; +import { getConfig, type MaskingConfig } from "../config"; +import { detectSecrets, extractTextFromRequest } from "../secrets/detect"; import { getRouter, type MaskDecision, type RoutingDecision } from "../services/decision"; import type { ChatCompletionRequest, @@ -71,8 +72,62 @@ proxyRoutes.post( async (c) => { const startTime = Date.now(); const body = c.req.valid("json") as ChatCompletionRequest; + const config = getConfig(); const router = getRouter(); + // Secrets detection runs before PII detection + if (config.secrets_detection.enabled) { + const text = extractTextFromRequest(body); + const secretsResult = detectSecrets(text, config.secrets_detection); + + if (secretsResult.detected) { + const secretTypes = secretsResult.matches.map((m) => m.type); + const secretTypesStr = secretTypes.join(", "); + + // Set headers before returning error + c.header("X-LLM-Shield-Secrets-Detected", "true"); + c.header("X-LLM-Shield-Secrets-Types", secretTypesStr); + + // Block action (Phase 1) - return 422 error + if (config.secrets_detection.action === "block") { + // Log metadata only (no secret content) + logRequest( + { + timestamp: new Date().toISOString(), + mode: config.mode, + provider: "upstream", // Note: Request never reached provider + model: body.model || "unknown", + piiDetected: false, + entities: [], + latencyMs: Date.now() - startTime, + scanTimeMs: 0, + language: config.pii_detection.fallback_language, + languageFallback: false, + secretsDetected: true, + secretsTypes: secretTypes, + }, + c.req.header("User-Agent") || null, + ); + + return c.json( + { + error: { + message: `Request blocked: detected secret material (${secretTypesStr}). Remove secrets and retry.`, + type: "invalid_request_error", + details: { + secrets_detected: secretTypes, + }, + }, + }, + 422, + ); + } + + // TODO: Phase 2 - redact and route_local actions + // For now, if action is not "block", we continue (but this shouldn't happen in Phase 1) + } + } + let decision: RoutingDecision; try { decision = await router.decide(body.messages); @@ -111,13 +166,44 @@ async function handleCompletion( try { const result = await client.chatCompletion(request, authHeader); - setShieldHeaders(c, decision); + // Check for secrets in the request (for headers, even if not blocking) + const config = getConfig(); + let secretsDetected = false; + let secretsTypes: string[] = []; + if (config.secrets_detection.enabled) { + const text = extractTextFromRequest(body); + const secretsResult = detectSecrets(text, config.secrets_detection); + if (secretsResult.detected) { + secretsDetected = true; + secretsTypes = secretsResult.matches.map((m) => m.type); + } + } + + setShieldHeaders(c, decision, secretsDetected, secretsTypes); if (result.isStreaming) { - return handleStreamingResponse(c, result, decision, startTime, maskedContent, maskingConfig); + return handleStreamingResponse( + c, + result, + decision, + startTime, + maskedContent, + maskingConfig, + secretsDetected, + secretsTypes, + ); } - return handleJsonResponse(c, result, decision, startTime, maskedContent, maskingConfig); + return handleJsonResponse( + c, + result, + decision, + startTime, + maskedContent, + maskingConfig, + secretsDetected, + secretsTypes, + ); } catch (error) { console.error("LLM request error:", error); const message = error instanceof Error ? error.message : "Unknown error"; @@ -128,7 +214,12 @@ async function handleCompletion( /** * Set X-LLM-Shield response headers */ -function setShieldHeaders(c: Context, decision: RoutingDecision) { +function setShieldHeaders( + c: Context, + decision: RoutingDecision, + secretsDetected?: boolean, + secretsTypes?: string[], +) { c.header("X-LLM-Shield-Mode", decision.mode); c.header("X-LLM-Shield-Provider", decision.provider); c.header("X-LLM-Shield-PII-Detected", decision.piiResult.hasPII.toString()); @@ -139,6 +230,10 @@ function setShieldHeaders(c: Context, decision: RoutingDecision) { if (decision.mode === "mask") { c.header("X-LLM-Shield-PII-Masked", decision.piiResult.hasPII.toString()); } + if (secretsDetected && secretsTypes) { + c.header("X-LLM-Shield-Secrets-Detected", "true"); + c.header("X-LLM-Shield-Secrets-Types", secretsTypes.join(",")); + } } /** @@ -151,9 +246,19 @@ function handleStreamingResponse( startTime: number, maskedContent: string | undefined, maskingConfig: MaskingConfig, + secretsDetected?: boolean, + secretsTypes?: string[], ) { logRequest( - createLogData(decision, result, startTime, undefined, maskedContent), + createLogData( + decision, + result, + startTime, + undefined, + maskedContent, + secretsDetected, + secretsTypes, + ), c.req.header("User-Agent") || null, ); @@ -183,9 +288,19 @@ function handleJsonResponse( startTime: number, maskedContent: string | undefined, maskingConfig: MaskingConfig, + secretsDetected?: boolean, + secretsTypes?: string[], ) { logRequest( - createLogData(decision, result, startTime, result.response, maskedContent), + createLogData( + decision, + result, + startTime, + result.response, + maskedContent, + secretsDetected, + secretsTypes, + ), c.req.header("User-Agent") || null, ); @@ -205,6 +320,8 @@ function createLogData( startTime: number, response?: ChatCompletionResponse, maskedContent?: string, + secretsDetected?: boolean, + secretsTypes?: string[], ): RequestLogData { return { timestamp: new Date().toISOString(), @@ -221,6 +338,8 @@ function createLogData( languageFallback: decision.piiResult.languageFallback, detectedLanguage: decision.piiResult.detectedLanguage, maskedContent, + secretsDetected, + secretsTypes, }; } diff --git a/src/secrets/detect.ts b/src/secrets/detect.ts index 7a5879c..7b33402 100644 --- a/src/secrets/detect.ts +++ b/src/secrets/detect.ts @@ -29,10 +29,7 @@ export interface SecretsDetectionResult { export function extractTextFromRequest(body: ChatCompletionRequest): string { return body.messages .map((message) => message.content) - .filter( - (content): content is string => - typeof content === "string" && content.length > 0 - ) + .filter((content): content is string => typeof content === "string" && content.length > 0) .join("\n"); } @@ -47,15 +44,14 @@ export function extractTextFromRequest(body: ChatCompletionRequest): string { */ export function detectSecrets( text: string, - config: SecretsDetectionConfig + config: SecretsDetectionConfig, ): SecretsDetectionResult { if (!config.enabled) { return { detected: false, matches: [] }; } // Apply max_scan_chars limit - const textToScan = - config.max_scan_chars > 0 ? text.slice(0, config.max_scan_chars) : text; + const textToScan = config.max_scan_chars > 0 ? text.slice(0, config.max_scan_chars) : text; const matches: SecretsMatch[] = []; const redactions: SecretsRedaction[] = []; @@ -90,8 +86,7 @@ export function detectSecrets( const matchedPositions = new Set(); // RSA PRIVATE KEY - const rsaPattern = - /-----BEGIN RSA PRIVATE KEY-----[\s\S]*?-----END RSA PRIVATE KEY-----/g; + const rsaPattern = /-----BEGIN RSA PRIVATE KEY-----[\s\S]*?-----END RSA PRIVATE KEY-----/g; let rsaCount = 0; for (const match of textToScan.matchAll(rsaPattern)) { rsaCount++; @@ -106,8 +101,7 @@ export function detectSecrets( } // PRIVATE KEY (generic) - exclude RSA matches - const privateKeyPattern = - /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/g; + const privateKeyPattern = /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/g; let privateKeyCount = 0; for (const match of textToScan.matchAll(privateKeyPattern)) { if (match.index !== undefined && !matchedPositions.has(match.index)) { diff --git a/src/services/logger.ts b/src/services/logger.ts index b79ad8b..941778e 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -19,6 +19,8 @@ export interface RequestLog { language_fallback: boolean; detected_language: string | null; masked_content: string | null; + secrets_detected: number | null; + secrets_types: string | null; } /** @@ -76,6 +78,8 @@ export class Logger { language_fallback INTEGER NOT NULL DEFAULT 0, detected_language TEXT, masked_content TEXT, + secrets_detected INTEGER, + secrets_types TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); @@ -95,9 +99,9 @@ export class Logger { log(entry: Omit): void { const stmt = this.db.prepare(` INSERT INTO request_logs - (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content) + (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content, secrets_detected, secrets_types) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( @@ -116,6 +120,8 @@ export class Logger { entry.language_fallback ? 1 : 0, entry.detected_language, entry.masked_content, + entry.secrets_detected ?? null, + entry.secrets_types ?? null, ); } @@ -271,11 +277,18 @@ export interface RequestLogData { languageFallback: boolean; detectedLanguage?: string; maskedContent?: string; + secretsDetected?: boolean; + secretsTypes?: string[]; } export function logRequest(data: RequestLogData, userAgent: string | null): void { try { const logger = getLogger(); + + // Safety: Never log content if secrets were detected + // Even if log_content is true, secrets are never logged + const shouldLogContent = data.maskedContent && !data.secretsDetected; + logger.log({ timestamp: data.timestamp, mode: data.mode, @@ -291,7 +304,9 @@ export function logRequest(data: RequestLogData, userAgent: string | null): void language: data.language, language_fallback: data.languageFallback, detected_language: data.detectedLanguage ?? null, - masked_content: data.maskedContent ?? null, + masked_content: shouldLogContent ? (data.maskedContent ?? null) : null, + secrets_detected: data.secretsDetected !== undefined ? (data.secretsDetected ? 1 : 0) : null, + secrets_types: data.secretsTypes?.join(",") ?? null, }); } catch (error) { console.error("Failed to log request:", error);