CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
admin-security.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| ba9e143 | 1 | /** |
| 2 | * Admin security dashboard — SOC 2 evidence surface. | |
| 3 | * | |
| 4 | * GET /admin/security — recent failed logins, locked accounts, admin actions, | |
| 5 | * MFA status, active session count, account deletions | |
| 6 | * GET /admin/soc2 — static SOC 2 readiness checklist | |
| 7 | * | |
| 8 | * Both routes are gated by `isSiteAdmin`. | |
| 9 | */ | |
| 10 | ||
| 11 | import { Hono } from "hono"; | |
| 7b0a81b | 12 | import type { Context } from "hono"; |
| ba9e143 | 13 | import { and, count, desc, eq, gte, sql } from "drizzle-orm"; |
| 14 | import { db } from "../db"; | |
| 15 | import { | |
| 16 | auditLog, | |
| 17 | loginAttempts, | |
| 18 | sessions, | |
| 19 | userTotp, | |
| 20 | users, | |
| 21 | } from "../db/schema"; | |
| 22 | import { isSiteAdmin } from "../lib/admin"; | |
| 23 | import { softAuth } from "../middleware/auth"; | |
| 24 | import type { AuthEnv } from "../middleware/auth"; | |
| 25 | import { Layout } from "../views/layout"; | |
| 26 | ||
| 27 | const adminSecurity = new Hono<AuthEnv>(); | |
| 28 | adminSecurity.use("*", softAuth); | |
| 29 | ||
| 30 | // ── Auth guard ─────────────────────────────────────────────────────────────── | |
| 7b0a81b | 31 | // SECURITY: "/admin/security*" (no slash before the *) does not match "/admin/security" |
| 32 | // itself in this Hono version -- confirmed via a standalone reproduction, and | |
| 33 | // live: an unauthenticated GET to both pages returned 200 in production. This | |
| 34 | // gate was NEVER running. Registering on the exact path AND the "/*" sub-path | |
| 35 | // form is the verified-correct pattern (see the sibling fix in org-secrets.tsx). | |
| 36 | const securityGuard = async ( | |
| 37 | c: Context<AuthEnv>, | |
| 38 | next: () => Promise<void> | |
| 39 | ) => { | |
| ba9e143 | 40 | const user = c.get("user"); |
| 41 | if (!user || !(await isSiteAdmin(user.id))) { | |
| 7b0a81b | 42 | return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`); |
| ba9e143 | 43 | } |
| 44 | return next(); | |
| 7b0a81b | 45 | }; |
| 46 | adminSecurity.use("/admin/security", securityGuard); | |
| 47 | adminSecurity.use("/admin/security/*", securityGuard); | |
| 48 | adminSecurity.use("/admin/soc2", securityGuard); | |
| 49 | adminSecurity.use("/admin/soc2/*", securityGuard); | |
| ba9e143 | 50 | |
| 51 | // ── Scoped CSS ─────────────────────────────────────────────────────────────── | |
| 52 | const securityStyles = ` | |
| 53 | .sec-page { max-width: 1200px; margin: 0 auto; padding: var(--space-6) var(--space-4); } | |
| 54 | ||
| 55 | .sec-hero { | |
| 56 | position: relative; margin-bottom: var(--space-6); | |
| 57 | padding: var(--space-5) var(--space-6); | |
| 58 | background: var(--bg-elevated); border: 1px solid var(--border); | |
| 59 | border-radius: 16px; overflow: hidden; | |
| 60 | } | |
| 61 | .sec-hero::before { | |
| 62 | content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; | |
| 63 | background: linear-gradient(90deg, transparent 0%, #f87171 30%, #fb923c 70%, transparent 100%); | |
| 64 | opacity: 0.7; pointer-events: none; | |
| 65 | } | |
| 66 | .sec-hero h1 { font-size: 1.5rem; font-weight: 800; margin: 0 0 4px; } | |
| 67 | .sec-hero p { color: var(--text-muted); margin: 0; font-size: 14px; } | |
| 68 | .sec-hero-nav { margin-top: 16px; display: flex; gap: 8px; flex-wrap: wrap; } | |
| 69 | .sec-hero-nav a { font-size: 13px; } | |
| 70 | ||
| 71 | .sec-stat-grid { | |
| 72 | display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); | |
| 73 | gap: 12px; margin-bottom: var(--space-6); | |
| 74 | } | |
| 75 | .sec-stat { | |
| 76 | background: var(--bg-elevated); border: 1px solid var(--border); | |
| 77 | border-radius: 12px; padding: var(--space-4); | |
| 78 | } | |
| 79 | .sec-stat-label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; } | |
| 80 | .sec-stat-value { font-size: 2rem; font-weight: 800; } | |
| 81 | .sec-stat-value.danger { color: #f87171; } | |
| 82 | .sec-stat-value.warning { color: #fb923c; } | |
| 83 | .sec-stat-value.ok { color: #34d399; } | |
| 84 | ||
| 85 | .sec-section { margin-bottom: var(--space-6); } | |
| 86 | .sec-section h2 { font-size: 16px; font-weight: 700; margin: 0 0 12px; } | |
| 87 | ||
| 88 | .sec-table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 89 | .sec-table th { text-align: left; padding: 8px 12px; color: var(--text-muted); font-weight: 600; border-bottom: 1px solid var(--border); } | |
| 90 | .sec-table td { padding: 8px 12px; border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06)); vertical-align: middle; } | |
| 91 | .sec-table tr:last-child td { border-bottom: none; } | |
| 92 | .sec-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } | |
| 93 | ||
| 94 | .sec-badge { | |
| 95 | display: inline-block; padding: 2px 8px; border-radius: 100px; | |
| 96 | font-size: 11px; font-weight: 600; | |
| 97 | } | |
| 98 | .sec-badge.red { background: #f871711a; color: #f87171; } | |
| 99 | .sec-badge.orange { background: #fb923c1a; color: #fb923c; } | |
| 100 | .sec-badge.green { background: #34d3991a; color: #34d399; } | |
| 101 | ||
| 102 | /* SOC 2 checklist */ | |
| 103 | .soc2-page { max-width: 900px; margin: 0 auto; padding: var(--space-6) var(--space-4); } | |
| 104 | .soc2-hero { | |
| 105 | position: relative; margin-bottom: var(--space-6); | |
| 106 | padding: var(--space-5) var(--space-6); | |
| 107 | background: var(--bg-elevated); border: 1px solid var(--border); | |
| 108 | border-radius: 16px; overflow: hidden; | |
| 109 | } | |
| 110 | .soc2-hero::before { | |
| 111 | content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; | |
| 6fd5915 | 112 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| ba9e143 | 113 | opacity: 0.7; pointer-events: none; |
| 114 | } | |
| 115 | .soc2-hero h1 { font-size: 1.5rem; font-weight: 800; margin: 0 0 4px; } | |
| 116 | .soc2-hero p { color: var(--text-muted); margin: 0; font-size: 14px; } | |
| 117 | .soc2-category { margin-bottom: var(--space-5); } | |
| 118 | .soc2-category h2 { font-size: 15px; font-weight: 700; margin: 0 0 10px; } | |
| 119 | .soc2-row { | |
| 120 | display: flex; align-items: flex-start; gap: 12px; | |
| 121 | padding: 10px 0; border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.06)); | |
| 122 | font-size: 13.5px; | |
| 123 | } | |
| 124 | .soc2-row:last-child { border-bottom: none; } | |
| 125 | .soc2-icon { font-size: 16px; flex-shrink: 0; width: 24px; text-align: center; margin-top: 1px; } | |
| 126 | .soc2-text { flex: 1; } | |
| 127 | .soc2-label { font-weight: 600; } | |
| 128 | .soc2-desc { color: var(--text-muted); margin-top: 2px; font-size: 12.5px; } | |
| 129 | .soc2-status-ok { color: #34d399; } | |
| 130 | .soc2-status-warn { color: #fb923c; } | |
| 131 | .soc2-section-card { | |
| 132 | background: var(--bg-elevated); border: 1px solid var(--border); | |
| 133 | border-radius: 12px; padding: var(--space-4) var(--space-5); | |
| 134 | margin-bottom: var(--space-4); | |
| 135 | } | |
| 136 | `; | |
| 137 | ||
| 138 | // ── GET /admin/security ────────────────────────────────────────────────────── | |
| 139 | adminSecurity.get("/admin/security", async (c) => { | |
| 140 | const user = c.get("user")!; | |
| 141 | const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); | |
| 142 | const since1h = new Date(Date.now() - 60 * 60 * 1000); | |
| 143 | ||
| 144 | // Recent failed logins in last 24h grouped by email + IP. | |
| 145 | const failedLogins = await db | |
| 146 | .select({ | |
| 147 | email: loginAttempts.email, | |
| 148 | ip: loginAttempts.ip, | |
| 149 | attempts: sql<number>`count(*)::int`, | |
| 150 | }) | |
| 151 | .from(loginAttempts) | |
| 152 | .where( | |
| 153 | and( | |
| 154 | eq(loginAttempts.success, false), | |
| 155 | gte(loginAttempts.createdAt, since24h) | |
| 156 | ) | |
| 157 | ) | |
| 158 | .groupBy(loginAttempts.email, loginAttempts.ip) | |
| 159 | .orderBy(desc(sql`count(*)`)) | |
| 160 | .limit(20); | |
| 161 | ||
| 162 | // Locked accounts: email addresses with >= 10 failures in last 1h. | |
| 163 | const lockedAccounts = await db | |
| 164 | .select({ | |
| 165 | email: loginAttempts.email, | |
| 166 | attempts: sql<number>`count(*)::int`, | |
| 167 | }) | |
| 168 | .from(loginAttempts) | |
| 169 | .where( | |
| 170 | and( | |
| 171 | eq(loginAttempts.success, false), | |
| 172 | gte(loginAttempts.createdAt, since1h) | |
| 173 | ) | |
| 174 | ) | |
| 175 | .groupBy(loginAttempts.email) | |
| 176 | .having(sql`count(*) >= 10`); | |
| 177 | ||
| 178 | // Recent admin audit actions in last 24h. | |
| 179 | const adminActions = await db | |
| 180 | .select({ | |
| 181 | id: auditLog.id, | |
| 182 | action: auditLog.action, | |
| 183 | ip: auditLog.ip, | |
| 184 | createdAt: auditLog.createdAt, | |
| 185 | username: users.username, | |
| 186 | }) | |
| 187 | .from(auditLog) | |
| 188 | .leftJoin(users, eq(auditLog.userId, users.id)) | |
| 189 | .where(gte(auditLog.createdAt, since24h)) | |
| 190 | .orderBy(desc(auditLog.createdAt)) | |
| 191 | .limit(30); | |
| 192 | ||
| 193 | // Active session count. | |
| 194 | const [sessionRow] = await db | |
| 195 | .select({ cnt: sql<number>`count(*)::int` }) | |
| 196 | .from(sessions) | |
| 197 | .where(gte(sessions.expiresAt, new Date())); | |
| 198 | ||
| 199 | // Users with MFA configured (TOTP enabled). | |
| 200 | const [mfaRow] = await db | |
| 201 | .select({ cnt: sql<number>`count(*)::int` }) | |
| 202 | .from(userTotp) | |
| 203 | .where(sql`enabled_at IS NOT NULL`); | |
| 204 | ||
| 205 | // Total users. | |
| 206 | const [usersRow] = await db | |
| 207 | .select({ cnt: sql<number>`count(*)::int` }) | |
| 208 | .from(users) | |
| 209 | .where(sql`deleted_at IS NULL`); | |
| 210 | ||
| 211 | // Recent account deletions in audit log. | |
| 212 | const accountDeletions = await db | |
| 213 | .select({ | |
| 214 | id: auditLog.id, | |
| 215 | action: auditLog.action, | |
| 216 | ip: auditLog.ip, | |
| 217 | createdAt: auditLog.createdAt, | |
| 218 | username: users.username, | |
| 219 | }) | |
| 220 | .from(auditLog) | |
| 221 | .leftJoin(users, eq(auditLog.userId, users.id)) | |
| 222 | .where( | |
| 223 | and( | |
| 224 | eq(auditLog.action, "account.delete"), | |
| 225 | gte(auditLog.createdAt, since24h) | |
| 226 | ) | |
| 227 | ) | |
| 228 | .orderBy(desc(auditLog.createdAt)) | |
| 229 | .limit(10); | |
| 230 | ||
| 231 | const totalUsers = usersRow?.cnt ?? 0; | |
| 232 | const mfaUsers = mfaRow?.cnt ?? 0; | |
| 233 | const noMfaUsers = Math.max(0, totalUsers - mfaUsers); | |
| 234 | const activeSessions = sessionRow?.cnt ?? 0; | |
| 235 | const failedCount = failedLogins.reduce((s, r) => s + r.attempts, 0); | |
| 236 | const lockedCount = lockedAccounts.length; | |
| 237 | ||
| 238 | return c.html( | |
| 239 | <Layout title="Security dashboard" user={user}> | |
| 240 | <style dangerouslySetInnerHTML={{ __html: securityStyles }} /> | |
| 241 | <div class="sec-page"> | |
| 242 | <div class="sec-hero"> | |
| 243 | <h1>Security dashboard</h1> | |
| 244 | <p> | |
| 245 | Real-time view of authentication events, account lockouts, and admin | |
| 246 | actions. Data is used as SOC 2 evidence for CC6.1 and CC7.2. | |
| 247 | </p> | |
| 248 | <div class="sec-hero-nav"> | |
| 249 | <a href="/admin/security" class="btn btn-sm active">Security</a> | |
| 250 | <a href="/admin/soc2" class="btn btn-sm">SOC 2 Checklist</a> | |
| 251 | <a href="/admin" class="btn btn-sm">Admin home</a> | |
| 252 | </div> | |
| 253 | </div> | |
| 254 | ||
| 255 | {/* ── Stat cards ── */} | |
| 256 | <div class="sec-stat-grid"> | |
| 257 | <div class="sec-stat"> | |
| 258 | <div class="sec-stat-label">Failed logins (24h)</div> | |
| 259 | <div class={`sec-stat-value ${failedCount > 50 ? "danger" : failedCount > 10 ? "warning" : "ok"}`}> | |
| 260 | {failedCount} | |
| 261 | </div> | |
| 262 | </div> | |
| 263 | <div class="sec-stat"> | |
| 264 | <div class="sec-stat-label">Locked accounts (1h window)</div> | |
| 265 | <div class={`sec-stat-value ${lockedCount > 0 ? "danger" : "ok"}`}> | |
| 266 | {lockedCount} | |
| 267 | </div> | |
| 268 | </div> | |
| 269 | <div class="sec-stat"> | |
| 270 | <div class="sec-stat-label">Active sessions</div> | |
| 271 | <div class="sec-stat-value">{activeSessions}</div> | |
| 272 | </div> | |
| 273 | <div class="sec-stat"> | |
| 274 | <div class="sec-stat-label">Users without MFA</div> | |
| 275 | <div class={`sec-stat-value ${noMfaUsers > 0 ? "warning" : "ok"}`}> | |
| 276 | {noMfaUsers} | |
| 277 | </div> | |
| 278 | </div> | |
| 279 | <div class="sec-stat"> | |
| 280 | <div class="sec-stat-label">Total users</div> | |
| 281 | <div class="sec-stat-value">{totalUsers}</div> | |
| 282 | </div> | |
| 283 | <div class="sec-stat"> | |
| 284 | <div class="sec-stat-label">MFA-enabled users</div> | |
| 285 | <div class="sec-stat-value ok">{mfaUsers}</div> | |
| 286 | </div> | |
| 287 | </div> | |
| 288 | ||
| 289 | {/* ── Locked accounts ── */} | |
| 290 | {lockedAccounts.length > 0 && ( | |
| 291 | <div class="sec-section"> | |
| 292 | <h2>Locked accounts (10+ failures in last hour)</h2> | |
| 293 | <div class="sec-card"> | |
| 294 | <table class="sec-table"> | |
| 295 | <thead> | |
| 296 | <tr> | |
| 297 | <th>Email</th> | |
| 298 | <th>Failed attempts (1h)</th> | |
| 299 | <th>Status</th> | |
| 300 | </tr> | |
| 301 | </thead> | |
| 302 | <tbody> | |
| 303 | {lockedAccounts.map((row) => ( | |
| 304 | <tr key={row.email}> | |
| 305 | <td>{row.email}</td> | |
| 306 | <td>{row.attempts}</td> | |
| 307 | <td> | |
| 308 | <span class="sec-badge red">Locked</span> | |
| 309 | </td> | |
| 310 | </tr> | |
| 311 | ))} | |
| 312 | </tbody> | |
| 313 | </table> | |
| 314 | </div> | |
| 315 | </div> | |
| 316 | )} | |
| 317 | ||
| 318 | {/* ── Recent failed logins ── */} | |
| 319 | <div class="sec-section"> | |
| 320 | <h2>Recent failed login attempts (last 24h)</h2> | |
| 321 | <div class="sec-card"> | |
| 322 | {failedLogins.length === 0 ? ( | |
| 323 | <div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;"> | |
| 324 | No failed logins in the last 24 hours. | |
| 325 | </div> | |
| 326 | ) : ( | |
| 327 | <table class="sec-table"> | |
| 328 | <thead> | |
| 329 | <tr> | |
| 330 | <th>Email</th> | |
| 331 | <th>IP address</th> | |
| 332 | <th>Attempts</th> | |
| 333 | <th>Risk</th> | |
| 334 | </tr> | |
| 335 | </thead> | |
| 336 | <tbody> | |
| 337 | {failedLogins.map((row) => ( | |
| 338 | <tr key={`${row.email}-${row.ip}`}> | |
| 339 | <td>{row.email}</td> | |
| 340 | <td> | |
| 341 | <code style="font-size: 12px;">{row.ip}</code> | |
| 342 | </td> | |
| 343 | <td>{row.attempts}</td> | |
| 344 | <td> | |
| 345 | <span | |
| 346 | class={`sec-badge ${row.attempts >= 10 ? "red" : row.attempts >= 5 ? "orange" : "green"}`} | |
| 347 | > | |
| 348 | {row.attempts >= 10 | |
| 349 | ? "High" | |
| 350 | : row.attempts >= 5 | |
| 351 | ? "Medium" | |
| 352 | : "Low"} | |
| 353 | </span> | |
| 354 | </td> | |
| 355 | </tr> | |
| 356 | ))} | |
| 357 | </tbody> | |
| 358 | </table> | |
| 359 | )} | |
| 360 | </div> | |
| 361 | </div> | |
| 362 | ||
| 363 | {/* ── Recent admin actions ── */} | |
| 364 | <div class="sec-section"> | |
| 365 | <h2>Recent audit log entries (last 24h)</h2> | |
| 366 | <div class="sec-card"> | |
| 367 | {adminActions.length === 0 ? ( | |
| 368 | <div style="padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;"> | |
| 369 | No audit log entries in the last 24 hours. | |
| 370 | </div> | |
| 371 | ) : ( | |
| 372 | <table class="sec-table"> | |
| 373 | <thead> | |
| 374 | <tr> | |
| 375 | <th>Time</th> | |
| 376 | <th>User</th> | |
| 377 | <th>Action</th> | |
| 378 | <th>IP</th> | |
| 379 | </tr> | |
| 380 | </thead> | |
| 381 | <tbody> | |
| 382 | {adminActions.map((row) => ( | |
| 383 | <tr key={row.id}> | |
| 384 | <td style="white-space: nowrap; color: var(--text-muted);"> | |
| 385 | {new Date(row.createdAt).toLocaleString("en-US", { | |
| 386 | month: "short", | |
| 387 | day: "numeric", | |
| 388 | hour: "2-digit", | |
| 389 | minute: "2-digit", | |
| 390 | })} | |
| 391 | </td> | |
| 392 | <td>{row.username ?? "(system)"}</td> | |
| 393 | <td> | |
| 394 | <code style="font-size: 12px;">{row.action}</code> | |
| 395 | </td> | |
| 396 | <td style="color: var(--text-muted);"> | |
| 397 | <code style="font-size: 12px;">{row.ip ?? "-"}</code> | |
| 398 | </td> | |
| 399 | </tr> | |
| 400 | ))} | |
| 401 | </tbody> | |
| 402 | </table> | |
| 403 | )} | |
| 404 | </div> | |
| 405 | </div> | |
| 406 | ||
| 407 | {/* ── Account deletions ── */} | |
| 408 | {accountDeletions.length > 0 && ( | |
| 409 | <div class="sec-section"> | |
| 410 | <h2>Recent account deletions (last 24h)</h2> | |
| 411 | <div class="sec-card"> | |
| 412 | <table class="sec-table"> | |
| 413 | <thead> | |
| 414 | <tr> | |
| 415 | <th>Time</th> | |
| 416 | <th>User</th> | |
| 417 | <th>IP</th> | |
| 418 | </tr> | |
| 419 | </thead> | |
| 420 | <tbody> | |
| 421 | {accountDeletions.map((row) => ( | |
| 422 | <tr key={row.id}> | |
| 423 | <td style="white-space: nowrap; color: var(--text-muted);"> | |
| 424 | {new Date(row.createdAt).toLocaleString("en-US", { | |
| 425 | month: "short", | |
| 426 | day: "numeric", | |
| 427 | hour: "2-digit", | |
| 428 | minute: "2-digit", | |
| 429 | })} | |
| 430 | </td> | |
| 431 | <td>{row.username ?? "-"}</td> | |
| 432 | <td style="color: var(--text-muted);"> | |
| 433 | <code style="font-size: 12px;">{row.ip ?? "-"}</code> | |
| 434 | </td> | |
| 435 | </tr> | |
| 436 | ))} | |
| 437 | </tbody> | |
| 438 | </table> | |
| 439 | </div> | |
| 440 | </div> | |
| 441 | )} | |
| 442 | ||
| 443 | <p style="font-size: 12px; color: var(--text-muted); margin-top: var(--space-4);"> | |
| 444 | All times are server-local. Full audit trail available at{" "} | |
| 445 | <a href="/audit">Audit log</a>. For SOC 2 mapping see{" "} | |
| 446 | <a href="/admin/soc2">SOC 2 Checklist</a>. | |
| 447 | </p> | |
| 448 | </div> | |
| 449 | </Layout> | |
| 450 | ); | |
| 451 | }); | |
| 452 | ||
| 453 | // ── GET /admin/soc2 ─────────────────────────────────────────────────────────── | |
| 454 | adminSecurity.get("/admin/soc2", async (c) => { | |
| 455 | const user = c.get("user")!; | |
| 456 | ||
| 457 | const CheckItem = ({ | |
| 458 | ok, | |
| 459 | label, | |
| 460 | desc, | |
| 461 | }: { | |
| 462 | ok: boolean; | |
| 463 | label: string; | |
| 464 | desc?: string; | |
| 465 | }) => ( | |
| 466 | <div class="soc2-row"> | |
| 467 | <div class={`soc2-icon ${ok ? "soc2-status-ok" : "soc2-status-warn"}`}> | |
| 468 | {ok ? "✓" : "⚠"} | |
| 469 | </div> | |
| 470 | <div class="soc2-text"> | |
| 471 | <div class="soc2-label">{label}</div> | |
| 472 | {desc && <div class="soc2-desc">{desc}</div>} | |
| 473 | </div> | |
| 474 | </div> | |
| 475 | ); | |
| 476 | ||
| 477 | return c.html( | |
| 478 | <Layout title="SOC 2 Readiness" user={user}> | |
| 479 | <style dangerouslySetInnerHTML={{ __html: securityStyles }} /> | |
| 480 | <div class="soc2-page"> | |
| 481 | <div class="soc2-hero"> | |
| 482 | <h1>SOC 2 Readiness Checklist</h1> | |
| 483 | <p> | |
| 484 | Maps the five SOC 2 Trust Service Criteria to Gluecron's implemented | |
| 485 | controls. Green items have active technical controls; amber items need | |
| 486 | policy, tooling, or external assessment. | |
| 487 | <br /> | |
| 488 | <span style="color: var(--text-muted); font-size: 12px;"> | |
| 489 | Last reviewed: {new Date().toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })} ·{" "} | |
| 490 | <a href="/admin/security">View security dashboard</a> | |
| 491 | </span> | |
| 492 | </p> | |
| 493 | </div> | |
| 494 | ||
| 495 | {/* ── CC: Security ── */} | |
| 496 | <div class="soc2-category"> | |
| 497 | <div class="soc2-section-card"> | |
| 498 | <h2 style="margin-top: 0;">CC — Security (Common Criteria)</h2> | |
| 499 | ||
| 500 | <CheckItem | |
| 501 | ok={true} | |
| 502 | label="Audit log (CC7.2)" | |
| 503 | desc="Every sensitive action is written to the audit_log table with user, IP, timestamp, and action. Exportable via /admin/audit or /api/v2/admin/audit." | |
| 504 | /> | |
| 505 | <CheckItem | |
| 506 | ok={true} | |
| 507 | label="Access controls — role-based (CC6.1)" | |
| 508 | desc="Admin routes gated by isSiteAdmin. Repository access gated by visibility + collaborator membership. Branch protection rules enforced on push and merge." | |
| 509 | /> | |
| 510 | <CheckItem | |
| 511 | ok={true} | |
| 512 | label="Account lockout after repeated failures (CC6.1)" | |
| 513 | desc="10 failed login attempts within 1 hour locks the account for 15 minutes. Attempts logged to login_attempts table with email + IP. Lockout events written to audit_log as auth.login.locked." | |
| 514 | /> | |
| 515 | <CheckItem | |
| 516 | ok={true} | |
| 517 | label="Rate limiting (CC6.6)" | |
| 518 | desc="Login endpoint: 20 req/min/IP (middleware rate-limit). API: 1000 req/min/IP. Git push/pull: 100 req/min/IP. Brute-force protection on forgot-password and magic-link endpoints." | |
| 519 | /> | |
| 520 | <CheckItem | |
| 521 | ok={true} | |
| 522 | label="Encryption in transit — HTTPS (CC6.7)" | |
| 523 | desc="TLS is enforced by the Fly.io edge and documented in fly.toml. All internal Neon PostgreSQL connections use SSL." | |
| 524 | /> | |
| 525 | <CheckItem | |
| 526 | ok={true} | |
| 527 | label="Encryption at rest — SSH + API keys (CC6.7)" | |
| 528 | desc="SSH public keys stored as plain text (read-only); private keys never leave the user. API tokens stored as SHA-256 hashes; plaintext never persisted. Passwords stored as bcrypt hashes (cost 12)." | |
| 529 | /> | |
| 530 | <CheckItem | |
| 531 | ok={true} | |
| 532 | label="Session management (CC6.1)" | |
| 533 | desc="30-day session expiry. Per-user session list at /settings/sessions with individual revoke and revoke-all. IP address and user-agent logged per session." | |
| 534 | /> | |
| 535 | <CheckItem | |
| 536 | ok={true} | |
| 537 | label="2FA / TOTP support (CC6.1)" | |
| 538 | desc="TOTP (RFC 6238) supported via /settings/2fa. Recovery codes (SHA-256 hashed) for device loss. WebAuthn/passkey support for phishing-resistant auth." | |
| 539 | /> | |
| 540 | <CheckItem | |
| 541 | ok={false} | |
| 542 | label="MFA enforcement policy (CC6.1)" | |
| 543 | desc="MFA is available but not yet mandatory for admin accounts. Recommend enforcing MFA for all users with isAdmin=true. Tracked: admin.security.mfa_enforcement_policy." | |
| 544 | /> | |
| 545 | <CheckItem | |
| 546 | ok={false} | |
| 547 | label="Penetration test (CC7.1)" | |
| 548 | desc="No external penetration test on record. Schedule an annual third-party assessment. OWASP Top 10 self-review partially complete (secret-scan gate covers A3, A7)." | |
| 549 | /> | |
| 550 | <CheckItem | |
| 551 | ok={false} | |
| 552 | label="Vulnerability disclosure policy (CC7.1)" | |
| 553 | desc="No public security.txt or responsible-disclosure policy page. Add /security.txt and a SECURITY.md to the repo." | |
| 554 | /> | |
| 555 | </div> | |
| 556 | </div> | |
| 557 | ||
| 558 | {/* ── A: Availability ── */} | |
| 559 | <div class="soc2-category"> | |
| 560 | <div class="soc2-section-card"> | |
| 561 | <h2 style="margin-top: 0;">A — Availability</h2> | |
| 562 | ||
| 563 | <CheckItem | |
| 564 | ok={true} | |
| 565 | label="Health probes (A1.2)" | |
| 566 | desc="GET /health returns 200 with uptime, memory, and DB reachability. Used by Fly.io TCP healthcheck. Autopilot tick monitors DB connectivity." | |
| 567 | /> | |
| 568 | <CheckItem | |
| 569 | ok={true} | |
| 570 | label="Public status page (A1.2)" | |
| 571 | desc="/status surfaces platform health. /admin/status shows synthetic-monitor results per component." | |
| 572 | /> | |
| 573 | <CheckItem | |
| 574 | ok={true} | |
| 575 | label="Database backups (A1.3)" | |
| 576 | desc="Neon PostgreSQL provides continuous PITR (point-in-time recovery) with 7-day history by default on Pro plans. Verify the backup retention period in the Neon console." | |
| 577 | /> | |
| 578 | <CheckItem | |
| 579 | ok={false} | |
| 580 | label="SLA definition (A1.1)" | |
| 581 | desc="No documented uptime SLA. Define target availability (e.g. 99.9%) and add it to /legal/terms or a dedicated /sla page." | |
| 582 | /> | |
| 583 | <CheckItem | |
| 584 | ok={false} | |
| 585 | label="Incident response runbook (A1.2)" | |
| 586 | desc="No documented incident response process. Create a runbook covering detection → escalation → communication → post-mortem." | |
| 587 | /> | |
| 588 | </div> | |
| 589 | </div> | |
| 590 | ||
| 591 | {/* ── C: Confidentiality ── */} | |
| 592 | <div class="soc2-category"> | |
| 593 | <div class="soc2-section-card"> | |
| 594 | <h2 style="margin-top: 0;">C — Confidentiality</h2> | |
| 595 | ||
| 596 | <CheckItem | |
| 597 | ok={true} | |
| 598 | label="Repository visibility controls (C1.1)" | |
| 599 | desc="Repos are private by default. Visibility enforced at the route layer (softAuth + git protocol). Private repos not reachable without a valid session or token." | |
| 600 | /> | |
| 601 | <CheckItem | |
| 602 | ok={true} | |
| 603 | label="API token scoping (C1.2)" | |
| 604 | desc="Tokens carry comma-separated scope list. /api/* handlers check scope before processing write requests." | |
| 605 | /> | |
| 606 | <CheckItem | |
| 607 | ok={true} | |
| 608 | label="Personal access token management (C1.2)" | |
| 609 | desc="Tokens shown once on creation; stored as SHA-256 hash. Users can revoke at /settings/tokens. Expiry supported." | |
| 610 | /> | |
| 611 | <CheckItem | |
| 612 | ok={false} | |
| 613 | label="Data classification policy (C1.1)" | |
| 614 | desc="No formal data classification. Define at least: Public (explore), Internal (private repos), Confidential (credentials, PII). Required for auditor review." | |
| 615 | /> | |
| 616 | <CheckItem | |
| 617 | ok={false} | |
| 618 | label="Third-party sub-processor list (C1.2)" | |
| 619 | desc="Neon, Resend, Fly.io, Anthropic API are used. Document these as sub-processors with data-handling descriptions on the Privacy page." | |
| 620 | /> | |
| 621 | </div> | |
| 622 | </div> | |
| 623 | ||
| 624 | {/* ── PI: Processing Integrity ── */} | |
| 625 | <div class="soc2-category"> | |
| 626 | <div class="soc2-section-card"> | |
| 627 | <h2 style="margin-top: 0;">PI — Processing Integrity</h2> | |
| 628 | ||
| 629 | <CheckItem | |
| 630 | ok={true} | |
| 631 | label="Audit trail for all mutations (PI1.2)" | |
| 632 | desc="Every sensitive write (merge, delete, force-push, token create/revoke, deploy) emitted to audit_log. Immutable append-only structure." | |
| 633 | /> | |
| 634 | <CheckItem | |
| 635 | ok={true} | |
| 636 | label="Git immutability (PI1.3)" | |
| 637 | desc="Branch protection prevents force-push on protected branches. Gate runs stored in gate_runs with commit SHA. Push events recorded in audit_log." | |
| 638 | /> | |
| 639 | <CheckItem | |
| 640 | ok={true} | |
| 641 | label="GateTest / CI enforcement (PI1.1)" | |
| 642 | desc="GateTest, secret-scan, type-check, and lint gates block merge on failure. Gate results stored per-commit in gate_runs with pass/fail status." | |
| 643 | /> | |
| 644 | <CheckItem | |
| 645 | ok={false} | |
| 646 | label="Input validation documentation (PI1.1)" | |
| 647 | desc="Input validation is implemented in route handlers but not formally documented. Add OpenAPI schema validation annotations for auditor review." | |
| 648 | /> | |
| 649 | </div> | |
| 650 | </div> | |
| 651 | ||
| 652 | {/* ── P: Privacy ── */} | |
| 653 | <div class="soc2-category"> | |
| 654 | <div class="soc2-section-card"> | |
| 655 | <h2 style="margin-top: 0;">P — Privacy</h2> | |
| 656 | ||
| 657 | <CheckItem | |
| 658 | ok={true} | |
| 659 | label="Account deletion with grace period (P4.3)" | |
| 660 | desc="Users can schedule account deletion at /settings. 30-day grace period with cancellation option. Deletion scheduled via deletionScheduledFor; purge via autopilot task." | |
| 661 | /> | |
| 662 | <CheckItem | |
| 663 | ok={true} | |
| 664 | label="Terms of Service + Privacy Policy (P1.1)" | |
| 665 | desc="Terms available at /terms and /legal/terms. Privacy Policy at /privacy. Acceptance timestamp + version recorded per user on registration (termsAcceptedAt, termsVersion)." | |
| 666 | /> | |
| 667 | <CheckItem | |
| 668 | ok={true} | |
| 669 | label="Email verification (P4.2)" | |
| 670 | desc="Email verification sent on registration when RESEND_API_KEY is configured. emailVerifiedAt timestamp tracked per user." | |
| 671 | /> | |
| 672 | <CheckItem | |
| 673 | ok={false} | |
| 674 | label="Data Processing Agreement / DPA (P1.1)" | |
| 675 | desc="No DPA available for EU/EEA customers. Required for GDPR Article 28 compliance if handling EU personal data. Add DPA to /legal/ and link from Privacy Policy." | |
| 676 | /> | |
| 677 | <CheckItem | |
| 678 | ok={false} | |
| 679 | label="Data retention policy (P6.7)" | |
| 680 | desc="No documented data retention schedule. Specify how long: sessions (30d), audit_log (indefinite), deleted accounts (purged after 30d grace). Document and automate." | |
| 681 | /> | |
| 682 | <CheckItem | |
| 683 | ok={false} | |
| 684 | label="Right to export / data portability (P8.1)" | |
| 685 | desc="No self-service data export for users. GitHub provides this via API; add an equivalent for full GDPR compliance." | |
| 686 | /> | |
| 687 | </div> | |
| 688 | </div> | |
| 689 | ||
| 690 | <div style="margin-top: var(--space-6); padding: var(--space-4); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px; font-size: 13px; color: var(--text-muted);"> | |
| 691 | <strong style="color: var(--text);">Summary:</strong> 16 controls implemented, 10 gaps identified. | |
| 692 | Priority order for SOC 2 Type I: (1) MFA enforcement for admins, (2) SLA definition, | |
| 693 | (3) DPA for EU customers, (4) Penetration test, (5) Vulnerability disclosure policy. | |
| 694 | Contact <a href="mailto:security@gluecron.com">security@gluecron.com</a> to report issues. | |
| 695 | </div> | |
| 696 | </div> | |
| 697 | </Layout> | |
| 698 | ); | |
| 699 | }); | |
| 700 | ||
| 701 | export default adminSecurity; |