Skip to content

Commit 277c745

Browse files
authored
fix(security): ignore charset/alphabet literals in secret detection (#355)
* fix(security): ignore charset/alphabet literals in secret detection * chore: dotenv-diff ignore * chore: dotenv-diff ignore
1 parent c0b1aa8 commit 277c745

File tree

3 files changed

+120
-1
lines changed

3 files changed

+120
-1
lines changed

.changeset/empty-squids-try.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'dotenv-diff': patch
3+
---
4+
5+
fix false positive secret warnings on charset/alphabet strings

packages/cli/src/core/security/secretDetectors.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,48 @@ function ignoreUrlsMatch(url: string, ignoreUrls?: string[]): boolean {
119119
);
120120
}
121121

122+
/**
123+
* Checks if a string looks like a character set / alphabet used for ID generation
124+
* or similar utilities (e.g. customAlphabet, nanoid, uuid generation).
125+
*
126+
* A charset string is characterised by:
127+
* - Containing long runs of consecutive ASCII characters (a-z, A-Z, 0-9)
128+
* - Low uniqueness ratio: many repeated character classes, few truly unique chars
129+
* relative to string length
130+
*
131+
* Examples that should pass:
132+
* 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' // dotenv-diff-ignore
133+
* '0123456789abcdef'
134+
* 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' (base32 alphabet)
135+
*/
136+
function looksLikeCharset(s: string): boolean {
137+
// Must be reasonably long to bother checking
138+
if (s.length < 16) return false;
139+
140+
// Unique character ratio: a charset reuses few characters relative to its length,
141+
// but more importantly its unique chars are a large fraction of the total charset
142+
// space (26 lc + 26 uc + 10 digits = 62). If >50% of the possible alphanumeric
143+
// chars appear, it's almost certainly a charset definition.
144+
const unique = new Set(s.replace(/[^A-Za-z0-9]/g, '')).size;
145+
if (unique >= 61) return true; // covers a-z (26), A-Z (26), 0-9 (10), or combos
146+
147+
// Fallback: detect sequential runs of 6+ consecutive ASCII codes
148+
// e.g. 'abcdef', 'ABCDEF', '012345'
149+
const sequentialRunThreshold = 6;
150+
let maxRun = 1;
151+
let currentRun = 1;
152+
for (let i = 1; i < s.length; i++) {
153+
if (s.charCodeAt(i) === s.charCodeAt(i - 1)! + 1) {
154+
currentRun++;
155+
if (currentRun > maxRun) maxRun = currentRun;
156+
} else {
157+
currentRun = 1;
158+
}
159+
}
160+
161+
return maxRun >= sequentialRunThreshold;
162+
}
163+
122164
/**
123165
* Checks if a string looks like a harmless literal.
124166
* @param s - The string to check.
@@ -137,7 +179,8 @@ function looksHarmlessLiteral(s: string): boolean {
137179
) || // env-like keys
138180
/^[MmZzLlHhVvCcSsQqTtAa][0-9eE+.\- ,MmZzLlHhVvCcSsQqTtAa]*$/.test(s) || // SVG path data
139181
/<svg[\s\S]*?>[\s\S]*?<\/svg>/i.test(s) || // SVG markup
140-
HARMLESS_URLS.some((rx) => rx.test(s)) // Allowlisted URLs
182+
HARMLESS_URLS.some((rx) => rx.test(s)) || // Allowlisted URLs
183+
looksLikeCharset(s) // character sets / alphabets used for ID generation
141184
);
142185
}
143186

packages/cli/test/unit/core/security/secretDetectors.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,5 +456,76 @@ const email = "user@example.com";
456456

457457
expect(findings).toHaveLength(0);
458458
});
459+
460+
describe('charset and alphabet detection', () => {
461+
it('should ignore full alphanumeric alphabet (customAlphabet pattern)', () => {
462+
// The exact case from the bug report
463+
const source = `const createBundleId = customAlphabet(
464+
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
465+
8,
466+
)`;
467+
const findings = detectSecretsInSource('test.ts', source);
468+
expect(findings).toHaveLength(0);
469+
});
470+
471+
it('should ignore lowercase-only alphabet', () => {
472+
const source = "const id = nanoid('abcdefghijklmnopqrstuvwxyz', 10);";
473+
const findings = detectSecretsInSource('test.ts', source);
474+
expect(findings).toHaveLength(0);
475+
});
476+
477+
it('should ignore uppercase-only alphabet', () => {
478+
const source =
479+
"const code = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 6);";
480+
const findings = detectSecretsInSource('test.ts', source);
481+
expect(findings).toHaveLength(0);
482+
});
483+
484+
it('should ignore hex charset', () => {
485+
// 16 unique chars, has a sequential run of 10 digits + 6 letters
486+
const source = "const hex = customAlphabet('0123456789abcdef', 32);";
487+
const findings = detectSecretsInSource('test.ts', source);
488+
expect(findings).toHaveLength(0);
489+
});
490+
491+
it('should ignore base32 alphabet', () => {
492+
// RFC 4648 base32: A-Z + 2-7
493+
const source =
494+
"const encoded = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', 16);";
495+
const findings = detectSecretsInSource('test.ts', source);
496+
expect(findings).toHaveLength(0);
497+
});
498+
499+
it('should ignore digits-only charset', () => {
500+
const source = "const pin = customAlphabet('0123456789', 6);";
501+
const findings = detectSecretsInSource('test.ts', source);
502+
expect(findings).toHaveLength(0);
503+
});
504+
505+
it('should still detect a real high-entropy secret that is not a charset', () => {
506+
// Looks like a real token — no sequential runs, no large unique set structure
507+
const source =
508+
'const token = "xK9mQwP2zLsR8tYu5nV7cJ4hFgD6eS1iO0pA3bC";';
509+
const findings = detectSecretsInSource('test.ts', source);
510+
// Should still be flagged as entropy finding
511+
expect(findings.length).toBeGreaterThan(0);
512+
expect(findings.some((f) => f.kind === 'entropy')).toBe(true);
513+
});
514+
515+
it('should still detect AWS key even if it superficially resembles an alphabet', () => {
516+
const source = 'const key = "AKIAIOSFODNN7EXAMPLE";';
517+
const findings = detectSecretsInSource('test.ts', source);
518+
expect(findings).toHaveLength(1);
519+
expect(findings[0].severity).toBe('high');
520+
});
521+
522+
it('should ignore alphabet assigned to a variable without a function call', () => {
523+
// Charset used as a plain constant, not inside a function
524+
const source =
525+
"const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';";
526+
const findings = detectSecretsInSource('test.ts', source);
527+
expect(findings).toHaveLength(0);
528+
});
529+
});
459530
});
460531
});

0 commit comments

Comments
 (0)