Enhance secrets detection functionality in proxy routes:
authormaximiliancw <redacted>
Fri, 9 Jan 2026 13:44:59 +0000 (14:44 +0100)
committermaximiliancw <redacted>
Fri, 9 Jan 2026 13:44:59 +0000 (14:44 +0100)
- 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

src/config.ts
src/routes/proxy.ts
src/secrets/detect.ts
src/services/logger.ts

index b6ac5f4e92809c6c5908778fdde2302680e34432..21c9517f028a9582f2869f7863a4306b94857509 100644 (file)
@@ -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("<SECRET_REDACTED_{N}>"),
   log_detected_types: z.boolean().default(true),
index f3a10c76b45a07342573fc4a77d44eca8ec2a45b..3466f2f849b8a61cd7a9804ea357db6aba449f24 100644 (file)
@@ -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,
   };
 }
 
index 7a5879caa70b5ab2793c075c470202276f848472..7b33402c6e796eadc6d0e5b6a44a0b9484db8240 100644 (file)
@@ -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<number>();
 
     // 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)) {
index b79ad8b8bbe11654a221fa8816e23efb58ed6538..941778ec89dd28f54b2a274faf35a2edc9bd097d 100644 (file)
@@ -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<RequestLog, "id">): 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);
git clone https://git.99rst.org/PROJECT