@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
212 lines • 8.19 kB
JavaScript
import { ContextFile } from '../domain/ContextFile.js';
import { Violation } from '../domain/Violation.js';
import { Location } from '../domain/Location.js';
import { Severity } from '../domain/Severity.js';
/**
* SecretDetectionRule — flags credentials pasted into CLAUDE.md.
*
* @remarks
* Committing a live API key into a context file is one of the most damaging
* authoring mistakes: CLAUDE.md is routinely shared, versioned, and fed to
* models, so a leaked key propagates widely. This rule detects the common
* provider key shapes (OpenAI, Anthropic, GitHub, AWS, Google, Slack), PEM
* private-key blocks, and a high-entropy heuristic for
* `KEY=`/`TOKEN=`/`SECRET=`/`PASSWORD=` assignments.
*
* Findings are ERROR severity. Messages name the kind of secret and mask the
* value (first four characters + `…`) so the linter never re-echoes the
* credential it is warning about.
*
* Scope: Markdown files only ({@link appliesTo}). Both prose and fenced code
* blocks are scanned — a pasted key is equally dangerous in either.
*/
export class SecretDetectionRule {
id = 'secret-detection';
description = 'Detects likely API keys, tokens, and private keys committed to CLAUDE.md';
/**
* Ordered so more specific prefixes win before generic ones (Anthropic
* `sk-ant-` and OpenAI `sk-proj-` are matched before the classic OpenAI
* `sk-` shape). Hyphens in the specific prefixes already prevent the classic
* pattern from matching them, but the ordering keeps the intent explicit.
*/
static PATTERNS = [
{
kind: 'Anthropic API key',
pattern: /(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9-]{16,}/g,
},
{
kind: 'OpenAI API key',
pattern: /(?<![A-Za-z0-9])sk-proj-[A-Za-z0-9_-]{16,}/g,
},
{
kind: 'OpenAI API key',
pattern: /(?<![A-Za-z0-9])sk-[A-Za-z0-9]{20,}/g,
},
{
kind: 'GitHub token',
pattern: /(?<![A-Za-z0-9])gh[opsu]_[A-Za-z0-9]{36,}/g,
},
{
kind: 'GitHub fine-grained token',
pattern: /(?<![A-Za-z0-9])github_pat_[A-Za-z0-9_]{22,}/g,
},
{
kind: 'AWS access key',
pattern: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/g,
},
{
kind: 'Google API key',
pattern: /(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g,
},
{
kind: 'Slack token',
pattern: /(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/g,
},
{
kind: 'private key',
pattern: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g,
alwaysReport: true,
},
];
/** Substrings that mark a value as an obvious placeholder, not a secret. */
static PLACEHOLDER_TOKENS = [
'example',
'placeholder',
'redacted',
'changeme',
'dummy',
'sample',
'your',
'here',
'todo',
'fake',
'xxxx',
'xxx',
'notreal',
'replaceme',
];
/**
* Env-style assignment keys that indicate a secret value is being set. The
* identifier is required to be upper-snake-case (e.g. `API_TOKEN`,
* `SECRET_KEY`) so ordinary prose like "here is a token: …" is not mistaken
* for an assignment.
*/
static ASSIGNMENT_KEY = /(?<![A-Za-z0-9_])([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*)\s*[:=]\s*['"]?([^\s'"]+)['"]?/g;
static MIN_ENTROPY_LENGTH = 20;
static MIN_DISTINCT_CHARS = 8;
static MIN_ENTROPY_BITS = 3.5;
appliesTo(file) {
return file.isMarkdown();
}
lint(file) {
const violations = [];
file.lines.forEach((line, index) => {
const lineNumber = index + 1;
this.collectPatternMatches(line, lineNumber, violations);
this.collectEntropyMatches(line, lineNumber, violations);
});
return violations;
}
/** Match the known credential shapes on a single line. */
collectPatternMatches(line, lineNumber, out) {
for (const { kind, pattern, alwaysReport, } of SecretDetectionRule.PATTERNS) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(line)) !== null) {
const token = match[0];
if (alwaysReport || !SecretDetectionRule.looksLikePlaceholder(token)) {
out.push(this.violation(kind, token, lineNumber, match.index + 1));
}
}
}
}
/**
* Heuristic pass: a `KEY=`/`TOKEN=`/`SECRET=`/`PASSWORD=` assignment whose
* value is long, high-entropy, and mixed is very likely a real secret even
* when it carries no recognizable provider prefix.
*/
collectEntropyMatches(line, lineNumber, out) {
const pattern = SecretDetectionRule.ASSIGNMENT_KEY;
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(line)) !== null) {
const value = match[2];
if (value === undefined) {
continue;
}
if (SecretDetectionRule.matchesKnownPattern(value)) {
// Already reported by the prefix pass; avoid a duplicate finding.
continue;
}
if (!SecretDetectionRule.isHighEntropySecret(value)) {
continue;
}
const valueColumn = match.index + match[0].indexOf(value) + 1;
out.push(this.violation('high-entropy secret', value, lineNumber, valueColumn));
}
}
violation(kind, secret, line, column) {
return new Violation(this.id, `Possible ${kind} ('${SecretDetectionRule.mask(secret)}') committed to ` +
`a context file at line ${line}. Remove it and rotate the credential.`, Severity.ERROR, new Location(line, column));
}
/** Show only the first four characters, then an ellipsis. */
static mask(secret) {
return `${secret.slice(0, 4)}…`;
}
static looksLikePlaceholder(secret) {
const lower = secret.toLowerCase();
if (SecretDetectionRule.PLACEHOLDER_TOKENS.some(t => lower.includes(t))) {
return true;
}
if (/[<>{}()$]/.test(secret)) {
return true;
}
// Real credentials are high-variety; a handful of distinct characters
// means a repeated filler like `sk-xxxx…` or `AKIAAAAA…`.
return SecretDetectionRule.distinctChars(secret) < 5;
}
static matchesKnownPattern(value) {
return SecretDetectionRule.PATTERNS.some(({ pattern }) => {
pattern.lastIndex = 0;
return pattern.test(value);
});
}
static isHighEntropySecret(value) {
if (value.length < SecretDetectionRule.MIN_ENTROPY_LENGTH) {
return false;
}
// Secret-shaped tokens only: base64/hex-ish charset, no path/URL punctuation.
if (!/^[A-Za-z0-9+/_=.-]+$/.test(value)) {
return false;
}
// Require a letter/digit mix so prose words and pure numbers don't trip it.
if (!/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
return false;
}
if (SecretDetectionRule.looksLikePlaceholder(value)) {
return false;
}
if (SecretDetectionRule.distinctChars(value) <
SecretDetectionRule.MIN_DISTINCT_CHARS) {
return false;
}
return (SecretDetectionRule.shannonEntropy(value) >=
SecretDetectionRule.MIN_ENTROPY_BITS);
}
static distinctChars(value) {
return new Set(value).size;
}
static shannonEntropy(value) {
const frequencies = new Map();
for (const char of value) {
frequencies.set(char, (frequencies.get(char) ?? 0) + 1);
}
let entropy = 0;
for (const count of frequencies.values()) {
const probability = count / value.length;
entropy -= probability * Math.log2(probability);
}
return entropy;
}
}
//# sourceMappingURL=SecretDetectionRule.js.map