1
0

SchemaSecretsAtRestTest.php 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Auth;
  4. use App\Tests\Integration\Support\AppTestCase;
  5. /**
  6. * SPEC §M14.8: a best-effort sanity scan of the schema for columns whose
  7. * name suggests they could store unhashed credentials.
  8. *
  9. * The architectural rule is: **the api never stores passwords or raw
  10. * secrets**. Local-admin passwords live as Argon2id hashes in the UI's
  11. * env (not in the DB at all). API tokens are stored as their SHA-256
  12. * digest plus an 8-char prefix. OIDC, MaxMind, and IPinfo credentials
  13. * never touch the DB — they live in `.env` for the relevant container.
  14. *
  15. * This test catches accidental schema drift where someone adds a column
  16. * called `password`, `client_secret`, etc. — anything that smells like a
  17. * credential.
  18. *
  19. * Implementation note: DBAL's SQLite SchemaManager can't always parse
  20. * tables defined with raw CREATE TABLE statements that include CHECK
  21. * constraints (the api_tokens table does this for kind-vs-id mutual
  22. * exclusion). We sidestep that by reading sqlite_master + pragma
  23. * directly, which works for the SQLite test bootstrap regardless of
  24. * how the table was created.
  25. */
  26. final class SchemaSecretsAtRestTest extends AppTestCase
  27. {
  28. /**
  29. * Columns whose name matches one of these substrings is suspect.
  30. * Lower-cased; we match a substring inside `lower(column_name)`.
  31. */
  32. private const SUSPECT_NEEDLES = [
  33. 'password',
  34. '_secret',
  35. 'secret_',
  36. 'license_key',
  37. 'api_key',
  38. 'private_key',
  39. 'client_secret',
  40. 'oauth_secret',
  41. 'plaintext',
  42. 'cleartext',
  43. ];
  44. /**
  45. * `<table>.<column>` (lower-case) names known to be safe under the
  46. * rule. Add justification comments here if a future migration needs
  47. * an exception. Currently empty — none of the suspect names match a
  48. * legitimate column.
  49. *
  50. * @var list<string>
  51. */
  52. private const ALLOWLIST_COLUMNS = [];
  53. public function testNoSchemaColumnLooksLikeUnhashedSecret(): void
  54. {
  55. $offenders = [];
  56. foreach ($this->listUserTables() as $table) {
  57. foreach ($this->columnsOf($table) as $column) {
  58. $name = strtolower((string) $column);
  59. $key = $table . '.' . $name;
  60. if (in_array($key, self::ALLOWLIST_COLUMNS, true)) {
  61. continue;
  62. }
  63. if (self::matchesSuspect($name)) {
  64. $offenders[] = $key;
  65. }
  66. }
  67. }
  68. self::assertSame(
  69. [],
  70. $offenders,
  71. "Schema contains columns whose name suggests they store unhashed secrets:\n - "
  72. . implode("\n - ", $offenders)
  73. . "\n\nIf any of these are intentional and store hashed/derived data, add to ALLOWLIST_COLUMNS with justification."
  74. );
  75. }
  76. public function testApiTokensTableStoresHashesNotRawValues(): void
  77. {
  78. // Positive assertion: the `api_tokens` table — the one place we
  79. // legitimately have to keep "credential-shaped" data — has the
  80. // expected hash + prefix columns (and no plaintext column).
  81. $names = array_map('strtolower', $this->columnsOf('api_tokens'));
  82. self::assertContains('token_hash', $names, 'api_tokens must have token_hash column');
  83. self::assertContains('token_prefix', $names, 'api_tokens must have token_prefix column');
  84. self::assertNotContains('token', $names, 'api_tokens must NOT have a raw token column');
  85. self::assertNotContains('token_plain', $names);
  86. self::assertNotContains('raw_token', $names);
  87. }
  88. /**
  89. * @return list<string>
  90. */
  91. private function listUserTables(): array
  92. {
  93. /** @var list<array<string, mixed>> $rows */
  94. $rows = $this->db->fetchAllAssociative(
  95. "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'phinxlog%'"
  96. );
  97. return array_map(static fn (array $r): string => (string) $r['name'], $rows);
  98. }
  99. /**
  100. * @return list<string>
  101. */
  102. private function columnsOf(string $table): array
  103. {
  104. /** @var list<array<string, mixed>> $rows */
  105. $rows = $this->db->fetchAllAssociative(
  106. sprintf('PRAGMA table_info(%s)', $this->db->quoteIdentifier($table))
  107. );
  108. return array_map(static fn (array $r): string => (string) $r['name'], $rows);
  109. }
  110. private static function matchesSuspect(string $name): bool
  111. {
  112. foreach (self::SUSPECT_NEEDLES as $needle) {
  113. if (str_contains($name, $needle)) {
  114. return true;
  115. }
  116. }
  117. return false;
  118. }
  119. }