import { SecretLintRuleContext, SecretLintRuleCreator, SecretLintRuleMessageTranslate, SecretLintSourceCode, } from "@secretlint/types"; import { matchPatterns } from "@textlint/regexp-string-matcher"; export const messages = { PrivateKey: { en: (props: { KEY: string }) => `found private key: ${props.KEY}`, ja: (props: { KEY: string }) => `秘密鍵: ${props.KEY} がみつかりました`, }, }; export type Options = { /** * Define allow pattern written by RegReg-like strings * See https://github.com/textlint/regexp-string-matcher#regexp-like-string **/ allows?: string[]; }; /** * Regular expression to detect private keys and extract Base64 content in one pass * Based on https://docs.cribl.io/docs/regexesyml * Captures the Base64 content (group 1) between BEGIN and END markers * * Length constraint {100,10000} (heuristic): * - Minimum 100: Heuristically excludes short placeholders (smallest real key ECDSA-256 ≈ 288 chars) * - Maximum 10000: Heuristically prevents catastrophic backtracking and extremely large inputs * * Character class [A-Za-z0-9+/=\s\\]: * - Restricts to Base64 characters, whitespace, and backslash to prevent ReDoS attacks * - Backslash included to handle JSON escape sequences (\n, \r, \t) * - Fails fast on invalid characters, reducing backtracking complexity * * ReDoS mitigation: * - Uses greedy quantifier (no ?) to reduce backtracking ambiguity * - Simplified optional parts to minimize exponential complexity * - Length limit ensures bounded execution time */ const PEM_CONTENT_PATTERN = /-----BEGIN[ ]?(?:(?:RSA|DSA|EC|OPENSSH|PGP) )?PRIVATE KEY(?: BLOCK)?-----\n?([A-Za-z0-9+/=\s\\]{100,10000})\n?-----END[ ]?(?:(?:RSA|DSA|EC|OPENSSH|PGP) )?PRIVATE KEY(?: BLOCK)?-----/gm; /** * Validate if the Base64 content is a real private key or a placeholder * Based on Base64 validation approach without decoding * * References: * - RFC 7468: Textual Encodings of PKIX, PKCS, and CMS Structures * https://tools.ietf.org/html/rfc7468 * - RFC 4648: The Base16, Base32, and Base64 Data Encodings * https://datatracker.ietf.org/doc/html/rfc4648 * - RFC 5208: PKCS #8: Private-Key Information Syntax v1.2 * https://datatracker.ietf.org/doc/html/rfc5208 * - RFC 5958: Asymmetric Key Packages * https://datatracker.ietf.org/doc/html/rfc5958 * - RFC 3447: PKCS #1: RSA Cryptography Specifications v2.1 * https://datatracker.ietf.org/doc/html/rfc3447 * - OpenSSH PROTOCOL.key * https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key * * @param base64Content - The Base64-encoded key content (without PEM headers) * @returns true if it's likely a real private key, false if it's a placeholder */ function validateBase64KeyContent(base64Content: string): boolean { // 1. Base64 format validation (RFC 4648) // Reject non-Base64 characters like "...", "***", "xxx" if (!/^[A-Za-z0-9+/]+=*$/.test(base64Content)) { return false; } // 2. Minimum length check (Base64 100 chars = ~75 bytes decoded) // Real keys are much longer: RSA-1024 ≈ 800 chars, EC-256 ≈ 120 chars if (base64Content.length < 100) { return false; } // 3. Magic byte check (in Base64) // ASN.1 format (PKCS#1, PKCS#8, SEC1): 0x30 (SEQUENCE) → "MI*" // OpenSSH format: "openssh-key-v1\0" → "b3BlbnNzaC1rZXktdjE" const validMagicBytes = /^(MI|b3BlbnNzaC1rZXktdjE)/; if (!validMagicBytes.test(base64Content)) { return false; } return true; } function reportIfFoundRawPrivateKey({ source, options, context, t, }: { source: SecretLintSourceCode; options: Required; context: SecretLintRuleContext; t: SecretLintRuleMessageTranslate; }) { // Use PEM_CONTENT_PATTERN to extract both full match and Base64 content in one pass const results = source.content.matchAll(PEM_CONTENT_PATTERN); for (const result of results) { const index = result.index || 0; const match = result[0] || ""; const base64Raw = result[1] || ""; const range = [index, index + match.length] as const; const allowedResults = matchPatterns(match, options.allows); if (allowedResults.length > 0) { continue; } // Remove whitespace and escaped newlines (from JSON strings) const base64Content = base64Raw.replace(/\s|\\n/g, ""); // Validate if the Base64 content is a real private key if (!validateBase64KeyContent(base64Content)) { continue; } context.report({ message: t("PrivateKey", { KEY: match, }), range, }); } } export const creator: SecretLintRuleCreator = { messages, meta: { id: "@secretlint/secretlint-rule-privatekey", recommended: true, type: "scanner", supportedContentTypes: ["text"], docs: { url: "https://github.com/secretlint/secretlint/blob/master/packages/%40secretlint/secretlint-rule-privatekey/README.md", }, }, create(context, options) { const t = context.createTranslator(messages); const normalizedOptions = { allows: options.allows || [], }; return { file(source: SecretLintSourceCode) { reportIfFoundRawPrivateKey({ source, options: normalizedOptions, context, t }); }, }; }, };