Add environment variable credential detection (#19)
authorMax Wolf <redacted>
Mon, 12 Jan 2026 15:02:29 +0000 (16:02 +0100)
committerGitHub <redacted>
Mon, 12 Jan 2026 15:02:29 +0000 (16:02 +0100)
* Add PatternDetector and DetectionResult interfaces for secrets detection registry

* 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

* Implement new pattern detector and add corresponding SecretEntityType options

* Register new detector and extend test suite accordingly

* Add new entity types to config.ts

* Update docs and example config

* Add environment variables section to secrets detection docs

---------

Co-authored-by: Stefan Gasser <redacted>
12 files changed:
README.md
config.example.yaml
docs/concepts/secrets-detection.mdx
docs/configuration/secrets-detection.mdx
docs/introduction.mdx
src/config.ts
src/secrets/detect.test.ts
src/secrets/detect.ts
src/secrets/patterns/env-vars.ts [new file with mode: 0644]
src/secrets/patterns/index.ts
src/secrets/patterns/types.ts
src/secrets/patterns/utils.ts

index df5ef043d9287df2c8c7f788cdf700ee98a19f50..fd52fe433d2d76564dd8b6baa0ef81ceafcfac1e 100644 (file)
--- a/README.md
+++ b/README.md
@@ -118,6 +118,9 @@ Works with any OpenAI-compatible tool:
 - GitHub tokens
 - JWT tokens
 - Bearer tokens
+- Env passwords
+- Env secrets
+- Connection strings
 
 ## Tech Stack
 
index dc2896aad5e289126293664ec99afee162e3e086..2adad623a80b0cd85c24119e0858bd5f6fa1ae6c 100644 (file)
@@ -114,6 +114,11 @@ secrets_detection:
   # Tokens (opt-in):
   #   - JWT_TOKEN:           JSON Web Tokens (eyJ...)
   #   - BEARER_TOKEN:        Bearer tokens in Authorization-style contexts
+  #
+  # Environment Variables (opt-in):
+  #   - ENV_PASSWORD:        Password variables (DB_PASSWORD=..., ADMIN_PWD=...)
+  #   - ENV_SECRET:          Secret variables (APP_SECRET=..., JWT_SECRET=...)
+  #   - CONNECTION_STRING:   Database URLs with credentials (postgres://user:pass@host/db)
   entities:
     - OPENSSH_PRIVATE_KEY
     - PEM_PRIVATE_KEY
@@ -123,6 +128,10 @@ secrets_detection:
     # - API_KEY_GITHUB
     # - JWT_TOKEN
     # - BEARER_TOKEN
+    # Uncomment to detect environment variable credentials:
+    # - ENV_PASSWORD
+    # - ENV_SECRET
+    # - CONNECTION_STRING
 
   # Maximum characters to scan per request (performance limit)
   # Note: Secrets placed after this limit won't be detected.
index 0db671d3322608bf7a01d7f2238aee2bb9b43542..0966b0164744e021dcea58465c9a707c74ff9e6d 100644 (file)
@@ -1,6 +1,6 @@
 ---
 title: Secrets Detection
-description: Detect and protect private keys, API keys, and tokens
+description: Detect and protect private keys, API keys, tokens, and environment credentials
 ---
 
 # Secrets Detection
@@ -31,6 +31,14 @@ PasteGuard detects secrets before PII detection and can block, redact, or route
 | `JWT_TOKEN` | `eyJ...` (three base64 segments) |
 | `BEARER_TOKEN` | `Bearer ...` (40+ char tokens) |
 
+### Environment Variables (opt-in)
+
+| Type | Pattern |
+|------|---------|
+| `ENV_PASSWORD` | `DB_PASSWORD=...`, `ADMIN_PWD=...` (8+ char values) |
+| `ENV_SECRET` | `APP_SECRET=...`, `JWT_SECRET=...` (8+ char values) |
+| `CONNECTION_STRING` | `postgres://user:pass@host`, `mongodb://...` |
+
 ## Actions
 
 | Action | Description |
index ac79f114a85226c6a318e9af3fb9f8faab2b76d2..8d35ef2bc6be1a5651972fd08825d535f907be19 100644 (file)
@@ -1,6 +1,6 @@
 ---
 title: Secrets Detection Config
-description: Configure secrets detection settings
+description: Configure detection of private keys, API keys, tokens, and environment credentials
 ---
 
 # Secrets Detection Configuration
@@ -87,6 +87,16 @@ secrets_detection:
     - BEARER_TOKEN   # Bearer ... (40+ char tokens)
 ```
 
+### Environment Variables (opt-in)
+
+```yaml
+secrets_detection:
+  entities:
+    - ENV_PASSWORD       # DB_PASSWORD=..., ADMIN_PWD=... (8+ char values)
+    - ENV_SECRET         # APP_SECRET=..., JWT_SECRET=... (8+ char values)
+    - CONNECTION_STRING  # postgres://user:pass@host, mongodb://user:pass@host
+```
+
 ## Performance
 
 For large payloads, limit scanning:
index 89b285a6edef0b8a13dade1082351894c04d3174..28878bc993608c5154d4ba56765ad6451ca3e032 100644 (file)
@@ -54,7 +54,7 @@ Two privacy modes:
 ## Features
 
 - **PII Detection** — Names, emails, phone numbers, credit cards, IBANs, and more
-- **Secrets Detection** — API keys, tokens, private keys caught before they reach the LLM
+- **Secrets Detection** — API keys, tokens, private keys, env credentials caught before they reach the LLM
 - **Streaming Support** — Real-time unmasking as tokens arrive
 - **24 Languages** — Works in English, German, French, and 21 more
 - **OpenAI-Compatible** — Change one URL, keep your code
index 764f27c0d0ffc047ee540b0e3249c88e5f80757f..73af18bae93b53b33ed2a74505d0d7d8a099b73b 100644 (file)
@@ -101,6 +101,9 @@ const SecretEntityTypes = [
   "API_KEY_GITHUB",
   "JWT_TOKEN",
   "BEARER_TOKEN",
+  "ENV_PASSWORD",
+  "ENV_SECRET",
+  "CONNECTION_STRING",
 ] as const;
 
 const SecretsDetectionSchema = z.object({
index 8347123238d04af060b49122fde2adc2d49a167f..35c6409b0f3d8ce7b0ce25cf46dd3669dd6b9bb4 100644 (file)
@@ -332,6 +332,281 @@ describe("detectSecrets - Bearer Tokens", () => {
   });
 });
 
+describe("detectSecrets - ENV_PASSWORD", () => {
+  const passwordConfig: SecretsDetectionConfig = {
+    ...defaultConfig,
+    entities: ["ENV_PASSWORD"],
+  };
+
+  test("detects DB_PASSWORD with value", () => {
+    const text = "DB_PASSWORD=mysecretpassword123";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches).toHaveLength(1);
+    expect(result.matches[0].type).toBe("ENV_PASSWORD");
+    expect(result.matches[0].count).toBe(1);
+  });
+
+  test("detects PASSWORD with quoted value", () => {
+    const text = `ADMIN_PASSWORD="super_secret_pass"`;
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_PASSWORD");
+  });
+
+  test("detects PASSWORD with single-quoted value", () => {
+    const text = "MYSQL_ROOT_PASSWORD='p@ssw0rd!123'";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_PASSWORD");
+  });
+
+  test("detects _PWD suffix variation", () => {
+    const text = "DB_PWD=mypassword123";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_PASSWORD");
+  });
+
+  test("detects ADMIN_PWD variation", () => {
+    const text = "ADMIN_PWD=secretadminpwd";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_PASSWORD");
+  });
+
+  test("detects PASSWORD with colon assignment (YAML style)", () => {
+    const text = "database_password: productionpass123";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_PASSWORD");
+  });
+
+  test("detects multiple password patterns", () => {
+    const text = `DB_PASSWORD=secret123456
+REDIS_PASSWORD='another_secret'
+ADMIN_PWD=adminpass123`;
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].count).toBe(3);
+  });
+
+  test("avoids false positive - password value too short", () => {
+    const text = "DB_PASSWORD=short";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("avoids false positive - empty password", () => {
+    const text = `DB_PASSWORD=""`;
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("avoids false positive - placeholder value too short", () => {
+    const text = "DB_PASSWORD=change";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("redaction positions are correct", () => {
+    const text = "config: DB_PASSWORD=mysecretpassword123 here";
+    const result = detectSecrets(text, passwordConfig);
+    expect(result.redactions).toBeDefined();
+    expect(result.redactions?.length).toBe(1);
+    const redacted = text.slice(result.redactions![0].start, result.redactions![0].end);
+    expect(redacted).toBe("DB_PASSWORD=mysecretpassword123");
+  });
+});
+
+describe("detectSecrets - ENV_SECRET", () => {
+  const secretConfig: SecretsDetectionConfig = {
+    ...defaultConfig,
+    entities: ["ENV_SECRET"],
+  };
+
+  test("detects APP_SECRET with value", () => {
+    const text = "APP_SECRET=abc123xyz789def456";
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches).toHaveLength(1);
+    expect(result.matches[0].type).toBe("ENV_SECRET");
+  });
+
+  test("detects JWT_SECRET with quoted value", () => {
+    const text = `JWT_SECRET="my-super-secret-jwt-key"`;
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_SECRET");
+  });
+
+  test("detects SESSION_SECRET", () => {
+    const text = "SESSION_SECRET='longsessionsecretvalue'";
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_SECRET");
+  });
+
+  test("detects RAILS_SECRET_KEY_BASE style", () => {
+    const text = "RAILS_SECRET=abcdef123456789xyz";
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_SECRET");
+  });
+
+  test("detects SECRET with colon assignment (YAML style)", () => {
+    const text = "app_secret: production_secret_key_here";
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("ENV_SECRET");
+  });
+
+  test("detects multiple secret patterns", () => {
+    const text = `APP_SECRET=secret123456
+JWT_SECRET="another_jwt_secret"
+SESSION_SECRET=session_key_here`;
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].count).toBe(3);
+  });
+
+  test("avoids false positive - secret value too short", () => {
+    const text = "APP_SECRET=short";
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("avoids false positive - empty secret", () => {
+    const text = `JWT_SECRET=""`;
+    const result = detectSecrets(text, secretConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("redaction positions are correct", () => {
+    const text = "export APP_SECRET=mysupersecretvalue123 # comment";
+    const result = detectSecrets(text, secretConfig);
+    expect(result.redactions).toBeDefined();
+    expect(result.redactions?.length).toBe(1);
+    const redacted = text.slice(result.redactions![0].start, result.redactions![0].end);
+    expect(redacted).toBe("APP_SECRET=mysupersecretvalue123");
+  });
+});
+
+describe("detectSecrets - CONNECTION_STRING", () => {
+  const connConfig: SecretsDetectionConfig = {
+    ...defaultConfig,
+    entities: ["CONNECTION_STRING"],
+  };
+
+  test("detects postgres connection string", () => {
+    const text = "postgres://user:password123@localhost:5432/mydb";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches).toHaveLength(1);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects postgresql connection string", () => {
+    const text = "postgresql://admin:secret@db.example.com:5432/production";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects mysql connection string", () => {
+    const text = "mysql://root:p@ssw0rd@db.host.com:3306/appdb";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects mariadb connection string", () => {
+    const text = "mariadb://dbuser:dbpass123@mariadb.local/database";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects mongodb connection string", () => {
+    const text = "mongodb://admin:mongopass@cluster.mongodb.net:27017/mydb";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects mongodb+srv connection string", () => {
+    const text = "mongodb+srv://user:atlaspass@cluster.mongodb.net/database";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects redis connection string", () => {
+    const text = "redis://default:redispassword@redis.example.com:6379";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects amqp connection string", () => {
+    const text = "amqp://guest:guestpass@rabbitmq.local:5672/vhost";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects amqps (secure) connection string", () => {
+    const text = "amqps://user:securepass@mq.example.com:5671/";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects connection string with any variable name", () => {
+    const text = "MY_CUSTOM_DB_URL=postgres://user:secret@host/db";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects quoted connection string", () => {
+    const text = `DATABASE_URL="postgres://user:pass123@localhost/db"`;
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].type).toBe("CONNECTION_STRING");
+  });
+
+  test("detects multiple connection strings", () => {
+    const text = `PRIMARY_DB=postgres://user:pass@host1/db1
+REPLICA_DB=postgres://user:pass@host2/db2
+CACHE=redis://default:pass@redis:6379`;
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(true);
+    expect(result.matches[0].count).toBe(3);
+  });
+
+  test("avoids false positive - URL without password", () => {
+    const text = "postgres://localhost:5432/mydb";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("avoids false positive - http/https URLs", () => {
+    const text = "https://user:pass@example.com/api";
+    const result = detectSecrets(text, connConfig);
+    expect(result.detected).toBe(false);
+  });
+
+  test("redaction covers full connection string", () => {
+    const text = "export DB=postgres://admin:secret123@db.example.com:5432/prod";
+    const result = detectSecrets(text, connConfig);
+    expect(result.redactions).toBeDefined();
+    expect(result.redactions?.length).toBe(1);
+    const redacted = text.slice(result.redactions![0].start, result.redactions![0].end);
+    expect(redacted).toBe("postgres://admin:secret123@db.example.com:5432/prod");
+  });
+});
+
 describe("detectSecrets - Mixed secret types", () => {
   const allConfig: SecretsDetectionConfig = {
     ...defaultConfig,
@@ -343,6 +618,9 @@ describe("detectSecrets - Mixed secret types", () => {
       "API_KEY_GITHUB",
       "JWT_TOKEN",
       "BEARER_TOKEN",
+      "ENV_PASSWORD",
+      "ENV_SECRET",
+      "CONNECTION_STRING",
     ],
   };
 
index 66c962bcd63b7168046b1f0ec8910df4969b02e0..241a84665df0bd114d8cf43bfb564f6a3b0a3771 100644 (file)
@@ -34,6 +34,7 @@ export function extractTextFromRequest(body: ChatCompletionRequest): string {
  * - Private keys: OpenSSH, PEM (RSA, generic, encrypted)
  * - API keys: OpenAI, AWS, GitHub
  * - Tokens: JWT, Bearer
+ * - Environment variables: Passwords, secrets, connection strings
  *
  * Respects max_scan_chars limit for performance.
  */
diff --git a/src/secrets/patterns/env-vars.ts b/src/secrets/patterns/env-vars.ts
new file mode 100644 (file)
index 0000000..3b5c602
--- /dev/null
@@ -0,0 +1,48 @@
+import type { PatternDetector, SecretsMatch, SecretsRedaction } from "./types";
+import { detectPattern } from "./utils";
+
+/**
+ * Environment variables detector
+ *
+ * Detects:
+ * - ENV_PASSWORD: Password variables (_PASSWORD, _PWD suffix with 8+ char values)
+ * - ENV_SECRET: Secret variables (_SECRET suffix with 8+ char values)
+ * - CONNECTION_STRING: Database URLs with embedded passwords (user:pass@host)
+ */
+export const envVarsDetector: PatternDetector = {
+  patterns: ["ENV_PASSWORD", "ENV_SECRET", "CONNECTION_STRING"],
+
+  detect(text: string, enabledTypes: Set<string>) {
+    const matches: SecretsMatch[] = [];
+    const redactions: SecretsRedaction[] = [];
+
+    // Environment variable password patterns: _PASSWORD or _PWD suffix with value (8+ chars)
+    // Case-insensitive for variable name, supports = and : assignment, quoted/unquoted values
+    if (enabledTypes.has("ENV_PASSWORD")) {
+      const passwordPattern =
+        /[A-Za-z_][A-Za-z0-9_]*(?:PASSWORD|_PWD)\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/gi;
+      detectPattern(text, passwordPattern, "ENV_PASSWORD", matches, redactions);
+    }
+
+    // Environment variable secret patterns: _SECRET suffix with value (8+ chars)
+    // Case-insensitive for variable name, supports = and : assignment, quoted/unquoted values
+    if (enabledTypes.has("ENV_SECRET")) {
+      const secretPattern = /[A-Za-z_][A-Za-z0-9_]*_SECRET\s*[=:]\s*['"]?[^\s'"]{8,}['"]?/gi;
+      detectPattern(text, secretPattern, "ENV_SECRET", matches, redactions);
+    }
+
+    // Database connection strings with embedded passwords (user:password@host format)
+    // Supports: postgres, postgresql, mysql, mariadb, mongodb, mongodb+srv, redis, amqp, amqps
+    if (enabledTypes.has("CONNECTION_STRING")) {
+      const connectionPattern =
+        /(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqps?):\/\/[^:]+:[^@\s]+@[^\s'"]+/gi;
+      detectPattern(text, connectionPattern, "CONNECTION_STRING", matches, redactions);
+    }
+
+    return {
+      detected: matches.length > 0,
+      matches,
+      redactions: redactions.length > 0 ? redactions : undefined,
+    };
+  },
+};
index 957f301ca9e29d1e77b389ec0e33369a4c28202e..5c3895098ea72b1b1325041198b3650dde6b8556 100644 (file)
@@ -1,4 +1,5 @@
 import { apiKeysDetector } from "./api-keys";
+import { envVarsDetector } from "./env-vars";
 import { privateKeysDetector } from "./private-keys";
 import { tokensDetector } from "./tokens";
 import type { PatternDetector } from "./types";
@@ -13,6 +14,7 @@ export const patternDetectors: PatternDetector[] = [
   privateKeysDetector,
   apiKeysDetector,
   tokensDetector,
+  envVarsDetector,
 ];
 
 // Re-export types and utilities for convenience
index e8ebc021ed6d8f0a3df805fcb5888072d3c44c74..95b6f27482b20f306cae9b4116fd7cc9ba3e5d0d 100644 (file)
@@ -8,7 +8,10 @@ export type SecretEntityType =
   | "API_KEY_AWS"
   | "API_KEY_GITHUB"
   | "JWT_TOKEN"
-  | "BEARER_TOKEN";
+  | "BEARER_TOKEN"
+  | "ENV_PASSWORD"
+  | "ENV_SECRET"
+  | "CONNECTION_STRING";
 
 export interface SecretsMatch {
   type: SecretEntityType;
@@ -38,5 +41,5 @@ export interface PatternDetector {
   patterns: SecretEntityType[];
 
   /** Run detection for enabled entity types */
-  detect(text: string, enabledTypes: Set<SecretEntityType>): SecretsDetectionResult;
+  detect(text: string, enabledTypes: Set<string>): SecretsDetectionResult;
 }
index aa1d1f23f7d1b70f576e88213e967eecb9b89e87..6d124d286d502af5cbfb131c30cf6d93b35325ea 100644 (file)
@@ -1,4 +1,4 @@
-import type { SecretEntityType, SecretsMatch, SecretsRedaction } from "./types";
+import type { SecretsMatch, SecretsRedaction } from "./types";
 
 /**
  * Helper to detect secrets matching a pattern and collect matches/redactions
@@ -6,7 +6,7 @@ import type { SecretEntityType, SecretsMatch, SecretsRedaction } from "./types";
 export function detectPattern(
   text: string,
   pattern: RegExp,
-  entityType: SecretEntityType,
+  entityType: string,
   matches: SecretsMatch[],
   redactions: SecretsRedaction[],
   existingPositions?: Set<number>,
@@ -22,12 +22,12 @@ export function detectPattern(
       redactions.push({
         start: match.index,
         end: match.index + match[0].length,
-        type: entityType,
+        type: entityType as SecretsRedaction["type"],
       });
     }
   }
   if (count > 0) {
-    matches.push({ type: entityType, count });
+    matches.push({ type: entityType as SecretsMatch["type"], count });
   }
   return count;
 }
git clone https://git.99rst.org/PROJECT