Refactor secrets detection into pattern registry (#18)
authorMax Wolf <redacted>
Sun, 11 Jan 2026 17:55:34 +0000 (18:55 +0100)
committerGitHub <redacted>
Sun, 11 Jan 2026 17:55:34 +0000 (18:55 +0100)
* 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>
12 files changed:
README.md
config.example.yaml
docs/concepts/secrets-detection.mdx
docs/configuration/secrets-detection.mdx
src/config.ts
src/secrets/detect.ts
src/secrets/patterns/api-keys.ts [new file with mode: 0644]
src/secrets/patterns/index.ts [new file with mode: 0644]
src/secrets/patterns/private-keys.ts [new file with mode: 0644]
src/secrets/patterns/tokens.ts [new file with mode: 0644]
src/secrets/patterns/types.ts [new file with mode: 0644]
src/secrets/patterns/utils.ts [new file with mode: 0644]

index d12ff9c2f355d602ca504ebe23f1a4c57ad485ef..973550eee5ce23cb6f8df9ee1eddcd7185f68f28 100644 (file)
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ docker compose up -d
 
 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)**
 
index 96e18c2bc4674ac5f3c689d02f60a88013d2a3d0..6ada82e04d15ba41f33a68437ef6d2de84e93e79 100644 (file)
@@ -96,9 +96,10 @@ secrets_detection:
   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
index 8206536ee22f6cf201f6059daf576a40b88d68db..0db671d3322608bf7a01d7f2238aee2bb9b43542 100644 (file)
@@ -35,28 +35,28 @@ PasteGuard detects secrets before PII detection and can block, redact, or route
 
 | 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
 
index 89a23e71df8a5661c6fc364de52760268a56a795..ac79f114a85226c6a318e9af3fb9f8faab2b76d2 100644 (file)
@@ -8,7 +8,7 @@ description: Configure secrets detection settings
 ```yaml
 secrets_detection:
   enabled: true
-  action: block
+  action: redact
   entities:
     - OPENSSH_PRIVATE_KEY
     - PEM_PRIVATE_KEY
@@ -21,7 +21,7 @@ secrets_detection:
 | 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) |
@@ -31,22 +31,22 @@ secrets_detection:
 
 | 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
index 426456fc7dc236196a9e5e56e2fda5bdfcf3a30d..764f27c0d0ffc047ee540b0e3249c88e5f80757f 100644 (file)
@@ -105,7 +105,7 @@ const SecretEntityTypes = [
 
 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}>"),
index 85e8f7f5e7762b77e58895477b6e0f3bcdc6c4a7..66c962bcd63b7168046b1f0ec8910df4969b02e0 100644 (file)
@@ -1,35 +1,16 @@
 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
@@ -46,49 +27,13 @@ export function extractTextFromRequest(body: ChatCompletionRequest): string {
     .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.
  */
@@ -103,107 +48,31 @@ export function detectSecrets(
   // 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,
   };
 }
diff --git a/src/secrets/patterns/api-keys.ts b/src/secrets/patterns/api-keys.ts
new file mode 100644 (file)
index 0000000..438c1cd
--- /dev/null
@@ -0,0 +1,44 @@
+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,
+    };
+  },
+};
diff --git a/src/secrets/patterns/index.ts b/src/secrets/patterns/index.ts
new file mode 100644 (file)
index 0000000..957f301
--- /dev/null
@@ -0,0 +1,20 @@
+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";
diff --git a/src/secrets/patterns/private-keys.ts b/src/secrets/patterns/private-keys.ts
new file mode 100644 (file)
index 0000000..2325eea
--- /dev/null
@@ -0,0 +1,84 @@
+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,
+    };
+  },
+};
diff --git a/src/secrets/patterns/tokens.ts b/src/secrets/patterns/tokens.ts
new file mode 100644 (file)
index 0000000..c5a6c02
--- /dev/null
@@ -0,0 +1,38 @@
+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,
+    };
+  },
+};
diff --git a/src/secrets/patterns/types.ts b/src/secrets/patterns/types.ts
new file mode 100644 (file)
index 0000000..e8ebc02
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * 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;
+}
diff --git a/src/secrets/patterns/utils.ts b/src/secrets/patterns/utils.ts
new file mode 100644 (file)
index 0000000..aa1d1f2
--- /dev/null
@@ -0,0 +1,33 @@
+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;
+}
git clone https://git.99rst.org/PROJECT