@hilbras/keystone 2.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +350 -0
  2. package/README.md +72 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +7 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/rateLimit.d.ts +10 -7
  10. package/dist/plugins/rateLimit.d.ts.map +1 -1
  11. package/dist/plugins/rateLimit.js +75 -36
  12. package/dist/plugins/rateLimit.js.map +1 -1
  13. package/dist/routes/admin/organizations.d.ts.map +1 -1
  14. package/dist/routes/admin/organizations.js +2 -0
  15. package/dist/routes/admin/organizations.js.map +1 -1
  16. package/dist/routes/admin/platform.d.ts.map +1 -1
  17. package/dist/routes/admin/platform.js +14 -1
  18. package/dist/routes/admin/platform.js.map +1 -1
  19. package/dist/routes/apiKeys.d.ts.map +1 -1
  20. package/dist/routes/apiKeys.js +15 -1
  21. package/dist/routes/apiKeys.js.map +1 -1
  22. package/dist/routes/auth.d.ts.map +1 -1
  23. package/dist/routes/auth.js +104 -3
  24. package/dist/routes/auth.js.map +1 -1
  25. package/dist/routes/emailVerification.d.ts.map +1 -1
  26. package/dist/routes/emailVerification.js +2 -0
  27. package/dist/routes/emailVerification.js.map +1 -1
  28. package/dist/routes/magicLinks.d.ts.map +1 -1
  29. package/dist/routes/magicLinks.js +2 -0
  30. package/dist/routes/magicLinks.js.map +1 -1
  31. package/dist/routes/oauth2.d.ts.map +1 -1
  32. package/dist/routes/oauth2.js +16 -2
  33. package/dist/routes/oauth2.js.map +1 -1
  34. package/dist/routes/password.d.ts.map +1 -1
  35. package/dist/routes/password.js +2 -0
  36. package/dist/routes/password.js.map +1 -1
  37. package/dist/routes/scim.d.ts.map +1 -1
  38. package/dist/routes/scim.js +2 -0
  39. package/dist/routes/scim.js.map +1 -1
  40. package/dist/routes/smsOtp.d.ts.map +1 -1
  41. package/dist/routes/smsOtp.js +4 -0
  42. package/dist/routes/smsOtp.js.map +1 -1
  43. package/dist/routes/totp.d.ts.map +1 -1
  44. package/dist/routes/totp.js +18 -1
  45. package/dist/routes/totp.js.map +1 -1
  46. package/dist/services/configuration/profiles.d.ts +26 -0
  47. package/dist/services/configuration/profiles.d.ts.map +1 -1
  48. package/dist/services/configuration/profiles.js +80 -1
  49. package/dist/services/configuration/profiles.js.map +1 -1
  50. package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
  51. package/dist/services/events/subscribers/auditLog.js +38 -3
  52. package/dist/services/events/subscribers/auditLog.js.map +1 -1
  53. package/dist/services/events/types.d.ts +3 -1
  54. package/dist/services/events/types.d.ts.map +1 -1
  55. package/dist/services/events/validate.d.ts +1 -0
  56. package/dist/services/events/validate.d.ts.map +1 -1
  57. package/dist/services/events/validate.js +5 -1
  58. package/dist/services/events/validate.js.map +1 -1
  59. package/dist/services/localRateLimit.d.ts +44 -0
  60. package/dist/services/localRateLimit.d.ts.map +1 -0
  61. package/dist/services/localRateLimit.js +86 -0
  62. package/dist/services/localRateLimit.js.map +1 -0
  63. package/dist/services/refreshTokenState.d.ts +5 -0
  64. package/dist/services/refreshTokenState.d.ts.map +1 -0
  65. package/dist/services/refreshTokenState.js +30 -0
  66. package/dist/services/refreshTokenState.js.map +1 -0
  67. package/dist/services/setup/token.d.ts +12 -0
  68. package/dist/services/setup/token.d.ts.map +1 -1
  69. package/dist/services/setup/token.js +27 -3
  70. package/dist/services/setup/token.js.map +1 -1
  71. package/dist/services/tokens.d.ts +1 -0
  72. package/dist/services/tokens.d.ts.map +1 -1
  73. package/dist/services/tokens.js +1 -1
  74. package/dist/services/tokens.js.map +1 -1
  75. package/dist/services/trustedProxies.d.ts +24 -0
  76. package/dist/services/trustedProxies.d.ts.map +1 -1
  77. package/dist/services/trustedProxies.js +19 -0
  78. package/dist/services/trustedProxies.js.map +1 -1
  79. package/dist/services/webhooks.d.ts +16 -0
  80. package/dist/services/webhooks.d.ts.map +1 -1
  81. package/dist/services/webhooks.js +48 -3
  82. package/dist/services/webhooks.js.map +1 -1
  83. package/dist/setup-server.js +47 -3
  84. package/dist/setup-server.js.map +1 -1
  85. package/docs/API-REVIEW.md +121 -0
  86. package/docs/API.md +457 -0
  87. package/docs/ARCHITECTURE.md +142 -0
  88. package/docs/CONTRIBUTING.md +61 -0
  89. package/docs/DEPLOYMENT.md +257 -0
  90. package/docs/INTEGRATION.md +336 -0
  91. package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
  92. package/docs/MIGRATION-1.7.md +70 -0
  93. package/docs/MIGRATION-1.8.md +183 -0
  94. package/docs/MIGRATION-1.9.md +200 -0
  95. package/docs/MIGRATION-2.0.md +203 -0
  96. package/docs/MIGRATION-2.4.md +185 -0
  97. package/docs/PERFORMANCE.md +155 -0
  98. package/docs/RBAC.md +100 -0
  99. package/docs/RE-AUDIT.md +72 -0
  100. package/docs/README.md +54 -0
  101. package/docs/RELEASE-1.7.md +53 -0
  102. package/docs/ROADMAP.md +41 -0
  103. package/docs/SECURITY.md +143 -0
  104. package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
  105. package/docs/adrs/002-versioned-event-bus.md +30 -0
  106. package/docs/adrs/003-bullmq-for-background-work.md +20 -0
  107. package/docs/adrs/004-argon2id-password-hashing.md +19 -0
  108. package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
  109. package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
  110. package/docs/security/audit.md +82 -0
  111. package/docs/security/configuration.md +60 -0
  112. package/docs/security/enterprise-sso.md +193 -0
  113. package/docs/security/mtls.md +132 -0
  114. package/docs/security/proxy-security.md +128 -0
  115. package/docs/security/rate-limiting.md +79 -0
  116. package/docs/security/registry-exceptions.md +34 -0
  117. package/docs/security/registry.json +649 -0
  118. package/docs/security/registry.md +657 -0
  119. package/docs/security/scopes.md +45 -0
  120. package/docs/security/supply-chain.md +49 -0
  121. package/docs/security/trust-boundaries.md +111 -0
  122. package/package.json +15 -6
@@ -0,0 +1,649 @@
1
+ {
2
+ "$comment": "Security regression registry. Each entry is a vulnerability that was found, fixed, and given a permanent test. Entries may be withdrawn; a withdrawn id is recorded in `withdrawn` with the reason rather than renumbered, because a registry whose ids shift is one nobody can cite. `coverage` records suites that assert a property without corresponding to an evidenced defect. The registry is the source of truth: `npm run registry:check` verifies every entry points at a test file that exists, that no security suite is unclaimed, and that every mandatory attack class is covered. `npm run registry:render` regenerates docs/security/registry.md from this file, so the documentation cannot drift from the tests.",
3
+ "mandatoryAttackClasses": [
4
+ "privilege-escalation",
5
+ "mfa-bypass",
6
+ "tenant-isolation",
7
+ "token-replay",
8
+ "oauth-attacks",
9
+ "scim-cross-tenant",
10
+ "mtls-spoofing",
11
+ "xff-spoofing",
12
+ "api-scope-escalation",
13
+ "secret-disclosure",
14
+ "session-persistence",
15
+ "password-reset-invalidation"
16
+ ],
17
+ "entries": [
18
+ {
19
+ "id": "SEC-001",
20
+ "title": "Organization role escalation through self-assignment",
21
+ "severity": "critical",
22
+ "component": "authorization",
23
+ "release": "1.7.0",
24
+ "issue": "A member could set their own organization role, so a `member` could promote themselves to `owner` and then administer the organization they were only supposed to belong to.",
25
+ "fix": "src/routes/admin/organizations.ts — role changes are owner-only and cannot target the caller's own membership",
26
+ "test": "src/tests/security/authorization/authorization.test.ts",
27
+ "documentation": "docs/security/trust-boundaries.md",
28
+ "covers": [
29
+ "privilege-escalation",
30
+ "tenant-isolation"
31
+ ]
32
+ },
33
+ {
34
+ "id": "SEC-002",
35
+ "title": "Platform-owner routes reachable without the owner role",
36
+ "severity": "critical",
37
+ "component": "authorization",
38
+ "release": "1.7.0",
39
+ "issue": "Platform administration endpoints checked authentication but not the owner role, so any authenticated session could list and modify every user, organization and application in the installation.",
40
+ "fix": "src/routes/admin/helpers.ts — requireOwner() on every platform route",
41
+ "test": "src/tests/security/authorization/authorization.test.ts",
42
+ "documentation": "docs/security/trust-boundaries.md",
43
+ "covers": [
44
+ "privilege-escalation"
45
+ ]
46
+ },
47
+ {
48
+ "id": "SEC-003",
49
+ "title": "Tenant-unsafe workflow execution",
50
+ "severity": "high",
51
+ "component": "authorization",
52
+ "release": "1.7.0",
53
+ "issue": "Workflow lookup was keyed by identifier alone, so a request naming another organization's workflow run executed it and read its output.",
54
+ "fix": "src/services/workflows/engine.ts — every lookup is scoped to the owning organization",
55
+ "test": "src/tests/security/authorization/authorization.test.ts",
56
+ "documentation": "docs/security/trust-boundaries.md",
57
+ "covers": [
58
+ "tenant-isolation",
59
+ "privilege-escalation"
60
+ ]
61
+ },
62
+ {
63
+ "id": "SEC-004",
64
+ "title": "MFA could be enabled without ever being enforced",
65
+ "severity": "critical",
66
+ "component": "mfa",
67
+ "release": "1.8.0",
68
+ "issue": "MFA was checked on the interactive login path only. Every other token-issuing path — refresh, OAuth token exchange, the token login flow — issued a full session without consulting the user's MFA state, so the control was present in the UI and absent everywhere else.",
69
+ "fix": "src/services/application/authentication.ts — a single token-issuance chokepoint that every flow passes through",
70
+ "test": "src/tests/security/mfa/mfa.test.ts",
71
+ "documentation": "docs/security/trust-boundaries.md",
72
+ "covers": [
73
+ "mfa-bypass"
74
+ ]
75
+ },
76
+ {
77
+ "id": "SEC-005",
78
+ "title": "TOTP verification was not bound to the user",
79
+ "severity": "high",
80
+ "component": "mfa",
81
+ "release": "1.8.0",
82
+ "issue": "A TOTP code was checked against a resolved secret without confirming the code belonged to the account being authenticated, so a valid code for one account could satisfy verification for another.",
83
+ "fix": "src/services/totp.ts — verification is user-scoped",
84
+ "test": "src/tests/security/mfa/mfa.test.ts",
85
+ "documentation": "docs/security/trust-boundaries.md",
86
+ "covers": [
87
+ "mfa-bypass"
88
+ ]
89
+ },
90
+ {
91
+ "id": "SEC-006",
92
+ "title": "SCIM credentials were not organization-scoped",
93
+ "severity": "critical",
94
+ "component": "scim",
95
+ "release": "1.9.0",
96
+ "issue": "A SCIM token was accepted as a global credential, so a provisioning client for one organization could read and write users and groups in every organization in the installation.",
97
+ "fix": "src/services/scimCredentials.ts and src/routes/scim.ts — a credential resolves to exactly one organization",
98
+ "test": "src/tests/security/scim/isolation.test.ts",
99
+ "documentation": "docs/security/trust-boundaries.md",
100
+ "covers": [
101
+ "scim-cross-tenant",
102
+ "tenant-isolation"
103
+ ]
104
+ },
105
+ {
106
+ "id": "SEC-007",
107
+ "title": "x-service-account-id was a complete authentication bypass",
108
+ "severity": "critical",
109
+ "component": "trust-boundary",
110
+ "release": "2.0.0",
111
+ "issue": "The mTLS plugin trusted a client-supplied `x-service-account-id` header as the caller's identity. Any client that could reach the service could assert any service account and act as it, with no certificate and no secret.",
112
+ "fix": "src/plugins/mtls.ts — identity is derived from the verified client certificate and bound to its fingerprint",
113
+ "test": "src/tests/security/proxy/trust-boundary.test.ts",
114
+ "documentation": "docs/security/mtls.md",
115
+ "covers": [
116
+ "mtls-spoofing",
117
+ "privilege-escalation"
118
+ ]
119
+ },
120
+ {
121
+ "id": "SEC-008",
122
+ "title": "Unconditional x-forwarded-for trust escaped every rate limit",
123
+ "severity": "high",
124
+ "component": "proxy",
125
+ "release": "2.0.0",
126
+ "issue": "`trustProxy: true` accepted x-forwarded-for from any peer, so a client could present a fresh address on every request and receive an unlimited rate-limit budget on every endpoint, including login and MFA verification.",
127
+ "fix": "src/services/trustedProxies.ts — forwarding headers are honoured only from explicitly configured networks",
128
+ "test": "src/tests/security/rate-limiting/abuse-prevention.test.ts",
129
+ "documentation": "docs/security/proxy-security.md",
130
+ "covers": [
131
+ "xff-spoofing"
132
+ ]
133
+ },
134
+ {
135
+ "id": "SEC-009",
136
+ "title": "Client-supplied identity headers were not stripped",
137
+ "severity": "high",
138
+ "component": "trust-boundary",
139
+ "release": "2.0.0",
140
+ "issue": "Identity-bearing headers survived to route handlers even when the peer was untrusted, so a header-stripping gap in one plugin would be a bypass in another.",
141
+ "fix": "src/plugins/headerSanitization.ts — untrusted identity headers are removed before routing",
142
+ "test": "src/tests/security/proxy/trust-boundary.test.ts",
143
+ "documentation": "docs/security/trust-boundaries.md",
144
+ "covers": [
145
+ "xff-spoofing",
146
+ "mtls-spoofing"
147
+ ]
148
+ },
149
+ {
150
+ "id": "SEC-010",
151
+ "title": "A service account certificate was not bound to the account it claimed",
152
+ "severity": "high",
153
+ "component": "mtls",
154
+ "release": "2.0.0",
155
+ "issue": "Certificate authentication proved possession of a valid certificate but not that the certificate belonged to the service account being used, so any certificate issued by a trusted CA could impersonate any service account.",
156
+ "fix": "src/plugins/mtls.ts — the certificate fingerprint must match the stored binding",
157
+ "test": "src/tests/security/proxy/trust-boundary.test.ts",
158
+ "documentation": "docs/security/mtls.md",
159
+ "covers": [
160
+ "mtls-spoofing"
161
+ ]
162
+ },
163
+ {
164
+ "id": "SEC-011",
165
+ "title": "A production container shipped a bundled npm with eight high-severity CVEs",
166
+ "severity": "high",
167
+ "component": "supply-chain",
168
+ "release": "2.1.0",
169
+ "issue": "The production image included npm itself, which carried eight high advisories. `npm audit` and the OSV scanner both read the lockfile, not the image, so the finding was invisible to every configured gate.",
170
+ "fix": "Dockerfile — npm is removed from the runtime stage",
171
+ "test": ".github/workflows/supply-chain.yml",
172
+ "documentation": "docs/security/supply-chain.md",
173
+ "covers": []
174
+ },
175
+ {
176
+ "id": "SEC-012",
177
+ "title": "Single-use credentials were readable and consumable more than once",
178
+ "severity": "high",
179
+ "component": "tokens",
180
+ "release": "2.2.0",
181
+ "issue": "Magic links, password reset tokens and SMS OTP codes were checked and then consumed as two separate steps. Concurrent requests could each pass the check before any of them consumed the credential, so a single emailed code produced many sessions — 50 concurrent requests yielded 50 successful logins.",
182
+ "fix": "src/services/singleUse.ts — a single atomic claim, so exactly one request can consume a credential",
183
+ "test": "src/tests/security/tokens/single-use.test.ts",
184
+ "documentation": "docs/security/trust-boundaries.md",
185
+ "covers": [
186
+ "token-replay"
187
+ ]
188
+ },
189
+ {
190
+ "id": "SEC-013",
191
+ "title": "A used credential was not distinguishable from an unknown one",
192
+ "severity": "medium",
193
+ "component": "tokens",
194
+ "release": "2.2.0",
195
+ "issue": "Replay of a consumed magic link, reset token or OTP returned the same error as presenting a credential that never existed, so the second use of a stolen code was invisible.",
196
+ "fix": "src/services/singleUse.ts — replay is detected and reported as its own outcome",
197
+ "test": "src/tests/security/tokens/single-use.test.ts",
198
+ "documentation": "docs/security/trust-boundaries.md",
199
+ "covers": [
200
+ "token-replay"
201
+ ]
202
+ },
203
+ {
204
+ "id": "SEC-014",
205
+ "title": "Password reset did not revoke existing sessions",
206
+ "severity": "high",
207
+ "component": "sessions",
208
+ "release": "2.3.0",
209
+ "issue": "Resetting a password left every existing session and refresh token valid. An attacker who had obtained a session before the victim changed their password retained access indefinitely, which defeats the purpose of the reset.",
210
+ "fix": "src/services/sessionRevocation.ts — a password change evicts sessions, refresh tokens and pending challenges",
211
+ "test": "src/tests/security/sessions/session-revocation.test.ts",
212
+ "documentation": "docs/security/trust-boundaries.md",
213
+ "covers": [
214
+ "password-reset-invalidation",
215
+ "session-persistence"
216
+ ]
217
+ },
218
+ {
219
+ "id": "SEC-015",
220
+ "title": "Enabling MFA did not invalidate sessions issued without it",
221
+ "severity": "high",
222
+ "component": "sessions",
223
+ "release": "2.3.0",
224
+ "issue": "Turning on a second factor left pre-existing sessions untouched, so an attacker holding a session captured before enrolment kept full access after the user believed they had raised their security.",
225
+ "fix": "src/services/sessionRevocation.ts — revocation is centralized and applied on MFA enablement",
226
+ "test": "src/tests/security/mfa/mfa.test.ts",
227
+ "documentation": "docs/security/trust-boundaries.md",
228
+ "covers": [
229
+ "session-persistence",
230
+ "mfa-bypass"
231
+ ]
232
+ },
233
+ {
234
+ "id": "SEC-016",
235
+ "title": "The authorization_code grant never verified the client secret",
236
+ "severity": "high",
237
+ "component": "oauth",
238
+ "release": "2.4.0",
239
+ "issue": "`verifyClientSecret` was called on the other grants but not on the authorization code exchange, so a confidential client could redeem a code with only its public client_id. Latent rather than live — the code still required a matching redirect_uri and the code itself — but it removed a layer that the grant was specified to have.",
240
+ "fix": "src/routes/oauth2.ts — client authentication is mandatory for confidential clients on every grant",
241
+ "test": "src/tests/security/oauth/oauth2-hardening.test.ts",
242
+ "documentation": "docs/MIGRATION-2.4.md",
243
+ "covers": [
244
+ "oauth-attacks"
245
+ ]
246
+ },
247
+ {
248
+ "id": "SEC-017",
249
+ "title": "javascript: and data: were accepted as redirect URIs",
250
+ "severity": "high",
251
+ "component": "oauth",
252
+ "release": "2.4.0",
253
+ "issue": "Redirect URI validation checked structure but not scheme, so a client could register a `javascript:` URI. The authorization response carrying a code or token would then execute in the context of the authorization page, turning the redirect into script execution.",
254
+ "fix": "src/services/redirectUri.ts — only https, and http for loopback, are registrable",
255
+ "test": "src/tests/security/oauth/oauth2-hardening.test.ts",
256
+ "documentation": "docs/MIGRATION-2.4.md",
257
+ "covers": [
258
+ "oauth-attacks"
259
+ ]
260
+ },
261
+ {
262
+ "id": "SEC-018",
263
+ "title": "OIDC authorization requests carried no nonce",
264
+ "severity": "medium",
265
+ "component": "oidc",
266
+ "release": "2.4.0",
267
+ "issue": "The authorization request generated no nonce and the ID token response was not checked for one, so a token minted for a different session could be replayed into this one.",
268
+ "fix": "src/routes/oauth2.ts — a nonce is generated per request and verified on the response",
269
+ "test": "src/tests/security/oauth/oauth2-hardening.test.ts",
270
+ "documentation": "docs/MIGRATION-2.4.md",
271
+ "covers": [
272
+ "oauth-attacks"
273
+ ]
274
+ },
275
+ {
276
+ "id": "SEC-019",
277
+ "title": "ID token algorithm was inferred and expiry was optional",
278
+ "severity": "high",
279
+ "component": "oidc",
280
+ "release": "2.4.0",
281
+ "issue": "The ID token's signing algorithm was read from the token's own header rather than from the connector's registered configuration, and neither `exp` nor `iat` was required. A token could therefore be verified under an algorithm the operator never configured, and one with no expiry at all was accepted.",
282
+ "fix": "src/services/connectors/oidc.ts — the algorithm comes from configuration, and exp and iat are required",
283
+ "test": "src/tests/security/oauth/oauth2-hardening.test.ts",
284
+ "documentation": "docs/MIGRATION-2.4.md",
285
+ "covers": [
286
+ "oauth-attacks"
287
+ ]
288
+ },
289
+ {
290
+ "id": "SEC-020",
291
+ "title": "The default Google connector discarded the OIDC nonce",
292
+ "severity": "high",
293
+ "component": "oidc",
294
+ "release": "2.5.0",
295
+ "issue": "`GoogleConnector.exchangeCode` dropped the nonce when calling the token endpoint, so a correct nonce was never sent and the response could not be checked. This defeated SEC-018 for the most commonly configured provider, which is why the fix in 2.4.0 was not sufficient on its own.",
296
+ "fix": "src/services/connectors/google.ts — the nonce is forwarded on exchange",
297
+ "test": "src/tests/security/oidc/enterprise-sso.test.ts",
298
+ "documentation": "docs/security/enterprise-sso.md",
299
+ "covers": [
300
+ "oauth-attacks"
301
+ ]
302
+ },
303
+ {
304
+ "id": "SEC-021",
305
+ "title": "An unsigned SAML Issuer element was not validated",
306
+ "severity": "medium",
307
+ "component": "saml",
308
+ "release": "2.5.0",
309
+ "issue": "The Issuer was compared only when it fell inside a signed region. An attacker could place an unsigned Issuer naming a trusted IdP alongside a signed assertion from another IdP. Confirmed to lie outside both signed regions, so it was a real gap in the trust decision rather than a theoretical one.",
310
+ "fix": "src/routes/saml.ts — the Issuer is validated against the configured entity id in all cases",
311
+ "test": "src/tests/security/saml/saml-adversarial.test.ts",
312
+ "documentation": "docs/security/enterprise-sso.md",
313
+ "covers": [
314
+ "oauth-attacks"
315
+ ]
316
+ },
317
+ {
318
+ "id": "SEC-022",
319
+ "title": "RelayState verification threw instead of returning false",
320
+ "severity": "medium",
321
+ "component": "saml",
322
+ "release": "2.5.0",
323
+ "issue": "A RelayState that failed its integrity check raised rather than returning a rejection, turning a routine validation failure into a 500 and obscuring the real cause.",
324
+ "fix": "src/routes/saml.ts — verification returns a boolean and never throws",
325
+ "test": "src/tests/security/saml/saml-adversarial.test.ts",
326
+ "documentation": "docs/security/enterprise-sso.md",
327
+ "covers": []
328
+ },
329
+ {
330
+ "id": "SEC-023",
331
+ "title": "A wildcard scope let any API key act as any service account",
332
+ "severity": "critical",
333
+ "component": "api-keys",
334
+ "release": "2.6.0",
335
+ "issue": "The scope registry contained a `service_account` entry that any key carrying could use as a wildcard against every scope check, so a narrowly scoped key behaved as a fully privileged one.",
336
+ "fix": "src/services/scopes.ts — the wildcard is gone; every scope must be named",
337
+ "test": "src/tests/security/api-keys/scopes-and-principals.test.ts",
338
+ "documentation": "docs/security/scopes.md",
339
+ "covers": [
340
+ "api-scope-escalation",
341
+ "privilege-escalation"
342
+ ]
343
+ },
344
+ {
345
+ "id": "SEC-024",
346
+ "title": "Scope enforcement failed open for requests without a key",
347
+ "severity": "high",
348
+ "component": "api-keys",
349
+ "release": "2.6.0",
350
+ "issue": "`requireScopes` returned early when `request.apiKeyId` was absent, so any authenticated path that reached a scoped route without going through key authentication skipped the scope check entirely.",
351
+ "fix": "src/services/scopes.ts — enforcement fails closed and treats a missing key as no authority",
352
+ "test": "src/tests/security/api-keys/scopes-and-principals.test.ts",
353
+ "documentation": "docs/security/scopes.md",
354
+ "covers": [
355
+ "api-scope-escalation"
356
+ ]
357
+ },
358
+ {
359
+ "id": "SEC-025",
360
+ "title": "Human-only operations were available to machine principals",
361
+ "severity": "high",
362
+ "component": "service-accounts",
363
+ "release": "2.6.0",
364
+ "issue": "Profile and MFA management accepted any authenticated principal, so a service account key could read and change a person's profile and second-factor settings.",
365
+ "fix": "src/plugins/machinePrincipal.ts — requireHumanPrincipal() rejects machine principals, placed after authentication so it can see them",
366
+ "test": "src/tests/security/api-keys/scopes-and-principals.test.ts",
367
+ "documentation": "docs/security/scopes.md",
368
+ "covers": [
369
+ "api-scope-escalation",
370
+ "privilege-escalation"
371
+ ]
372
+ },
373
+ {
374
+ "id": "SEC-026",
375
+ "title": "Configuration redaction used a denylist that missed half the secrets",
376
+ "severity": "high",
377
+ "component": "configuration",
378
+ "release": "2.7.0",
379
+ "issue": "Redaction matched a list of known secret-looking key names. Measuring it against the real configuration surface, 12 of 24 secret-shaped keys were returned unredacted, including the signing keys — so the owner-only configuration endpoint disclosed the material used to sign tokens.",
380
+ "fix": "src/services/configuration/profiles.ts — an allowlist of exposable keys replaces the denylist",
381
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
382
+ "documentation": "docs/security/configuration.md",
383
+ "covers": [
384
+ "secret-disclosure"
385
+ ]
386
+ },
387
+ {
388
+ "id": "SEC-027",
389
+ "title": "An empty CORS allowlist permitted every origin with credentials",
390
+ "severity": "high",
391
+ "component": "configuration",
392
+ "release": "2.7.0",
393
+ "issue": "An unset or empty `ALLOWED_ORIGINS` was treated as 'allow all', combined with credentialed requests. A misconfiguration therefore produced a wildcard CORS policy that browsers enforce, rather than a closed one.",
394
+ "fix": "src/services/trustedProxies.ts — isOriginAllowed() fails closed on an empty allowlist, shared with the tests rather than duplicated",
395
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
396
+ "documentation": "docs/security/configuration.md",
397
+ "covers": [
398
+ "secret-disclosure"
399
+ ]
400
+ },
401
+ {
402
+ "id": "SEC-028",
403
+ "title": "The setup server allowed any origin with credentials",
404
+ "severity": "high",
405
+ "component": "configuration",
406
+ "release": "2.7.0",
407
+ "issue": "The first-run setup server was configured with `origin: true` and credentials enabled, so any page in the operator's browser could call it during setup and complete provisioning.",
408
+ "fix": "src/routes/setup.ts — the setup origin is restricted to the server's own address",
409
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
410
+ "documentation": "docs/security/configuration.md",
411
+ "covers": [
412
+ "secret-disclosure",
413
+ "privilege-escalation"
414
+ ]
415
+ },
416
+ {
417
+ "id": "SEC-029",
418
+ "title": "The setup token was written to stdout",
419
+ "severity": "high",
420
+ "component": "configuration",
421
+ "release": "2.7.0",
422
+ "issue": "The token granting initial owner access was printed to the process log, so it reached log aggregation, container stdout capture and any log shipper — the credential intended to bootstrap trust was the one most widely distributed.",
423
+ "fix": "src/services/setup/token.ts — the token is delivered once, and printing requires an explicit opt-in",
424
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
425
+ "documentation": "docs/security/configuration.md",
426
+ "covers": [
427
+ "secret-disclosure"
428
+ ]
429
+ },
430
+ {
431
+ "id": "SEC-030",
432
+ "title": "Webhook signing secrets were stored in plaintext",
433
+ "severity": "medium",
434
+ "component": "secrets",
435
+ "release": "2.7.0",
436
+ "issue": "Webhook secrets were stored as issued, so a database read, a backup or an admin query returned material that lets an attacker forge delivery attempts signed as this installation.",
437
+ "fix": "src/services/webhooks.ts — AES-256-GCM at rest, with legacy plaintext still readable",
438
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
439
+ "documentation": "docs/security/configuration.md",
440
+ "covers": [
441
+ "secret-disclosure"
442
+ ]
443
+ },
444
+ {
445
+ "id": "SEC-031",
446
+ "title": "Session cookies were not Secure by default in production",
447
+ "severity": "high",
448
+ "component": "configuration",
449
+ "release": "2.7.0",
450
+ "issue": "`COOKIE_SECURE` defaulted to false, so a production deployment that did not set it explicitly issued session cookies over plaintext, exposing them to interception on any non-TLS path.",
451
+ "fix": "src/plugins/auth.ts — Secure defaults to true when NODE_ENV is production",
452
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
453
+ "documentation": "docs/security/configuration.md",
454
+ "covers": [
455
+ "secret-disclosure",
456
+ "session-persistence"
457
+ ]
458
+ },
459
+ {
460
+ "id": "SEC-032",
461
+ "title": "The setup server listened on all interfaces",
462
+ "severity": "high",
463
+ "component": "configuration",
464
+ "release": "2.7.0",
465
+ "issue": "The setup server bound 0.0.0.0, so during first run the unauthenticated provisioning endpoint was reachable from the network rather than only from the operator's machine.",
466
+ "fix": "src/routes/setup.ts — loopback by default, overridable, and warns when exposed",
467
+ "test": "src/tests/security/configuration/secrets-config.test.ts",
468
+ "documentation": "docs/security/configuration.md",
469
+ "covers": [
470
+ "privilege-escalation"
471
+ ]
472
+ },
473
+ {
474
+ "id": "SEC-033",
475
+ "title": "A Redis outage removed rate limiting entirely",
476
+ "severity": "high",
477
+ "component": "rate-limiting",
478
+ "release": "2.8.0",
479
+ "issue": "Every limiter returned 'allowed' when Redis was unavailable, so during an outage login, MFA verification, OTP verification and the OAuth token exchange had no limit at all. An outage is precisely when unlimited attempts are worth having, because a burst of guessing no longer looks like a burst.",
480
+ "fix": "src/services/localRateLimit.ts — a bounded in-process budget, used only when Redis is unavailable",
481
+ "test": "src/tests/security/rate-limiting/abuse-prevention.test.ts",
482
+ "documentation": "docs/security/rate-limiting.md",
483
+ "covers": []
484
+ },
485
+ {
486
+ "id": "SEC-034",
487
+ "title": "MFA verification shared one budget across every user behind an address",
488
+ "severity": "high",
489
+ "component": "rate-limiting",
490
+ "release": "2.8.0",
491
+ "issue": "The rate-limit key included the submitted email, which the verification endpoint does not carry, so all 20 attempts were shared across every user at that address. An office behind a single NAT could have every legitimate second-factor login locked out by ordinary traffic.",
492
+ "fix": "src/routes/auth.ts — the key includes the challenge, which identifies one login attempt",
493
+ "test": "src/tests/security/rate-limiting/abuse-prevention.test.ts",
494
+ "documentation": "docs/security/rate-limiting.md",
495
+ "covers": [
496
+ "mfa-bypass"
497
+ ]
498
+ },
499
+ {
500
+ "id": "SEC-035",
501
+ "title": "Credential spraying was unbounded",
502
+ "severity": "medium",
503
+ "component": "rate-limiting",
504
+ "release": "2.8.0",
505
+ "issue": "The login budget was keyed on address and submitted address together, so it stopped repeated guesses at one account and did nothing about an attacker varying the address across a thousand accounts from one host.",
506
+ "fix": "src/routes/auth.ts — a second, address-keyed budget bounds spraying independently",
507
+ "test": "src/tests/security/rate-limiting/abuse-prevention.test.ts",
508
+ "documentation": "docs/security/rate-limiting.md",
509
+ "covers": []
510
+ },
511
+ {
512
+ "id": "SEC-036",
513
+ "title": "A refused request left no record",
514
+ "severity": "medium",
515
+ "component": "audit",
516
+ "release": "2.8.0",
517
+ "issue": "A rate-limit trip produced a 429 and nothing else, so sustained guessing at login or MFA verification was invisible except in aggregate. The requests that most warranted attention were the only ones absent from the log.",
518
+ "fix": "src/plugins/rateLimit.ts — rate_limit_triggered records the endpoint, address and which limiter decided",
519
+ "test": "src/tests/security/rate-limiting/abuse-prevention.test.ts",
520
+ "documentation": "docs/security/rate-limiting.md",
521
+ "covers": []
522
+ },
523
+ {
524
+ "id": "SEC-037",
525
+ "title": "Failed logins were never audited",
526
+ "severity": "medium",
527
+ "component": "audit",
528
+ "release": "2.8.0",
529
+ "issue": "user_login_failed existed in the event vocabulary and was never emitted, on either login route. Password guessing left no record.",
530
+ "fix": "src/routes/auth.ts — emitted on both routes, with no user id since the submitted address may match no account",
531
+ "test": "src/tests/security/authentication/login-abuse.test.ts",
532
+ "documentation": "docs/security/audit.md",
533
+ "covers": []
534
+ },
535
+ {
536
+ "id": "SEC-038",
537
+ "title": "A replayed refresh token was indistinguishable from an unknown one",
538
+ "severity": "medium",
539
+ "component": "tokens",
540
+ "release": "2.8.0",
541
+ "issue": "Rotation consumes a refresh token, so a second presentation failed exactly as a token that never existed would. A leaked token used twice was therefore treated as a typo, and the account's other credentials stayed valid.",
542
+ "fix": "src/services/refreshTokenState.ts — a spent token is identified and the account's credentials are revoked",
543
+ "test": "src/tests/security/authentication/login-abuse.test.ts",
544
+ "documentation": "docs/security/audit.md",
545
+ "covers": [
546
+ "token-replay",
547
+ "session-persistence"
548
+ ]
549
+ },
550
+ {
551
+ "id": "SEC-039",
552
+ "title": "API key creation had no rate limit",
553
+ "severity": "medium",
554
+ "component": "api-keys",
555
+ "release": "2.8.0",
556
+ "issue": "Minting a credential is an authentication event but was unlimited, so a leaked session could enumerate a batch of keys.",
557
+ "fix": "src/routes/apiKeys.ts — bounded like any other authentication endpoint",
558
+ "test": "src/tests/security/rate-limiting/abuse-prevention.test.ts",
559
+ "documentation": "docs/security/rate-limiting.md",
560
+ "covers": []
561
+ },
562
+ {
563
+ "id": "SEC-040",
564
+ "title": "A stale build output kept deleted security tests running",
565
+ "severity": "medium",
566
+ "component": "testing",
567
+ "release": "2.9.0",
568
+ "issue": "`tsc` does not remove output for sources that were renamed or deleted, so a moved suite ran twice under two paths and a deleted suite kept running. Test discovery therefore did not reflect the source tree, and a security test could be removed from the repository while continuing to pass in CI — or the reverse, a suite silently dropped out of the run.",
569
+ "fix": "package.json — the build cleans dist before compiling",
570
+ "test": "scripts/verify-release-metadata.mjs",
571
+ "documentation": "docs/security/registry.md",
572
+ "covers": []
573
+ },
574
+ {
575
+ "id": "SEC-041",
576
+ "title": "A test fixture path was correct at only one directory depth",
577
+ "severity": "low",
578
+ "component": "testing",
579
+ "release": "2.9.0",
580
+ "issue": "A suite reached its fixture by counting parent directories, which resolved correctly from `src` and not from the compiled output in `dist`. The suite passed and then failed to load the moment it was moved, which is a poor way to discover that a path is fragile.",
581
+ "fix": "src/tests/helpers/paths.ts — paths are anchored on the nearest package.json",
582
+ "test": "src/tests/security/authorization/authorization.test.ts",
583
+ "documentation": "docs/security/registry.md",
584
+ "covers": []
585
+ },
586
+ {
587
+ "id": "SEC-042",
588
+ "title": "The test runner's directory glob was shallower than the test tree",
589
+ "severity": "high",
590
+ "component": "testing",
591
+ "release": "2.9.0",
592
+ "issue": "`node --test` expands `**` as a single directory level rather than as globstar, so the discovery patterns stopped matching as soon as the security suites gained a directory level. 48 security tests — the entire authorization suite — stopped running, and the suite still reported success because the remaining files passed. A green run was reporting on less than the repository contained.",
593
+ "fix": "package.json — one pattern per directory depth, with no overlap",
594
+ "test": "src/tests/security/registry.test.ts",
595
+ "documentation": "docs/security/registry.md",
596
+ "covers": []
597
+ },
598
+ {
599
+ "id": "SEC-045",
600
+ "title": "The audit log export did not neutralise spreadsheet formula injection",
601
+ "severity": "medium",
602
+ "component": "audit",
603
+ "release": "2.9.0",
604
+ "issue": "The CSV export quoted values containing a delimiter or a quote, but did not neutralise a value beginning with `=`, `+`, `-` or `@`. Several of the exported columns are attacker-supplied — the user agent above all — and a spreadsheet evaluates such a cell as a formula when the file is opened. An audit export is precisely the file an operator opens in a spreadsheet, so that is the expected consumer rather than an edge case. Found by Semgrep's `direct-response-write` rule during the triage for this release; the rule's own finding was a false positive, but the code it pointed at was not.",
605
+ "fix": "src/routes/admin/platform.ts — a value with a formula prefix in first position is prefixed with an apostrophe before quoting",
606
+ "test": "src/tests/security/audit-export.test.ts",
607
+ "documentation": "docs/security/audit.md",
608
+ "covers": []
609
+ },
610
+ {
611
+ "id": "SEC-046",
612
+ "title": "Every service-account request produced no audit record at all",
613
+ "severity": "high",
614
+ "component": "audit",
615
+ "release": "2.9.0",
616
+ "issue": "A machine principal is represented in memory by a user object whose id is the sentinel `sa:<uuid>`, so that routes expecting `request.user` keep working without a matching user row. The audit subscriber passed that sentinel straight into `audit_log.user_id`, which is a uuid column. Postgres rejected the insert, the subscriber's catch logged `failed to write event` and the audit record was lost. The request itself succeeded, so nothing failed visibly, and the absence of a record looked exactly like a request that never happened. The practical effect was that every request authenticated by an API key or an mTLS service account left no audit trail — the privileged, non-human path, which is the one an attacker would most want to use quietly. Surfaced by a test whose fixture failed in CI and not locally, because CI's request ids and principal resolution differed enough for the bad insert to occur there.",
617
+ "fix": "src/services/events/subscribers/auditLog.ts — the sentinel is stripped, user_id is left null, and the service account is recorded in metadata",
618
+ "test": "src/tests/security/service-accounts/audit-attribution.test.ts",
619
+ "documentation": "docs/security/audit.md",
620
+ "covers": []
621
+ }
622
+ ],
623
+ "withdrawn": [
624
+ {
625
+ "id": "SEC-043",
626
+ "withdrawn": "2026-09-27",
627
+ "reason": "Written during the 2.9.0 release with a fix at a path that does not exist and an issue description the test does not cover. src/tests/security/saml/saml-validator.test.ts exercises the *valid* signed-response path; it does not test a missing audience or issuer requirement. There is no evidenced defect behind this entry, so it was not a finding. The suite is recorded under `coverage` instead.",
628
+ "replacedBy": "src/tests/security/saml/saml-validator.test.ts (coverage)"
629
+ },
630
+ {
631
+ "id": "SEC-044",
632
+ "withdrawn": "2026-09-27",
633
+ "reason": "Written during the 2.9.0 release with a fix at a path that does not exist and an issue description the test does not cover. src/tests/security/saml/sso-endpoint.test.ts tests isPrivateAddress, a guard on operator-supplied endpoint addresses; it does not test an unregistered host alias. No defect was evidenced behind this entry. The suite is recorded under `coverage` instead.",
634
+ "replacedBy": "src/tests/security/saml/sso-endpoint.test.ts (coverage)"
635
+ }
636
+ ],
637
+ "coverage": [
638
+ {
639
+ "test": "src/tests/security/saml/saml-validator.test.ts",
640
+ "covers": "A valid signed SAML response is accepted by the registered schema validator, and validateSamlSemantics accepts it.",
641
+ "why": "Positive-path coverage for the SAML validator. Not tied to a numbered finding: it asserts that a correct response is accepted, not that a previously-missing check was added."
642
+ },
643
+ {
644
+ "test": "src/tests/security/saml/sso-endpoint.test.ts",
645
+ "covers": "isPrivateAddress classifies loopback, private, link-local, carrier-grade NAT, benchmarking and both spellings of IPv4-mapped IPv6 as non-public.",
646
+ "why": "Coverage for the endpoint address policy in src/services/ssoEndpointPolicy.ts, which keeps operator-supplied SSO endpoint URLs from pointing at internal addresses. No defect was evidenced behind it, so it is not a numbered finding."
647
+ }
648
+ ]
649
+ }