* Add PatternDetector and DetectionResult interfaces for secrets detection registry
* Move all interfaces to patterns/types.ts and use the existing SecretesDetectionResult interface instead of the new DetectionResult
* Move pattern detection utility to new patterns/utils.ts module
* Refactor secrets detection using a registry system
- Create privateKeysDetector, apiKeysDetector, tokensDetector modules
- Refactor detectSecrets() to use the pattern registry
- Re-export types from detect.ts for backwards compatibility
* Change default secrets_detection action to redaction
Hint: The example config still shows `action: block` explicitly, with a comment noting
that `redact` is the default action if not specified
* Fix README default action references and improve overall structure / formatting
- Update all references from 'block (default)' to 'redact (default)'
- Fix Bearer token documentation (20+ → 40+ chars)
- Reorganize Configuration section with consistent headers
- Improve table formatting and section descriptions
- Use references to reduce duplications and maintenance overhead
* Improve type safety in PatternDetector interface
Use SecretEntityType instead of string for enabledTypes Set parameter
* Update docs to reflect redact as new default action
Reorder actions to show default first
---------
Co-authored-by: Stefan Gasser <redacted>
Point your app to `http://localhost:3000/openai/v1` instead of `https://api.openai.com/v1`.
-Dashboard: http://localhost:3000/dashboard
+Dashboard: [http://localhost:3000/dashboard](http://localhost:3000/dashboard)
For multiple languages, configuration options, and more: **[Read the docs →](https://pasteguard.com/docs/quickstart)**
enabled: true
# Action to take when secrets are detected:
- # block: Block the request with HTTP 400 (default, secure-by-default)
- # redact: Replace secrets with placeholders, unmask in response (reversible)
+ # block: Block the request with HTTP 400
+ # redact: Replace secrets with placeholders, unmask in response
# route_local: Route to local provider (only works in route mode)
+ # Default: redact (if not specified)
action: block
# Secret types to detect
| Action | Description |
|--------|-------------|
-| `block` | Return HTTP 400, request never reaches LLM (default) |
-| `redact` | Replace secrets with placeholders, restore in response |
+| `redact` | Replace secrets with placeholders, restore in response (default) |
+| `block` | Return HTTP 400, request never reaches LLM |
| `route_local` | Route to local LLM (requires route mode) |
-### Block (Default)
+### Redact (Default)
```yaml
secrets_detection:
- enabled: true
- action: block
+ action: redact
```
-Request is rejected with HTTP 400. The secret never reaches the LLM.
+Secrets are replaced with placeholders and restored in the response (like PII masking).
-### Redact
+### Block
```yaml
secrets_detection:
- action: redact
+ enabled: true
+ action: block
```
-Secrets are replaced with placeholders and restored in the response (like PII masking).
+Request is rejected with HTTP 400. The secret never reaches the LLM.
### Route to Local
```yaml
secrets_detection:
enabled: true
- action: block
+ action: redact
entities:
- OPENSSH_PRIVATE_KEY
- PEM_PRIVATE_KEY
| Option | Default | Description |
|--------|---------|-------------|
| `enabled` | `true` | Enable secrets detection |
-| `action` | `block` | Action when secrets found |
+| `action` | `redact` | Action when secrets found |
| `entities` | Private keys | Secret types to detect |
| `max_scan_chars` | `200000` | Max characters to scan (0 = unlimited) |
| `log_detected_types` | `true` | Log detected types (never logs content) |
| Action | Description |
|--------|-------------|
-| `block` | Return HTTP 400, request never reaches LLM (default) |
-| `redact` | Replace secrets with placeholders, restore in response |
+| `redact` | Replace secrets with placeholders, restore in response (default) |
+| `block` | Return HTTP 400, request never reaches LLM |
| `route_local` | Route to local LLM (requires route mode) |
-### Block (Default)
+### Redact (Default)
```yaml
secrets_detection:
- action: block
+ action: redact
```
-### Redact
+### Block
```yaml
secrets_detection:
- action: redact
+ action: block
```
### Route to Local
const SecretsDetectionSchema = z.object({
enabled: z.boolean().default(true),
- action: z.enum(["block", "redact", "route_local"]).default("block"),
+ action: z.enum(["block", "redact", "route_local"]).default("redact"),
entities: z.array(z.enum(SecretEntityTypes)).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}>"),
import type { SecretsDetectionConfig } from "../config";
import type { ChatCompletionRequest } from "../services/llm-client";
import { extractTextContent } from "../utils/content";
+import { patternDetectors } from "./patterns";
+import type { SecretsDetectionResult, SecretsMatch, SecretsRedaction } from "./patterns/types";
-/**
- * All supported secret entity types
- */
-export type SecretEntityType =
- | "OPENSSH_PRIVATE_KEY"
- | "PEM_PRIVATE_KEY"
- | "API_KEY_OPENAI"
- | "API_KEY_AWS"
- | "API_KEY_GITHUB"
- | "JWT_TOKEN"
- | "BEARER_TOKEN";
-
-export interface SecretsMatch {
- type: SecretEntityType;
- count: number;
-}
-
-export interface SecretsRedaction {
- start: number;
- end: number;
- type: SecretEntityType;
-}
-
-export interface SecretsDetectionResult {
- detected: boolean;
- matches: SecretsMatch[];
- redactions?: SecretsRedaction[];
-}
+// Re-export types from patterns module for backwards compatibility
+export type {
+ SecretEntityType,
+ SecretsDetectionResult,
+ SecretsMatch,
+ SecretsRedaction,
+} from "./patterns/types";
/**
* Extracts all text content from an OpenAI chat completion request
.join("\n");
}
-/**
- * Helper to detect secrets matching a pattern and add to matches/redactions
- */
-function detectPattern(
- textToScan: string,
- pattern: RegExp,
- entityType: SecretEntityType,
- matches: SecretsMatch[],
- redactions: SecretsRedaction[],
- existingPositions?: Set<number>,
-): number {
- let count = 0;
- for (const match of textToScan.matchAll(pattern)) {
- if (match.index !== undefined) {
- // Skip if this position was already matched by another pattern
- if (existingPositions?.has(match.index)) continue;
-
- count++;
- existingPositions?.add(match.index);
- redactions.push({
- start: match.index,
- end: match.index + match[0].length,
- type: entityType,
- });
- }
- }
- if (count > 0) {
- matches.push({ type: entityType, count });
- }
- return count;
-}
-
/**
* Detects secret material (e.g. private keys, API keys, tokens) in text
*
- * Scans for:
- * - OpenSSH private keys: -----BEGIN OPENSSH PRIVATE KEY-----
- * - PEM private keys: RSA, PRIVATE KEY, ENCRYPTED PRIVATE KEY
- * - OpenAI API keys: sk-... (48+ chars)
- * - AWS access keys: AKIA... (20 chars)
- * - GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ (40+ chars)
- * - JWT tokens: eyJ... (three base64 segments)
- * - Bearer tokens: Bearer ... (in Authorization-style contexts)
+ * Uses the pattern registry to scan for various secret types:
+ * - Private keys: OpenSSH, PEM (RSA, generic, encrypted)
+ * - API keys: OpenAI, AWS, GitHub
+ * - Tokens: JWT, Bearer
*
* Respects max_scan_chars limit for performance.
*/
// Apply max_scan_chars limit
const textToScan = config.max_scan_chars > 0 ? text.slice(0, config.max_scan_chars) : text;
- const matches: SecretsMatch[] = [];
- const redactions: SecretsRedaction[] = [];
-
// Track which entities to detect based on config
- const entitiesToDetect = new Set(config.entities);
+ const enabledTypes = new Set(config.entities);
- // OpenSSH private key pattern
- if (entitiesToDetect.has("OPENSSH_PRIVATE_KEY")) {
- const opensshPattern =
- /-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----/g;
- detectPattern(textToScan, opensshPattern, "OPENSSH_PRIVATE_KEY", matches, redactions);
- }
-
- // PEM private key patterns
- if (entitiesToDetect.has("PEM_PRIVATE_KEY")) {
- // Track all matched positions to avoid double counting
- const matchedPositions = new Set<number>();
-
- // RSA PRIVATE KEY
- const rsaPattern = /-----BEGIN RSA PRIVATE KEY-----[\s\S]*?-----END RSA PRIVATE KEY-----/g;
- detectPattern(textToScan, rsaPattern, "PEM_PRIVATE_KEY", matches, redactions, matchedPositions);
-
- // Remove PEM_PRIVATE_KEY from matches to accumulate all PEM types together
- const pemMatch = matches.find((m) => m.type === "PEM_PRIVATE_KEY");
- if (pemMatch) {
- matches.splice(matches.indexOf(pemMatch), 1);
- }
- let totalPemCount = pemMatch?.count || 0;
-
- // PRIVATE KEY (generic) - exclude RSA matches
- const privateKeyPattern = /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/g;
- const tempMatches: SecretsMatch[] = [];
- detectPattern(
- textToScan,
- privateKeyPattern,
- "PEM_PRIVATE_KEY",
- tempMatches,
- redactions,
- matchedPositions,
- );
- totalPemCount += tempMatches[0]?.count || 0;
+ // Aggregate results from all pattern detectors
+ const allMatches: SecretsMatch[] = [];
+ const allRedactions: SecretsRedaction[] = [];
- // ENCRYPTED PRIVATE KEY
- const encryptedPattern =
- /-----BEGIN ENCRYPTED PRIVATE KEY-----[\s\S]*?-----END ENCRYPTED PRIVATE KEY-----/g;
- const tempMatches2: SecretsMatch[] = [];
- detectPattern(
- textToScan,
- encryptedPattern,
- "PEM_PRIVATE_KEY",
- tempMatches2,
- redactions,
- matchedPositions,
- );
- totalPemCount += tempMatches2[0]?.count || 0;
+ for (const detector of patternDetectors) {
+ // Skip detectors that don't handle any enabled types
+ const hasEnabledPattern = detector.patterns.some((p) => enabledTypes.has(p));
+ if (!hasEnabledPattern) continue;
- if (totalPemCount > 0) {
- matches.push({ type: "PEM_PRIVATE_KEY", count: totalPemCount });
+ const result = detector.detect(textToScan, enabledTypes);
+ allMatches.push(...result.matches);
+ if (result.redactions) {
+ allRedactions.push(...result.redactions);
}
}
- // OpenAI API keys: sk-... followed by alphanumeric chars
- // Modern format: sk-proj-... or sk-... with 48+ total chars
- if (entitiesToDetect.has("API_KEY_OPENAI")) {
- // Match sk- followed by optional prefix (proj-, etc.) and alphanumeric/dash/underscore
- const openaiPattern = /sk-[a-zA-Z0-9_-]{45,}/g;
- detectPattern(textToScan, openaiPattern, "API_KEY_OPENAI", matches, redactions);
- }
-
- // AWS access keys: AKIA followed by 16 uppercase alphanumeric chars
- if (entitiesToDetect.has("API_KEY_AWS")) {
- const awsPattern = /AKIA[0-9A-Z]{16}/g;
- detectPattern(textToScan, awsPattern, "API_KEY_AWS", matches, redactions);
- }
-
- // GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ followed by 36+ alphanumeric chars
- if (entitiesToDetect.has("API_KEY_GITHUB")) {
- const githubPattern = /gh[pousr]_[a-zA-Z0-9]{36,}/g;
- detectPattern(textToScan, githubPattern, "API_KEY_GITHUB", matches, redactions);
- }
-
- // JWT tokens: three base64url segments separated by dots
- // Header starts with eyJ (base64 for {"...), minimum 20 chars per segment
- if (entitiesToDetect.has("JWT_TOKEN")) {
- const jwtPattern = /eyJ[a-zA-Z0-9_-]{20,}\.eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}/g;
- detectPattern(textToScan, jwtPattern, "JWT_TOKEN", matches, redactions);
- }
-
- // Bearer tokens in Authorization-style contexts
- // Matches "Bearer " followed by a token (at least 40 chars to reduce placeholder matches)
- if (entitiesToDetect.has("BEARER_TOKEN")) {
- const bearerPattern = /Bearer\s+[a-zA-Z0-9._-]{40,}/gi;
- detectPattern(textToScan, bearerPattern, "BEARER_TOKEN", matches, redactions);
- }
-
// Sort redactions by start position (descending) for safe replacement
- redactions.sort((a, b) => b.start - a.start);
+ allRedactions.sort((a, b) => b.start - a.start);
return {
- detected: matches.length > 0,
- matches,
- redactions: redactions.length > 0 ? redactions : undefined,
+ detected: allMatches.length > 0,
+ matches: allMatches,
+ redactions: allRedactions.length > 0 ? allRedactions : undefined,
};
}
--- /dev/null
+import type { PatternDetector, SecretsMatch, SecretsRedaction } from "./types";
+import { detectPattern } from "./utils";
+
+/**
+ * API keys detector
+ *
+ * Detects:
+ * - API_KEY_OPENAI: OpenAI API keys (sk-...)
+ * - API_KEY_AWS: AWS Access Keys (AKIA...)
+ * - API_KEY_GITHUB: GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
+ */
+export const apiKeysDetector: PatternDetector = {
+ patterns: ["API_KEY_OPENAI", "API_KEY_AWS", "API_KEY_GITHUB"],
+
+ detect(text: string, enabledTypes: Set<string>) {
+ const matches: SecretsMatch[] = [];
+ const redactions: SecretsRedaction[] = [];
+
+ // OpenAI API keys: sk-... followed by alphanumeric chars
+ // Modern format: sk-proj-... or sk-... with 48+ total chars
+ if (enabledTypes.has("API_KEY_OPENAI")) {
+ const openaiPattern = /sk-[a-zA-Z0-9_-]{45,}/g;
+ detectPattern(text, openaiPattern, "API_KEY_OPENAI", matches, redactions);
+ }
+
+ // AWS access keys: AKIA followed by 16 uppercase alphanumeric chars
+ if (enabledTypes.has("API_KEY_AWS")) {
+ const awsPattern = /AKIA[0-9A-Z]{16}/g;
+ detectPattern(text, awsPattern, "API_KEY_AWS", matches, redactions);
+ }
+
+ // GitHub tokens: ghp_, gho_, ghu_, ghs_, ghr_ followed by 36+ alphanumeric chars
+ if (enabledTypes.has("API_KEY_GITHUB")) {
+ const githubPattern = /gh[pousr]_[a-zA-Z0-9]{36,}/g;
+ detectPattern(text, githubPattern, "API_KEY_GITHUB", matches, redactions);
+ }
+
+ return {
+ detected: matches.length > 0,
+ matches,
+ redactions: redactions.length > 0 ? redactions : undefined,
+ };
+ },
+};
--- /dev/null
+import { apiKeysDetector } from "./api-keys";
+import { privateKeysDetector } from "./private-keys";
+import { tokensDetector } from "./tokens";
+import type { PatternDetector } from "./types";
+
+/**
+ * Registry of all pattern detectors
+ *
+ * Each detector handles one or more secret entity types.
+ * New detectors can be added here to extend secrets detection.
+ */
+export const patternDetectors: PatternDetector[] = [
+ privateKeysDetector,
+ apiKeysDetector,
+ tokensDetector,
+];
+
+// Re-export types and utilities for convenience
+export type { PatternDetector, SecretEntityType, SecretsDetectionResult } from "./types";
+export { detectPattern } from "./utils";
--- /dev/null
+import type {
+ PatternDetector,
+ SecretsDetectionResult,
+ SecretsMatch,
+ SecretsRedaction,
+} from "./types";
+import { detectPattern } from "./utils";
+
+/**
+ * Private keys detector
+ *
+ * Detects:
+ * - OPENSSH_PRIVATE_KEY: OpenSSH format (-----BEGIN OPENSSH PRIVATE KEY-----)
+ * - PEM_PRIVATE_KEY: PEM formats (RSA, PRIVATE KEY, ENCRYPTED PRIVATE KEY)
+ */
+export const privateKeysDetector: PatternDetector = {
+ patterns: ["OPENSSH_PRIVATE_KEY", "PEM_PRIVATE_KEY"],
+
+ detect(text: string, enabledTypes: Set<string>): SecretsDetectionResult {
+ const matches: SecretsMatch[] = [];
+ const redactions: SecretsRedaction[] = [];
+
+ // OpenSSH private key pattern
+ if (enabledTypes.has("OPENSSH_PRIVATE_KEY")) {
+ const opensshPattern =
+ /-----BEGIN OPENSSH PRIVATE KEY-----[\s\S]*?-----END OPENSSH PRIVATE KEY-----/g;
+ detectPattern(text, opensshPattern, "OPENSSH_PRIVATE_KEY", matches, redactions);
+ }
+
+ // PEM private key patterns
+ if (enabledTypes.has("PEM_PRIVATE_KEY")) {
+ // Track all matched positions to avoid double counting
+ const matchedPositions = new Set<number>();
+
+ // RSA PRIVATE KEY
+ const rsaPattern = /-----BEGIN RSA PRIVATE KEY-----[\s\S]*?-----END RSA PRIVATE KEY-----/g;
+ detectPattern(text, rsaPattern, "PEM_PRIVATE_KEY", matches, redactions, matchedPositions);
+
+ // Remove PEM_PRIVATE_KEY from matches to accumulate all PEM types together
+ const pemMatch = matches.find((m) => m.type === "PEM_PRIVATE_KEY");
+ if (pemMatch) {
+ matches.splice(matches.indexOf(pemMatch), 1);
+ }
+ let totalPemCount = pemMatch?.count || 0;
+
+ // PRIVATE KEY (generic) - exclude RSA matches
+ const privateKeyPattern = /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/g;
+ const tempMatches: SecretsMatch[] = [];
+ detectPattern(
+ text,
+ privateKeyPattern,
+ "PEM_PRIVATE_KEY",
+ tempMatches,
+ redactions,
+ matchedPositions,
+ );
+ totalPemCount += tempMatches[0]?.count || 0;
+
+ // ENCRYPTED PRIVATE KEY
+ const encryptedPattern =
+ /-----BEGIN ENCRYPTED PRIVATE KEY-----[\s\S]*?-----END ENCRYPTED PRIVATE KEY-----/g;
+ const tempMatches2: SecretsMatch[] = [];
+ detectPattern(
+ text,
+ encryptedPattern,
+ "PEM_PRIVATE_KEY",
+ tempMatches2,
+ redactions,
+ matchedPositions,
+ );
+ totalPemCount += tempMatches2[0]?.count || 0;
+
+ if (totalPemCount > 0) {
+ matches.push({ type: "PEM_PRIVATE_KEY", count: totalPemCount });
+ }
+ }
+
+ return {
+ detected: matches.length > 0,
+ matches,
+ redactions: redactions.length > 0 ? redactions : undefined,
+ };
+ },
+};
--- /dev/null
+import type { PatternDetector, SecretsMatch, SecretsRedaction } from "./types";
+import { detectPattern } from "./utils";
+
+/**
+ * Tokens detector
+ *
+ * Detects:
+ * - JWT_TOKEN: JSON Web Tokens (eyJ...)
+ * - BEARER_TOKEN: Bearer tokens in Authorization-style contexts
+ */
+export const tokensDetector: PatternDetector = {
+ patterns: ["JWT_TOKEN", "BEARER_TOKEN"],
+
+ detect(text: string, enabledTypes: Set<string>) {
+ const matches: SecretsMatch[] = [];
+ const redactions: SecretsRedaction[] = [];
+
+ // JWT tokens: three base64url segments separated by dots
+ // Header starts with eyJ (base64 for {"...), minimum 20 chars per segment
+ if (enabledTypes.has("JWT_TOKEN")) {
+ const jwtPattern = /eyJ[a-zA-Z0-9_-]{20,}\.eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}/g;
+ detectPattern(text, jwtPattern, "JWT_TOKEN", matches, redactions);
+ }
+
+ // Bearer tokens in Authorization-style contexts
+ // Matches "Bearer " followed by a token (at least 40 chars to reduce placeholder matches)
+ if (enabledTypes.has("BEARER_TOKEN")) {
+ const bearerPattern = /Bearer\s+[a-zA-Z0-9._-]{40,}/gi;
+ detectPattern(text, bearerPattern, "BEARER_TOKEN", matches, redactions);
+ }
+
+ return {
+ detected: matches.length > 0,
+ matches,
+ redactions: redactions.length > 0 ? redactions : undefined,
+ };
+ },
+};
--- /dev/null
+/**
+ * All supported secret entity types
+ */
+export type SecretEntityType =
+ | "OPENSSH_PRIVATE_KEY"
+ | "PEM_PRIVATE_KEY"
+ | "API_KEY_OPENAI"
+ | "API_KEY_AWS"
+ | "API_KEY_GITHUB"
+ | "JWT_TOKEN"
+ | "BEARER_TOKEN";
+
+export interface SecretsMatch {
+ type: SecretEntityType;
+ count: number;
+}
+
+export interface SecretsRedaction {
+ start: number;
+ end: number;
+ type: SecretEntityType;
+}
+
+export interface SecretsDetectionResult {
+ detected: boolean;
+ matches: SecretsMatch[];
+ redactions?: SecretsRedaction[];
+}
+
+/**
+ * Interface for pattern detector modules
+ *
+ * Each detector handles one or more secret entity types and provides
+ * a detect function that scans text for those patterns.
+ */
+export interface PatternDetector {
+ /** Entity types this detector can detect */
+ patterns: SecretEntityType[];
+
+ /** Run detection for enabled entity types */
+ detect(text: string, enabledTypes: Set<SecretEntityType>): SecretsDetectionResult;
+}
--- /dev/null
+import type { SecretEntityType, SecretsMatch, SecretsRedaction } from "./types";
+
+/**
+ * Helper to detect secrets matching a pattern and collect matches/redactions
+ */
+export function detectPattern(
+ text: string,
+ pattern: RegExp,
+ entityType: SecretEntityType,
+ matches: SecretsMatch[],
+ redactions: SecretsRedaction[],
+ existingPositions?: Set<number>,
+): number {
+ let count = 0;
+ for (const match of text.matchAll(pattern)) {
+ if (match.index !== undefined) {
+ // Skip if this position was already matched by another pattern
+ if (existingPositions?.has(match.index)) continue;
+
+ count++;
+ existingPositions?.add(match.index);
+ redactions.push({
+ start: match.index,
+ end: match.index + match[0].length,
+ type: entityType,
+ });
+ }
+ }
+ if (count > 0) {
+ matches.push({ type: entityType, count });
+ }
+ return count;
+}