.` (lower-case) names known to be safe under the * rule. Add justification comments here if a future migration needs * an exception. Currently empty — none of the suspect names match a * legitimate column. * * @var list */ private const ALLOWLIST_COLUMNS = []; public function testNoSchemaColumnLooksLikeUnhashedSecret(): void { $offenders = []; foreach ($this->listUserTables() as $table) { foreach ($this->columnsOf($table) as $column) { $name = strtolower((string) $column); $key = $table . '.' . $name; if (in_array($key, self::ALLOWLIST_COLUMNS, true)) { continue; } if (self::matchesSuspect($name)) { $offenders[] = $key; } } } self::assertSame( [], $offenders, "Schema contains columns whose name suggests they store unhashed secrets:\n - " . implode("\n - ", $offenders) . "\n\nIf any of these are intentional and store hashed/derived data, add to ALLOWLIST_COLUMNS with justification." ); } public function testApiTokensTableStoresHashesNotRawValues(): void { // Positive assertion: the `api_tokens` table — the one place we // legitimately have to keep "credential-shaped" data — has the // expected hash + prefix columns (and no plaintext column). $names = array_map('strtolower', $this->columnsOf('api_tokens')); self::assertContains('token_hash', $names, 'api_tokens must have token_hash column'); self::assertContains('token_prefix', $names, 'api_tokens must have token_prefix column'); self::assertNotContains('token', $names, 'api_tokens must NOT have a raw token column'); self::assertNotContains('token_plain', $names); self::assertNotContains('raw_token', $names); } /** * @return list */ private function listUserTables(): array { /** @var list> $rows */ $rows = $this->db->fetchAllAssociative( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'phinxlog%'" ); return array_map(static fn (array $r): string => (string) $r['name'], $rows); } /** * @return list */ private function columnsOf(string $table): array { /** @var list> $rows */ $rows = $this->db->fetchAllAssociative( sprintf('PRAGMA table_info(%s)', $this->db->quoteIdentifier($table)) ); return array_map(static fn (array $r): string => (string) $r['name'], $rows); } private static function matchesSuspect(string $name): bool { foreach (self::SUSPECT_NEEDLES as $needle) { if (str_contains($name, $needle)) { return true; } } return false; } }