diff --git a/dist/scanner/detectors.js b/dist/scanner/detectors.js index fd47bdd..d080095 100644 --- a/dist/scanner/detectors.js +++ b/dist/scanner/detectors.js @@ -151,6 +151,122 @@ export const BUILTIN_RULES = [ redactionStrategy: 'token-replace', enabled: true, }, + { + id: 'slack-token', + title: 'Slack Token', + description: 'Detects Slack bot/user/legacy tokens (xoxb/xoxp/xoxa/xoxr) and app-level tokens (xapp)', + severity: 'high', + category: 'secret', + // Bot/user tokens carry 2-3 numeric ID segments then the secret; legacy + // xoxa/xoxr and app-level xapp use their own shapes. Requiring the ID + // segments avoids matching placeholders like "xoxb-your-token". + pattern: /\bxox[bp]-(?:[0-9]{8,}-){2,3}[A-Za-z0-9-]{16,}\b|\bxox[ar]-[0-9]-[0-9a-zA-Z]{18,}\b|\bxapp-[0-9]-[A-Za-z0-9]+-[0-9]+-[A-Za-z0-9]{16,}\b/g, + // Synthetic (all-zero / EXAMPLE) fixtures assembled at runtime so the full + // token never appears as one literal in source - keeps upstream secret + // scanners from false-positiving on these examples. + examples: [ + ['xoxb', '000000000000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ['xapp', '1', 'EXAMPLE00000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'gitlab-personal-access-token', + title: 'GitLab Personal Access Token', + description: 'Detects GitLab personal access tokens (default glpat- prefix)', + severity: 'high', + category: 'secret', + pattern: /\bglpat-[A-Za-z0-9_-]{20,64}\b/g, + examples: ['glpat-EXAMPLExxxxEXAMPLExxxx'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'gitlab-ci-job-token', + title: 'GitLab CI Job Token', + description: 'Detects GitLab CI/CD job tokens (glcbt- prefix, GA since GitLab 16.9)', + severity: 'high', + category: 'secret', + pattern: /\bglcbt-[0-9A-Za-z]{1,5}_[A-Za-z0-9_-]{20}\b/g, + examples: ['glcbt-EXAMP_EXAMPLExxxxEXAMPLExx'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'npm-access-token', + title: 'npm Access Token', + description: 'Detects npm access tokens (npm_ prefix, 36-char base62 body)', + severity: 'high', + category: 'secret', + pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, + examples: ['npm_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEx'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'twilio-api-key-sid', + title: 'Twilio API Key SID', + description: 'Detects Twilio API Key SIDs (SK + 32 hex), which pair with an API key secret', + severity: 'high', + category: 'secret', + pattern: /\bSK[0-9a-fA-F]{32}\b/g, + examples: ['SK00000000000000000000000000000000'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'twilio-account-sid', + title: 'Twilio Account SID', + description: 'Detects Twilio Account SIDs (AC + 32 hex) - a public identifier, sensitive when paired with an auth token', + severity: 'low', + category: 'internal-data', + pattern: /\bAC[0-9a-fA-F]{32}\b/g, + examples: ['AC00000000000000000000000000000000'], + redactionStrategy: 'partial-mask', + enabled: true, + }, + { + id: 'sendgrid-api-key', + title: 'SendGrid API Key', + description: 'Detects SendGrid API keys (SG.<22>.<43> format)', + severity: 'critical', + category: 'secret', + pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, + // Assembled at runtime (see slack-token note) to avoid secret-scanner + // false positives on this synthetic fixture. + examples: [['SG', 'EXAMPLExxxxEXAMPLExxxx', 'EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEx'].join('.')], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'gcp-service-account-key', + title: 'GCP Service Account Key JSON', + description: 'Detects a Google Cloud service-account key JSON envelope (type service_account co-occurring with a private_key field), even when the PEM is elided', + severity: 'critical', + category: 'secret', + // Requires BOTH the exact "type": "service_account" and a "private_key" + // field within a bounded window (either order) so prose describing service + // accounts does not match. Bounded lazy span keeps it ReDoS-safe. + pattern: /"type"\s*:\s*"service_account"[\s\S]{0,2000}?"private_key"\s*:\s*"|"private_key"\s*:\s*"[\s\S]{0,2000}?"type"\s*:\s*"service_account"/g, + examples: ['{"type": "service_account", "project_id": "demo", "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----\\n", "client_email": "svc@demo.iam.gserviceaccount.com"}'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'database-connection-string-credentials', + title: 'Database Connection String with Credentials', + description: 'Detects database URIs with an inline password (scheme://user:password@host) for postgres/mysql/mongodb/redis/amqp', + severity: 'critical', + category: 'secret', + // Requires the ":password@" userinfo so credential-less URIs + // (scheme://host, scheme://user@host) never match. @ and / are illegal + // unencoded in RFC 3986 userinfo, so the class boundaries are reliable. + pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:/@]+:[^\s/@]+@[^\s/@]+/g, + examples: ['postgres://user:EXAMPLEpassword@db.example.com:5432/mydb'], + redactionStrategy: 'partial-mask', + enabled: true, + }, { id: 'generic-code-secret-assignment', title: 'Generic Code Secret Assignment', diff --git a/src/scanner/detectors.ts b/src/scanner/detectors.ts index 0d1065e..0b76100 100644 --- a/src/scanner/detectors.ts +++ b/src/scanner/detectors.ts @@ -166,6 +166,122 @@ export const BUILTIN_RULES: DetectionRule[] = [ redactionStrategy: 'token-replace', enabled: true, }, + { + id: 'slack-token', + title: 'Slack Token', + description: 'Detects Slack bot/user/legacy tokens (xoxb/xoxp/xoxa/xoxr) and app-level tokens (xapp)', + severity: 'high', + category: 'secret', + // Bot/user tokens carry 2-3 numeric ID segments then the secret; legacy + // xoxa/xoxr and app-level xapp use their own shapes. Requiring the ID + // segments avoids matching placeholders like "xoxb-your-token". + pattern: /\bxox[bp]-(?:[0-9]{8,}-){2,3}[A-Za-z0-9-]{16,}\b|\bxox[ar]-[0-9]-[0-9a-zA-Z]{18,}\b|\bxapp-[0-9]-[A-Za-z0-9]+-[0-9]+-[A-Za-z0-9]{16,}\b/g, + // Synthetic (all-zero / EXAMPLE) fixtures assembled at runtime so the full + // token never appears as one literal in source - keeps upstream secret + // scanners from false-positiving on these examples. + examples: [ + ['xoxb', '000000000000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ['xapp', '1', 'EXAMPLE00000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'gitlab-personal-access-token', + title: 'GitLab Personal Access Token', + description: 'Detects GitLab personal access tokens (default glpat- prefix)', + severity: 'high', + category: 'secret', + pattern: /\bglpat-[A-Za-z0-9_-]{20,64}\b/g, + examples: ['glpat-EXAMPLExxxxEXAMPLExxxx'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'gitlab-ci-job-token', + title: 'GitLab CI Job Token', + description: 'Detects GitLab CI/CD job tokens (glcbt- prefix, GA since GitLab 16.9)', + severity: 'high', + category: 'secret', + pattern: /\bglcbt-[0-9A-Za-z]{1,5}_[A-Za-z0-9_-]{20}\b/g, + examples: ['glcbt-EXAMP_EXAMPLExxxxEXAMPLExx'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'npm-access-token', + title: 'npm Access Token', + description: 'Detects npm access tokens (npm_ prefix, 36-char base62 body)', + severity: 'high', + category: 'secret', + pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, + examples: ['npm_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEx'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'twilio-api-key-sid', + title: 'Twilio API Key SID', + description: 'Detects Twilio API Key SIDs (SK + 32 hex), which pair with an API key secret', + severity: 'high', + category: 'secret', + pattern: /\bSK[0-9a-fA-F]{32}\b/g, + examples: ['SK00000000000000000000000000000000'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'twilio-account-sid', + title: 'Twilio Account SID', + description: 'Detects Twilio Account SIDs (AC + 32 hex) - a public identifier, sensitive when paired with an auth token', + severity: 'low', + category: 'internal-data', + pattern: /\bAC[0-9a-fA-F]{32}\b/g, + examples: ['AC00000000000000000000000000000000'], + redactionStrategy: 'partial-mask', + enabled: true, + }, + { + id: 'sendgrid-api-key', + title: 'SendGrid API Key', + description: 'Detects SendGrid API keys (SG.<22>.<43> format)', + severity: 'critical', + category: 'secret', + pattern: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g, + // Assembled at runtime (see slack-token note) to avoid secret-scanner + // false positives on this synthetic fixture. + examples: [['SG', 'EXAMPLExxxxEXAMPLExxxx', 'EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEx'].join('.')], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'gcp-service-account-key', + title: 'GCP Service Account Key JSON', + description: 'Detects a Google Cloud service-account key JSON envelope (type service_account co-occurring with a private_key field), even when the PEM is elided', + severity: 'critical', + category: 'secret', + // Requires BOTH the exact "type": "service_account" and a "private_key" + // field within a bounded window (either order) so prose describing service + // accounts does not match. Bounded lazy span keeps it ReDoS-safe. + pattern: /"type"\s*:\s*"service_account"[\s\S]{0,2000}?"private_key"\s*:\s*"|"private_key"\s*:\s*"[\s\S]{0,2000}?"type"\s*:\s*"service_account"/g, + examples: ['{"type": "service_account", "project_id": "demo", "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----\\n", "client_email": "svc@demo.iam.gserviceaccount.com"}'], + redactionStrategy: 'token-replace', + enabled: true, + }, + { + id: 'database-connection-string-credentials', + title: 'Database Connection String with Credentials', + description: 'Detects database URIs with an inline password (scheme://user:password@host) for postgres/mysql/mongodb/redis/amqp', + severity: 'critical', + category: 'secret', + // Requires the ":password@" userinfo so credential-less URIs + // (scheme://host, scheme://user@host) never match. @ and / are illegal + // unencoded in RFC 3986 userinfo, so the class boundaries are reliable. + pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?):\/\/[^\s:/@]+:[^\s/@]+@[^\s/@]+/g, + examples: ['postgres://user:EXAMPLEpassword@db.example.com:5432/mydb'], + redactionStrategy: 'partial-mask', + enabled: true, + }, { id: 'generic-code-secret-assignment', title: 'Generic Code Secret Assignment', diff --git a/tests/rules.test.ts b/tests/rules.test.ts index 43edef5..767eb41 100644 --- a/tests/rules.test.ts +++ b/tests/rules.test.ts @@ -194,3 +194,213 @@ describe('email-address rule TLD class (character-class typo fix)', () => { ); }); + +function ruleScanner(id: string): PrivacyScanner { + const rule = BUILTIN_RULES.find((r) => r.id === id) as DetectionRule; + expect(rule).toBeDefined(); + return new PrivacyScanner([rule]); +} + +describe('slack-token rule', () => { + const scanner = ruleScanner('slack-token'); + + // Synthetic fixtures assembled at runtime (see detectors.ts note) so the full + // token never appears as one literal in source. + test.each([ + ['xoxb', '000000000000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ['xoxp', '000000000000', '000000000000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ['xapp', '1', 'EXAMPLE00000', '000000000000', 'EXAMPLExxxxEXAMPLExxxx'].join('-'), + ])('matches a realistic Slack token: %s', (token: string) => { + const result = scanner.scan(`token=${token}`); + expect(result.findings.some((f) => f.ruleId === 'slack-token')).toBe(true); + }); + + test.each([ + 'how do slack tokens work?', + 'reset your xoxb- token', + 'xoxb-xxxx', + 'xoxb-your-slack-token-here', + ])('does not match placeholders or prose: %s', (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + }); +}); + +describe('gitlab-personal-access-token rule', () => { + const scanner = ruleScanner('gitlab-personal-access-token'); + + test('matches a realistic glpat- token', () => { + const result = scanner.scan('export GITLAB_TOKEN=glpat-EXAMPLExxxxEXAMPLExxxx'); + expect(result.findings.some((f) => f.ruleId === 'gitlab-personal-access-token')).toBe(true); + }); + + test.each(['glpat-xxxx', 'set GITLAB_TOKEN to your glpat- value'])( + 'does not match placeholders: %s', + (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + } + ); +}); + +describe('gitlab-ci-job-token rule', () => { + const scanner = ruleScanner('gitlab-ci-job-token'); + + test('matches a realistic glcbt- token', () => { + const result = scanner.scan('CI_JOB_TOKEN=glcbt-EXAMP_EXAMPLExxxxEXAMPLExx'); + expect(result.findings.some((f) => f.ruleId === 'gitlab-ci-job-token')).toBe(true); + }); + + test.each(['glcbt-xxxx', 'the CI_JOB_TOKEN variable name'])( + 'does not match placeholders or prose: %s', + (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + } + ); +}); + +describe('npm-access-token rule', () => { + const scanner = ruleScanner('npm-access-token'); + + test('matches a realistic npm_ token', () => { + const result = scanner.scan('//registry.npmjs.org/:_authToken=npm_EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEx'); + expect(result.findings.some((f) => f.ruleId === 'npm-access-token')).toBe(true); + }); + + test.each(['npm_token', 'NPM_TOKEN=changeme', 'run npm install'])( + 'does not match placeholders or prose: %s', + (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + } + ); +}); + +describe('twilio-api-key-sid rule', () => { + const scanner = ruleScanner('twilio-api-key-sid'); + + test('matches a realistic Twilio API Key SID', () => { + const result = scanner.scan('TWILIO_API_KEY_SID=SK00000000000000000000000000000000'); + expect(result.findings.some((f) => f.ruleId === 'twilio-api-key-sid')).toBe(true); + }); + + test.each(['SKxxxx', 'your Twilio SK... API key'])( + 'does not match placeholders or prose: %s', + (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + } + ); +}); + +describe('twilio-account-sid rule', () => { + const scanner = ruleScanner('twilio-account-sid'); + + test('matches a realistic Twilio Account SID', () => { + const result = scanner.scan('account_sid = AC00000000000000000000000000000000'); + expect(result.findings.some((f) => f.ruleId === 'twilio-account-sid')).toBe(true); + }); + + test('redacts as a low-severity identifier with partial-mask', () => { + const result = scanner.scan('AC00000000000000000000000000000000'); + const finding = result.findings.find((f) => f.ruleId === 'twilio-account-sid') as NonNullable< + ReturnType + >; + expect(finding.severity).toBe('low'); + expect(finding.redactedValue).toContain('***'); + }); + + test.each(['ACxxxx', 'the account SID starts with AC'])( + 'does not match placeholders or prose: %s', + (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + } + ); +}); + +describe('sendgrid-api-key rule', () => { + const scanner = ruleScanner('sendgrid-api-key'); + + test('matches a realistic SendGrid API key', () => { + const key = ['SG', 'EXAMPLExxxxEXAMPLExxxx', 'EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEx'].join('.'); + const result = scanner.scan(`SENDGRID_API_KEY=${key}`); + expect(result.findings.some((f) => f.ruleId === 'sendgrid-api-key')).toBe(true); + }); + + test.each(['SG.xxx.yyy', 'your SendGrid key looks like SG..'])( + 'does not match placeholders or prose: %s', + (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + } + ); +}); + +describe('gcp-service-account-key rule', () => { + const scanner = ruleScanner('gcp-service-account-key'); + + test('matches a service-account JSON envelope', () => { + const json = + '{"type": "service_account", "project_id": "demo", "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIB\\n-----END PRIVATE KEY-----\\n"}'; + const result = scanner.scan(json); + expect(result.findings.some((f) => f.ruleId === 'gcp-service-account-key')).toBe(true); + }); + + test('matches even when the private key value is elided and fields are reordered', () => { + const json = '{ "private_key": "REDACTED", "client_email": "x@y", "type":"service_account" }'; + const result = scanner.scan(json); + expect(result.findings.some((f) => f.ruleId === 'gcp-service-account-key')).toBe(true); + }); + + test.each([ + 'A service_account has a private_key field in its JSON.', + 'Set the type to service account and store the key securely.', + '{"type": "user_account", "name": "demo"}', + ])('does not match prose about service accounts: %s', (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + }); +}); + +describe('database-connection-string-credentials rule', () => { + const scanner = ruleScanner('database-connection-string-credentials'); + + test.each([ + 'postgres://user:EXAMPLEpassword@db.example.com:5432/mydb', + 'postgresql://user:EXAMPLEpassword@db.example.com:5432/mydb', + 'mysql://user:EXAMPLEpassword@10.0.0.5:3306/appdb', + 'mongodb://user:EXAMPLEpassword@mongo1.example.com:27017/records', + 'mongodb+srv://user:EXAMPLEpassword@cluster0.abcd.mongodb.net/records', + 'redis://default:EXAMPLEpassword@redis.internal:6379/0', + 'rediss://default:EXAMPLEpassword@redis.internal:6380/0', + 'amqp://guest:EXAMPLEpassword@rabbit.example.com:5672/vhost', + 'amqps://guest:EXAMPLEpassword@rabbit.example.com:5671/vhost', + ])('matches a URI with inline credentials: %s', (uri: string) => { + const result = scanner.scan(`DATABASE_URL=${uri}`); + expect(result.findings.some((f) => f.ruleId === 'database-connection-string-credentials')).toBe(true); + }); + + test('redacts with partial info preserved (partial-mask)', () => { + const result = scanner.scan('postgres://user:EXAMPLEpassword@db.example.com:5432/mydb'); + const finding = result.findings.find( + (f) => f.ruleId === 'database-connection-string-credentials' + ) as NonNullable>; + expect(finding.redactedValue).toContain('***'); + expect(finding.redactedValue).not.toContain('EXAMPLEpassword'); + }); + + test.each([ + 'postgres://localhost:5432/mydb', + 'postgres://user@host', + 'redis://localhost:6379/0', + 'amqp://rabbit.example.com/vhost', + 'mongodb://mongo1:27017/db', + 'See https://www.postgresql.org/docs/current/libpq-connect.html', + ])('does not match credential-less URIs or docs URLs: %s', (text: string) => { + const result = scanner.scan(text); + expect(result.findings).toHaveLength(0); + }); +}); +