From: Max Wolf Date: Mon, 12 Jan 2026 15:02:29 +0000 (+0100) Subject: Add environment variable credential detection (#19) X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=a1d0db797a7feb1c70e2b181f0a26762ab196fc5;p=sgasser-llm-shield.git Add environment variable credential detection (#19) * 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 --- diff --git a/README.md b/README.md index df5ef04..fd52fe4 100644 --- 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 diff --git a/config.example.yaml b/config.example.yaml index dc2896a..2adad62 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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. diff --git a/docs/concepts/secrets-detection.mdx b/docs/concepts/secrets-detection.mdx index 0db671d..0966b01 100644 --- a/docs/concepts/secrets-detection.mdx +++ b/docs/concepts/secrets-detection.mdx @@ -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 | diff --git a/docs/configuration/secrets-detection.mdx b/docs/configuration/secrets-detection.mdx index ac79f11..8d35ef2 100644 --- a/docs/configuration/secrets-detection.mdx +++ b/docs/configuration/secrets-detection.mdx @@ -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: diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 89b285a..28878bc 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -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 diff --git a/src/config.ts b/src/config.ts index 764f27c..73af18b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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({ diff --git a/src/secrets/detect.test.ts b/src/secrets/detect.test.ts index 8347123..35c6409 100644 --- a/src/secrets/detect.test.ts +++ b/src/secrets/detect.test.ts @@ -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", ], }; diff --git a/src/secrets/detect.ts b/src/secrets/detect.ts index 66c962b..241a846 100644 --- a/src/secrets/detect.ts +++ b/src/secrets/detect.ts @@ -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 index 0000000..3b5c602 --- /dev/null +++ b/src/secrets/patterns/env-vars.ts @@ -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) { + 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, + }; + }, +}; diff --git a/src/secrets/patterns/index.ts b/src/secrets/patterns/index.ts index 957f301..5c38950 100644 --- a/src/secrets/patterns/index.ts +++ b/src/secrets/patterns/index.ts @@ -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 diff --git a/src/secrets/patterns/types.ts b/src/secrets/patterns/types.ts index e8ebc02..95b6f27 100644 --- a/src/secrets/patterns/types.ts +++ b/src/secrets/patterns/types.ts @@ -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): SecretsDetectionResult; + detect(text: string, enabledTypes: Set): SecretsDetectionResult; } diff --git a/src/secrets/patterns/utils.ts b/src/secrets/patterns/utils.ts index aa1d1f2..6d124d2 100644 --- a/src/secrets/patterns/utils.ts +++ b/src/secrets/patterns/utils.ts @@ -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, @@ -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; }