const SECRET_ENV_PATTERN = /\bprocess(?:\.env|\[['"]env['"]\])(?:\.|\[['"])([A-Z0-9_]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY)[A-Z0-9_]*)(?:['"]\])?/g; const PRIVATE_KEY_PATTERN = /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/; const BEARER_LITERAL_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i; const ASSIGNMENT_SECRET_LITERAL_PATTERN = /\b(?:api[_-]?key|token|secret|password)\b\s*[:=]\s*['"][^'"]{12,}['"]/i; const SECRET_AUTH_LITERAL_FINDING = 'literal credential passed to ctx.secrets auth helper'; function skipQuoted(source: string, start: number): number { const quote = source[start]; let index = start + 1; while (index < source.length) { if (source[index] === '\\') { index += 2; } else if (source[index] === quote) { return index + 1; } else { index += 1; } } return source.length; } function skipTrivia(source: string, start: number): number { let index = start; while (index < source.length) { if (/\s/.test(source[index])) { index += 1; } else if (source.startsWith('//', index)) { index = source.indexOf('\n', index + 2); if (index === -1) return source.length; } else if (source.startsWith('/*', index)) { index = source.indexOf('*/', index + 2); if (index === -1) return source.length; index += 2; } else { return index; } } return index; } function consumeIdentifier( source: string, start: number, expected: string, ): number | undefined { return source.startsWith(expected, start) && !/[A-Za-z0-9_$]/.test(source[start - 1] ?? '') && !/[A-Za-z0-9_$]/.test(source[start + expected.length] ?? '') ? start + expected.length : undefined; } function consumeMember( source: string, start: number, expected: string, ): number | undefined { let index = skipTrivia(source, start); if (source[index] === '.') { return consumeIdentifier(source, skipTrivia(source, index + 1), expected); } if (source[index] !== '[') return undefined; index = skipTrivia(source, index + 1); if (source[index] !== "'" && source[index] !== '"') return undefined; const end = skipQuoted(source, index); if (source.slice(index + 1, end - 1) !== expected) return undefined; index = skipTrivia(source, end); return source[index] === ']' ? index + 1 : undefined; } function skipExpression(source: string, start: number): number { let index = start; let depth = 0; while (index < source.length) { const char = source[index]; if (char === "'" || char === '"' || char === '`') { index = skipQuoted(source, index); continue; } if (source.startsWith('//', index) || source.startsWith('/*', index)) { index = skipTrivia(source, index); continue; } if (char === '(' || char === '[' || char === '{') { depth += 1; } else if (char === ')' || char === ']' || char === '}') { if (depth === 0) return index; depth -= 1; } else if (char === ',' && depth === 0) { return index; } index += 1; } return index; } function isDirectStringLiteral(source: string, start: number): boolean { const quote = source[start]; if (quote !== "'" && quote !== '"' && quote !== '`') return false; const end = skipQuoted(source, start); return quote !== '`' || !source.slice(start, end).includes('${'); } function hasLiteralSecretAuthCredential(source: string): boolean { for (let index = 0; index < source.length; index += 1) { const char = source[index]; if (char === "'" || char === '"' || char === '`') { index = skipQuoted(source, index) - 1; continue; } if (source.startsWith('//', index) || source.startsWith('/*', index)) { index = skipTrivia(source, index) - 1; continue; } const afterCtx = consumeIdentifier(source, index, 'ctx'); if (!afterCtx) continue; const afterSecrets = consumeMember(source, afterCtx, 'secrets'); if (!afterSecrets) continue; const afterHeader = consumeMember(source, afterSecrets, 'header'); const helper = afterHeader ? 'header' : 'bearer'; const afterHelper = afterHeader ?? consumeMember(source, afterSecrets, 'bearer'); if (!afterHelper) continue; const afterOpen = skipTrivia(source, afterHelper); if (source[afterOpen] !== '(') continue; const firstArgument = skipTrivia(source, afterOpen + 1); const credential = helper === 'header' ? skipTrivia(source, skipExpression(source, firstArgument) + 1) : firstArgument; if (isDirectStringLiteral(source, credential)) return true; } return false; } /** * Returns the inline-secret findings in a string (empty if none). The throwing * validator below and the workflows→plays migration validator both call this so * the heuristics stay a single source of truth (no drift between "publish * rejects it" and "transform skips it loudly"). */ export function collectInlineSecretFindings(sourceCode: string): string[] { const findings: string[] = []; for (const match of sourceCode.matchAll(SECRET_ENV_PATTERN)) { findings.push(`process.env.${match[1]}`); } if (PRIVATE_KEY_PATTERN.test(sourceCode)) findings.push('private key block'); if (BEARER_LITERAL_PATTERN.test(sourceCode)) findings.push('bearer token literal'); if (ASSIGNMENT_SECRET_LITERAL_PATTERN.test(sourceCode)) { findings.push('secret-looking assignment literal'); } if (hasLiteralSecretAuthCredential(sourceCode)) { findings.push(SECRET_AUTH_LITERAL_FINDING); } return [...new Set(findings)]; } export function validatePlaySourceHasNoInlineSecrets(input: { sourceCode: string; filePath: string; }): void { const findings = collectInlineSecretFindings(input.sourceCode); if (!findings.length) return; throw new Error( [ `Play source ${input.filePath} appears to contain inline secret material: ${[ ...new Set(findings), ].join(', ')}.`, 'Author secrets in the dashboard, declare them in the Play\'s top-level secrets option, and read them at runtime with await ctx.secrets.get("NAME").', ].join(' '), ); } export function validatePlaySourceFilesHaveNoInlineSecrets( sourceFiles: Record, ): void { for (const [filePath, sourceCode] of Object.entries(sourceFiles)) { validatePlaySourceHasNoInlineSecrets({ filePath, sourceCode }); } }