/* * A column that can hold a credential has to be encrypted at rest. * * ── What this catches, and what it cost to find by hand (2026-09-09) ────── * `TwinReference.connectionConfig` was a plain `simple-json` column holding a signing secret * and a live access token. Two shell commands read both: * * sqlite3 -readonly db.sqlite "select connection_config from twin_references" * {"endpoint":"…","hookSecret":"dev-only-localhost-hook-2026","token":"eyJhbGciOi…"} * * The read path was already guarded — the detail resolver masks fields an adapter declares * `secret: true`. So the leak was not on the way out, it was the stored bytes: the database * file, its backups, its replicas. **Two different protections, and having one reads exactly * like having both.** * * ── Why a guard and not a review habit ──────────────────────────────────── * The framework has had the pieces for a while — `encryptJsonTransformer` and * `longTextColumn` — and nothing used them together. Two packages imported the transformer and * left it commented out. A rule that depends on someone remembering two exports is a rule that * holds until the next new column. * * So the rule is measured instead: any column whose name says it can carry a credential must * carry an encrypting transformer. New entities are covered the day they are written. * * ── What this does not claim ────────────────────────────────────────────── * Encryption at rest does not protect a value from anyone who can run this process — that * person holds the key. It protects the stored copy, which is a different set of people. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, readdirSync, statSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { join } from 'node:path' const SERVER = fileURLToPath(new URL('../server', import.meta.url)) /** * 이름이 「자격 증명이 들 수 있다」고 말하는 칸. * * 이름으로 판정하는 것의 한계를 적어 둔다 — `connectionConfig` 처럼 **이름이 비밀을 말하지 않는** * 칸이 실제로 비밀을 담고 있었다. 그래서 그 이름을 목록에 넣는다. 새 칸이 같은 부류이면 여기 더한다. */ const CREDENTIAL_NAMES = /^(secret|token|password|passwd|apiKey|credential|credentials|privateKey|connectionConfig)\??$/i /** 암호화를 붙이는 방법 — 둘 중 하나면 된다(shell 이 내놓는 것). */ const ENCRYPTED = /encryptedJsonColumn\(\)|encryptTransformer|encryptJsonTransformer/ function entityFiles(dir: string, out: string[] = []): string[] { for (const name of readdirSync(dir)) { const full = join(dir, name) if (statSync(full).isDirectory()) { entityFiles(full, out) continue } if (!full.endsWith('.ts') || full.endsWith('.d.ts')) continue const src = readFileSync(full, 'utf-8') if (src.includes('@Entity(')) out.push(full) } return out } /** 한 파일에서 `@Column` 선언과 그것이 붙은 속성 이름의 짝. */ function columnsOf(src: string): { property: string; declaration: string; line: number }[] { const lines = src.split('\n') const out: { property: string; declaration: string; line: number }[] = [] for (let i = 0; i < lines.length; i++) { if (!/^\s*@Column\(/.test(lines[i])) continue /* 선언이 여러 줄일 수 있다 — 괄호가 닫힐 때까지 모은다. */ let declaration = '' let depth = 0 let j = i do { declaration += lines[j] for (const ch of lines[j]) { if (ch === '(') depth++ else if (ch === ')') depth-- } j++ } while (depth > 0 && j < lines.length && j - i < 12) /* 데코레이터 다음의 첫 속성 줄 — 사이에 @Field 등이 끼어든다. */ for (let k = j; k < Math.min(j + 6, lines.length); k++) { const m = /^\s{2}(?:readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)\??\s*[:!]/.exec(lines[k]) if (m) { out.push({ property: m[1], declaration, line: i + 1 }) break } } } return out } test('★ a column that can hold a credential is encrypted at rest', () => { const offenders: string[] = [] let examined = 0 for (const file of entityFiles(SERVER)) { const src = readFileSync(file, 'utf-8') for (const column of columnsOf(src)) { examined++ if (!CREDENTIAL_NAMES.test(column.property)) continue if (ENCRYPTED.test(column.declaration)) continue offenders.push(`${file.slice(SERVER.length + 1)}:${column.line} ${column.property}`) } } assert.ok(examined > 40, `본 칸이 ${examined}개다 — 찾는 방법이 깨졌다`) assert.deepEqual( offenders, [], 'these columns can hold a credential and are stored in the clear:\n ' + offenders.join('\n ') + '\nAdd ...encryptedJsonColumn() (json) or transformer: encryptTransformer (string) from @things-factory/shell.\n' + 'Note the column type has to change with it — encryptedJsonColumn() carries both, which is why it exists.' ) }) test('this guard reads real entities, so a broken scan is red and not green', () => { /* * 위 검사는 위반이 없으면 통과한다. **아무것도 못 읽어도** 통과하므로, 읽었다는 사실을 따로 잰다 — * 오늘 이 저장소의 다른 검사가 견준 것이 0인데 초록이던 부류가 넷이었다. */ const files = entityFiles(SERVER) assert.ok(files.length > 10, `엔티티 파일이 ${files.length}개다 — 순회가 깨졌다`) const reference = files.find(f => f.endsWith('twin-reference.ts')) assert.ok(reference, 'twin-reference.ts 를 못 찾았다 — 이 검사가 생긴 이유가 그 파일이다') const found = columnsOf(readFileSync(reference!, 'utf-8')) const config = found.find(c => c.property === 'connectionConfig') assert.ok(config, 'connectionConfig 칸을 못 읽었다 — 짝짓기가 깨졌다') assert.match(config!.declaration, ENCRYPTED, 'connectionConfig 가 암호화되어 있지 않다') })