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,
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);
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";
/**
* 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());
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(","));
+ }
}
/**
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,
);
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,
);
startTime: number,
response?: ChatCompletionResponse,
maskedContent?: string,
+ secretsDetected?: boolean,
+ secretsTypes?: string[],
): RequestLogData {
return {
timestamp: new Date().toISOString(),
languageFallback: decision.piiResult.languageFallback,
detectedLanguage: decision.piiResult.detectedLanguage,
maskedContent,
+ secretsDetected,
+ secretsTypes,
};
}
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");
}
*/
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[] = [];
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++;
}
// 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)) {
language_fallback: boolean;
detected_language: string | null;
masked_content: string | null;
+ secrets_detected: number | null;
+ secrets_types: string | null;
}
/**
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
)
`);
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(
entry.language_fallback ? 1 : 0,
entry.detected_language,
entry.masked_content,
+ entry.secrets_detected ?? null,
+ entry.secrets_types ?? null,
);
}
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,
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);