/** * Forbids spreading the entire `credentials` object into a cache-key hash input * inside `cache.ts`. * * Bad: * crc32(JSON.stringify({ ...params, ...credentials })) * * Good (smartsheet/google-drive/freshservice pattern): * if (!credentials.unitoCredentialId) { * throw new HttpErrors.UnprocessableEntityError('...'); * } * hashValue({ ...params, userIdentifier: credentials.unitoCredentialId }); * * `unitoCredentialId` is Unito's internal record ID for the credential row — a * non-secret UUID. Hashing it never puts secret material in memory. Do NOT * fall back to `accessToken`. * * Why: * 1. Cache thrash on credential rotation. Access tokens rotate on every OAuth * refresh (and refresh tokens themselves rotate on use for some providers); * spreading the full credential makes EVERY field a cache-key input, so a * single-field rotation invalidates the whole cache. * 2. Credential surface area. The full credential transits memory as a JSON * string before hashing. Any error in this path can surface in stack * traces or memory dumps. * 3. Wrong unit of identity. Cache should be keyed on the stable identifier * of the credential (`unitoCredentialId`), not the credential material. * * Scope: * Only fires in files whose path ends in `/src/cache.ts`. `credentials.ts` * has legitimate `...credentials` spread (enrichment patterns) and is never * affected. */ import type { Rule } from 'eslint'; const CACHE_FILE_RE = /\/src\/cache\.ts$/; const CREDENTIALS_IDENTIFIER_RE = /^cred(|s|entials)$/; const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { description: 'Disallow spreading the entire `credentials` object into a cache-key hash input inside `cache.ts`', recommended: true, }, schema: [], messages: { credentialsSpread: 'Do not spread `...{{name}}` into a cache-key hash input. Spreading the full credential makes every field (including rotating tokens) part of the cache key, causes cache thrash on token refresh, and surfaces credential material as a JSON string. Use a non-secret stable identifier instead, e.g. `credentials.unitoCredentialId` (throw if it is missing — never fall back to `accessToken`).', }, }, create(context) { const filename = context.filename ?? ''; if (!CACHE_FILE_RE.test(filename)) { return {}; } return { SpreadElement(node) { if (!node.argument || node.argument.type !== 'Identifier') { return; } if (!CREDENTIALS_IDENTIFIER_RE.test(node.argument.name)) { return; } context.report({ node, messageId: 'credentialsSpread', data: { name: node.argument.name }, }); }, }; }, }; export default rule;