adonisjs-server-stats 1.16.0 → 1.17.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 (91) hide show
  1. package/README.md +14 -2
  2. package/dist/core/core/dashboard-api.d.ts +6 -4
  3. package/dist/core/index.js +9 -6
  4. package/dist/core/types.d.ts +24 -1
  5. package/dist/react/{CacheSection-DkLtukWz.js → CacheSection-C-X2Tnca.js} +48 -44
  6. package/dist/react/{CacheTab-DWeXnEBI.js → CacheTab-D55mfmeE.js} +1 -1
  7. package/dist/react/{ConfigSection-DaVbFPDg.js → ConfigSection-C-HGnZfd.js} +1 -1
  8. package/dist/react/{ConfigTab-D4640WJZ.js → ConfigTab-CNnsOXBh.js} +1 -1
  9. package/dist/react/{CustomPaneTab-DglmAVMC.js → CustomPaneTab-D-267UVd.js} +1 -1
  10. package/dist/react/{EmailsSection-vYx6ExTb.js → EmailsSection-Bqw4DI4r.js} +65 -57
  11. package/dist/react/{EmailsTab-DKDrRmRH.js → EmailsTab-CkHOhj2F.js} +1 -1
  12. package/dist/react/{EventsSection-CapH5xut.js → EventsSection-CMeyjzVC.js} +1 -1
  13. package/dist/react/{EventsTab-BNVEySAm.js → EventsTab-DgS2v1HX.js} +1 -1
  14. package/dist/react/{JobsSection-ZIQsJWid.js → JobsSection-oWH6pbrG.js} +1 -1
  15. package/dist/react/{JobsTab-DXQzXrDt.js → JobsTab-zdxX_as-.js} +1 -1
  16. package/dist/react/{LogEntryRow-BWkHE51-.js → LogEntryRow-Cn8Ee_X0.js} +1 -1
  17. package/dist/react/{LogsSection-ConXdBkL.js → LogsSection-XmiI2MMs.js} +2 -2
  18. package/dist/react/{LogsTab-CJM47LPn.js → LogsTab-ixusY4v7.js} +2 -2
  19. package/dist/react/{OverviewSection-DOMu2qvl.js → OverviewSection-CvhObTLP.js} +119 -111
  20. package/dist/react/{QueriesSection-DFsaOSJI.js → QueriesSection-BvledwBI.js} +1 -1
  21. package/dist/react/{QueriesTab-CY9CG_7L.js → QueriesTab-smQQJvJa.js} +1 -1
  22. package/dist/react/{RequestsSection-CC-eVHsl.js → RequestsSection-DKuYpViv.js} +2 -2
  23. package/dist/react/{RoutesSection-p1DMq41y.js → RoutesSection-5RNVurQ6.js} +1 -1
  24. package/dist/react/{RoutesTab-Cnqy8UcK.js → RoutesTab-CpXNO-Rx.js} +1 -1
  25. package/dist/react/{SplitPaneWrapper-XgkA0QxE.js → SplitPaneWrapper-DQvJnsuk.js} +1 -1
  26. package/dist/react/{TimelineTab-1YOERxe5.js → TimelineTab-LHQHHKkO.js} +2 -2
  27. package/dist/react/components/Dashboard/overview-data-context.d.ts +15 -0
  28. package/dist/react/components/Dashboard/sections/OverviewSection.d.ts +9 -0
  29. package/dist/react/{index-DOSlCpZ9.js → index-LlFvooUN.js} +148 -141
  30. package/dist/react/index.js +1 -1
  31. package/dist/src/collectors/app_collector.d.ts +11 -0
  32. package/dist/src/collectors/app_collector.js +11 -0
  33. package/dist/src/collectors/redis_collector.js +13 -13
  34. package/dist/src/dashboard/cache_handlers.d.ts +17 -3
  35. package/dist/src/dashboard/cache_handlers.js +14 -19
  36. package/dist/src/dashboard/coalesce_cache.d.ts +2 -8
  37. package/dist/src/dashboard/coalesce_cache.js +25 -0
  38. package/dist/src/dashboard/dashboard_controller.d.ts +1 -0
  39. package/dist/src/dashboard/dashboard_controller.js +8 -3
  40. package/dist/src/dashboard/dashboard_store.d.ts +1 -3
  41. package/dist/src/dashboard/dashboard_store.js +14 -9
  42. package/dist/src/dashboard/dashboard_types.d.ts +2 -0
  43. package/dist/src/dashboard/filter_handlers.js +26 -4
  44. package/dist/src/dashboard/flush_manager.d.ts +2 -6
  45. package/dist/src/dashboard/flush_manager.js +9 -11
  46. package/dist/src/dashboard/format_helpers.d.ts +0 -4
  47. package/dist/src/dashboard/format_helpers.js +0 -19
  48. package/dist/src/dashboard/integrations/config_inspector.js +15 -52
  49. package/dist/src/dashboard/migrator_tables.js +4 -0
  50. package/dist/src/dashboard/paginate_helper.js +21 -11
  51. package/dist/src/dashboard/sensitive_patterns.d.ts +76 -0
  52. package/dist/src/dashboard/sensitive_patterns.js +259 -0
  53. package/dist/src/dashboard/write_queue.d.ts +27 -16
  54. package/dist/src/dashboard/write_queue.js +50 -47
  55. package/dist/src/debug/event_collector.d.ts +8 -0
  56. package/dist/src/debug/event_collector.js +12 -0
  57. package/dist/src/debug/query_collector.js +6 -1
  58. package/dist/src/debug/trace_collector.js +26 -13
  59. package/dist/src/edge/client/dashboard.js +2 -2
  60. package/dist/src/edge/client-vue/dashboard.js +5 -5
  61. package/dist/src/edge/views/dashboard.edge +1 -1
  62. package/dist/src/edge/views/debug-panel.edge +1 -1
  63. package/dist/src/edge/views/stats-bar.edge +1 -1
  64. package/dist/src/middleware/request_tracking_middleware.js +7 -2
  65. package/dist/src/provider/dashboard_init.d.ts +9 -1
  66. package/dist/src/provider/dashboard_init.js +19 -0
  67. package/dist/src/provider/email_helpers.js +20 -5
  68. package/dist/src/provider/server_stats_provider.d.ts +9 -0
  69. package/dist/src/provider/server_stats_provider.js +26 -7
  70. package/dist/src/provider/shutdown_helpers.js +3 -1
  71. package/dist/src/provider/toolbar_setup.js +21 -15
  72. package/dist/src/types.d.ts +24 -1
  73. package/dist/vue/CacheSection-Bm0goqX6.js +161 -0
  74. package/dist/vue/{ConfigSection-BiRAiaHj.js → ConfigSection-BrYslIMN.js} +1 -1
  75. package/dist/vue/{EmailsSection-Dl44qyqY.js → EmailsSection-B8N5vaWA.js} +89 -85
  76. package/dist/vue/{EventsSection-CWjeitjU.js → EventsSection-D2Y_xt0m.js} +1 -1
  77. package/dist/vue/{JobsSection-DOBb4LjZ.js → JobsSection-cEKqELLC.js} +1 -1
  78. package/dist/vue/{LogsSection-CXx-HOWJ.js → LogsSection-C3H2c0S3.js} +1 -1
  79. package/dist/vue/{OverviewSection-CyfNQ8uV.js → OverviewSection-I2swuHDa.js} +1 -1
  80. package/dist/vue/{QueriesSection-CXBsFp-y.js → QueriesSection-CF1IE3SN.js} +1 -1
  81. package/dist/vue/{RequestsSection-YTIaZGZd.js → RequestsSection-CuwgXzmU.js} +1 -1
  82. package/dist/vue/{RoutesSection-CDKMey49.js → RoutesSection-BhJCF7Rn.js} +1 -1
  83. package/dist/vue/composables/useDashboardData.d.ts +1 -1
  84. package/dist/vue/{index-14x39RI_.js → index-DUsImuIN.js} +95 -95
  85. package/dist/vue/index.js +1 -1
  86. package/package.json +12 -3
  87. package/dist/src/dashboard/explain_query.d.ts +0 -8
  88. package/dist/src/dashboard/explain_query.js +0 -22
  89. package/dist/src/provider/boot_initializer.d.ts +0 -28
  90. package/dist/src/provider/boot_initializer.js +0 -35
  91. package/dist/vue/CacheSection-CxEBVVkF.js +0 -156
@@ -1,48 +1,9 @@
1
+ import { isSensitiveConfigName, looksLikeCredentialValue } from '../sensitive_patterns.js';
1
2
  // ---------------------------------------------------------------------------
2
- // Sensitive key patterns
3
+ // Sensitive key detection
3
4
  // ---------------------------------------------------------------------------
4
- /**
5
- * Patterns matched against key names (case-insensitive) to detect secrets.
6
- *
7
- * Uses `(?:^|[_.-])` and `(?:$|[_.-])` as boundaries instead of `\b`
8
- * because env vars use `_` as separators and `_` is a word character
9
- * in regex, so `\b` won't match between `CLIENT` and `SECRET` in
10
- * `GOOGLE_CLIENT_SECRET`.
11
- */
12
- const B = '(?:^|[_.\\-])'; // boundary before
13
- const A = '(?:$|[_.\\-])'; // boundary after
14
- const SENSITIVE_PATTERNS = [
15
- new RegExp(`${B}password${A}`, 'i'),
16
- new RegExp(`${B}secret${A}`, 'i'),
17
- new RegExp(`${B}token${A}`, 'i'),
18
- new RegExp(`${B}credential${A}`, 'i'),
19
- new RegExp(`${B}private${A}`, 'i'),
20
- new RegExp(`${B}auth${A}`, 'i'),
21
- // API keys: `api_key`, `apiKey`, `API_KEY`
22
- /api[_-]?key/i,
23
- // `_KEY` at end or `_KEY_` in middle (AWS_ACCESS_KEY_ID, ENCRYPTION_KEY, etc.)
24
- /[_-]key([_-]|$)/i,
25
- // ACCESS_KEY pattern (AWS credentials)
26
- /access[_-]?key/i,
27
- // Exact match for just "key" (standalone)
28
- /^key$/i,
29
- // Connection strings and DSNs
30
- new RegExp(`${B}dsn${A}`, 'i'),
31
- /connection[_-]?string/i,
32
- // Email addresses in env var names
33
- new RegExp(`${B}email${A}`, 'i'),
34
- new RegExp(`${B}smtp${A}`, 'i'),
35
- // Database/service URLs (often contain embedded credentials)
36
- /database[_-]?url/i,
37
- /redis[_-]?url/i,
38
- // Webhook secrets
39
- /webhook[_-]?secret/i,
40
- // Signing / encryption
41
- new RegExp(`${B}signing${A}`, 'i'),
42
- new RegExp(`${B}encryption${A}`, 'i'),
43
- // App key / app secret
44
- /app[_-]key/i,
45
- ];
5
+ // The name patterns live in `../sensitive_patterns.js` so the config inspector
6
+ // and the SQL-binding writer share one definition of "looks like a secret".
46
7
  const REDACTED_DISPLAY = '••••••••';
47
8
  function redact(_value) {
48
9
  // Never include the plaintext value: the redacted object is serialized
@@ -121,20 +82,19 @@ export class ConfigInspector {
121
82
  * Check if a key name matches any sensitive pattern.
122
83
  */
123
84
  function isSensitiveKey(key) {
124
- return SENSITIVE_PATTERNS.some((pattern) => pattern.test(key));
85
+ return isSensitiveConfigName(key);
125
86
  }
126
87
  /**
127
88
  * Check if a value looks sensitive based on its content.
128
89
  * Catches email addresses and URLs with embedded credentials.
129
90
  */
130
91
  function isSensitiveValue(value) {
131
- // Email addresses
92
+ // Email addresses are config-sensitive (SMTP accounts) even though they are
93
+ // ordinary data as a query binding — hence the check lives here, not in the
94
+ // shared shape helper.
132
95
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
133
96
  return true;
134
- // URLs with userinfo (credentials embedded in URL)
135
- if (/^[a-z][a-z0-9+.-]*:\/\/[^/]*:[^/]*@/i.test(value))
136
- return true;
137
- return false;
97
+ return looksLikeCredentialValue(value);
138
98
  }
139
99
  /** Sanitize a single key-value pair, redacting sensitive strings. */
140
100
  function sanitizeValue(key, value, seen) {
@@ -142,7 +102,7 @@ function sanitizeValue(key, value, seen) {
142
102
  return redact(value);
143
103
  }
144
104
  if (typeof value === 'object' && value !== null) {
145
- return sanitizeObject(value, seen);
105
+ return sanitizeObject(value, seen, key);
146
106
  }
147
107
  return value;
148
108
  }
@@ -150,7 +110,7 @@ function sanitizeValue(key, value, seen) {
150
110
  * Recursively sanitize an object, redacting string values whose keys
151
111
  * match sensitive patterns. Booleans and numbers are never redacted.
152
112
  */
153
- function sanitizeObject(obj, seen = new WeakSet()) {
113
+ function sanitizeObject(obj, seen = new WeakSet(), parentKey = '') {
154
114
  if (obj === null || obj === undefined)
155
115
  return obj;
156
116
  if (typeof obj !== 'object')
@@ -159,7 +119,10 @@ function sanitizeObject(obj, seen = new WeakSet()) {
159
119
  return '[Circular]';
160
120
  seen.add(obj);
161
121
  if (Array.isArray(obj)) {
162
- return obj.map((item) => sanitizeObject(item, seen));
122
+ // Route items through sanitizeValue (under the array's own key) so string
123
+ // elements get the value-shape checks — previously a credential inside an
124
+ // array survived because sanitizeObject passes primitives through.
125
+ return obj.map((item) => sanitizeValue(parentKey, item, seen));
163
126
  }
164
127
  const record = obj;
165
128
  const result = {};
@@ -28,6 +28,10 @@ export async function migrateRequests(db) {
28
28
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_created ON server_stats_requests(created_at)`);
29
29
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_url ON server_stats_requests(url)`);
30
30
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_duration ON server_stats_requests(duration)`);
31
+ // Covers the overview p95 query (WHERE created_at >= ? ORDER BY duration):
32
+ // with only the single-column indexes SQLite sorts the whole filtered range
33
+ // on every 2s cache miss.
34
+ await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_created_duration ON server_stats_requests(created_at, duration)`);
31
35
  await db.raw(`CREATE INDEX IF NOT EXISTS idx_ss_requests_status ON server_stats_requests(status_code)`);
32
36
  try {
33
37
  await db.raw('ALTER TABLE server_stats_requests ADD COLUMN http_request_id TEXT');
@@ -15,31 +15,41 @@ export function clampPerPage(value) {
15
15
  return 25;
16
16
  return clamp(Math.trunc(n), 1, 200);
17
17
  }
18
+ /**
19
+ * Rows the COUNT is allowed to scan. Filters include leading-wildcard LIKEs
20
+ * that no index can serve, so an uncapped COUNT walks the entire table twice
21
+ * per request (count + data). Past this many matches the exact total stops
22
+ * being interesting — the UI just needs "lots of pages".
23
+ */
24
+ const COUNT_SCAN_CAP = 10_000;
18
25
  /**
19
26
  * Execute a paginated query within a transaction.
20
27
  */
21
28
  export async function executePaginate(db, opts) {
22
29
  const perPage = clampPerPage(opts.perPage);
23
30
  return db.transaction(async (trx) => {
24
- const countQuery = trx(opts.table);
31
+ const countSub = trx(opts.table).select(trx.raw('1')).limit(COUNT_SCAN_CAP);
25
32
  if (opts.applyFilters)
26
- opts.applyFilters(countQuery);
27
- const [{ count: totalRaw }] = await countQuery.count('* as count');
28
- const total = Number(totalRaw);
29
- const offset = (opts.page - 1) * perPage;
30
- const dataQuery = trx(opts.table)
31
- .orderBy('created_at', 'desc')
32
- .limit(perPage)
33
- .offset(offset);
33
+ opts.applyFilters(countSub);
34
+ const countRows = (await trx.count('* as count').from(countSub.as('t')));
35
+ const total = Number(countRows[0]?.count ?? 0);
36
+ // Clamp the page into the real range: an arbitrary ?page= forces a
37
+ // full-scan-sized OFFSET on the single sqlite connection, blocking every
38
+ // other dashboard read for nothing.
39
+ const lastPage = Math.ceil(total / perPage);
40
+ const requested = Number.isFinite(Number(opts.page)) ? Math.trunc(Number(opts.page)) : 1;
41
+ const page = clamp(requested, 1, Math.max(1, lastPage));
42
+ const offset = (page - 1) * perPage;
43
+ const dataQuery = trx(opts.table).orderBy('created_at', 'desc').limit(perPage).offset(offset);
34
44
  if (opts.applyFilters)
35
45
  opts.applyFilters(dataQuery);
36
46
  const data = await dataQuery;
37
47
  return {
38
48
  data,
39
49
  total,
40
- page: opts.page,
50
+ page,
41
51
  perPage,
42
- lastPage: Math.ceil(total / perPage),
52
+ lastPage,
43
53
  };
44
54
  });
45
55
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared server-side rules for recognising credentials.
3
+ *
4
+ * Both the config inspector and the SQL-binding writer need to answer "does
5
+ * this look like a secret?", and they used to answer it differently — config
6
+ * values were redacted against a real word list while query bindings were only
7
+ * truncated by length. One list, used by both, so the same secret cannot be
8
+ * masked in one view and printed in full in another.
9
+ */
10
+ /**
11
+ * Names that identify a credential — applies to env vars, config keys, and SQL
12
+ * identifiers alike.
13
+ */
14
+ export declare const SECRET_NAME_PATTERNS: RegExp[];
15
+ /**
16
+ * Names that matter for env vars and config keys but NOT for SQL identifiers.
17
+ *
18
+ * An `email` env var is usually an SMTP account; an `email` *column* is ordinary
19
+ * application data, and redacting every binding of every query that touches it
20
+ * would make the query pane useless for debugging auth. Same for the service
21
+ * URLs, which are env-shaped names rather than column names.
22
+ */
23
+ export declare const CONFIG_ONLY_NAME_PATTERNS: RegExp[];
24
+ /** Whether a name identifies a credential. */
25
+ export declare function isSecretName(name: string): boolean;
26
+ /** Whether a name is sensitive in a config/env context (credentials plus contact/service names). */
27
+ export declare function isSensitiveConfigName(name: string): boolean;
28
+ /**
29
+ * Whether a SQL statement mentions a credential-shaped identifier.
30
+ *
31
+ * Tokenised first: the name patterns above use `_`/`.`/`-` boundaries, so
32
+ * running them across raw SQL would miss `password` sitting between spaces.
33
+ *
34
+ * Positional bindings cannot be mapped back to specific columns reliably, so a
35
+ * hit means every binding for that statement is redacted. Coarse on purpose —
36
+ * over-redacting one statement's parameters beats storing a password.
37
+ */
38
+ export declare function sqlMentionsSecret(sql: string): boolean;
39
+ export declare function looksLikeCredentialValue(value: string): boolean;
40
+ /**
41
+ * Redact and truncate SQL bindings before persistence.
42
+ *
43
+ * Two independent rules, because neither catches everything on its own:
44
+ *
45
+ * 1. **By statement.** If the SQL mentions a credential-shaped identifier
46
+ * (`password`, `remember_token`, `otp`, ...), every binding for that
47
+ * statement is redacted. Positional bindings cannot be mapped back to
48
+ * columns reliably, so this is all-or-nothing per statement — coarse, but
49
+ * it is the only thing that catches a short secret like a 6-digit OTP.
50
+ * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
51
+ * digests, and URLs with embedded credentials are redacted wherever they
52
+ * appear, regardless of the statement.
53
+ *
54
+ * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
55
+ * oversized payload cannot bloat the row.
56
+ *
57
+ * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
58
+ * the query pane stays useful for debugging.
59
+ */
60
+ export declare function sanitizeBindings(bindings: unknown, sqlText?: string): unknown;
61
+ /**
62
+ * Redact a structured record (a pino log entry, an event payload) with the
63
+ * same key/shape rules as bindings but WITHOUT length truncation — log data
64
+ * carries stack traces and payload context that must stay whole.
65
+ */
66
+ export declare function sanitizeRecordValues(record: unknown): unknown;
67
+ /**
68
+ * Redact credential-bearing query parameters from a URL before it is stored.
69
+ *
70
+ * Request URLs are persisted with their query strings (useful for debugging
71
+ * pagination and filters), but a `?token=…` password-reset link or a
72
+ * `?signature=…` signed URL must not land in SQLite in the clear. Parameters
73
+ * are redacted by name (see {@link isSecretName}) or when the value itself
74
+ * looks like a credential; everything else passes through untouched.
75
+ */
76
+ export declare function sanitizeUrlQuery(url: string): string;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Shared server-side rules for recognising credentials.
3
+ *
4
+ * Both the config inspector and the SQL-binding writer need to answer "does
5
+ * this look like a secret?", and they used to answer it differently — config
6
+ * values were redacted against a real word list while query bindings were only
7
+ * truncated by length. One list, used by both, so the same secret cannot be
8
+ * masked in one view and printed in full in another.
9
+ */
10
+ // Custom word boundaries: `\b` does not match between `CLIENT` and `SECRET` in
11
+ // `GOOGLE_CLIENT_SECRET`, because `_` is a word character. These treat `_`,
12
+ // `.`, and `-` as separators, and also match a bare token on its own. The
13
+ // trailing boundary tolerates a plural `s` so `tokens`/`secrets` match too.
14
+ // camelCase is handled by normalizing the name BEFORE testing (see
15
+ // `splitCamelCase`), not by the boundaries themselves.
16
+ const B = '(?:^|[_.\\-])'; // boundary before
17
+ const A = 's?(?:$|[_.\\-])'; // boundary after (optional plural)
18
+ /**
19
+ * Insert `_` at lower/digit→UPPER transitions so the separator-based patterns
20
+ * above see camelCase the same way they see snake_case. Without this,
21
+ * `passwordHash` and `clientSecret` — the *normal* shape for AdonisJS config
22
+ * keys and camelCase column strategies — sail past every pattern while their
23
+ * snake_case twins are redacted.
24
+ */
25
+ function splitCamelCase(name) {
26
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1_$2');
27
+ }
28
+ /**
29
+ * Names that identify a credential — applies to env vars, config keys, and SQL
30
+ * identifiers alike.
31
+ */
32
+ export const SECRET_NAME_PATTERNS = [
33
+ new RegExp(`${B}password${A}`, 'i'),
34
+ new RegExp(`${B}secret${A}`, 'i'),
35
+ new RegExp(`${B}token${A}`, 'i'),
36
+ new RegExp(`${B}credential${A}`, 'i'),
37
+ new RegExp(`${B}private${A}`, 'i'),
38
+ new RegExp(`${B}auth${A}`, 'i'),
39
+ // HTTP credential carriers — log records embed request headers
40
+ new RegExp(`${B}authorization${A}`, 'i'),
41
+ new RegExp(`${B}cookie${A}`, 'i'),
42
+ // API keys: `api_key`, `apiKey`, `API_KEY`
43
+ /api[_-]?key/i,
44
+ // `_KEY` at end or `_KEY_` in middle (AWS_ACCESS_KEY_ID, ENCRYPTION_KEY, etc.)
45
+ /[_-]key([_-]|$)/i,
46
+ // ACCESS_KEY pattern (AWS credentials)
47
+ /access[_-]?key/i,
48
+ // Exact match for just "key" (standalone)
49
+ /^key$/i,
50
+ // Connection strings and DSNs
51
+ new RegExp(`${B}dsn${A}`, 'i'),
52
+ /connection[_-]?string/i,
53
+ // Webhook secrets
54
+ /webhook[_-]?secret/i,
55
+ // Signing / encryption
56
+ new RegExp(`${B}signing${A}`, 'i'),
57
+ new RegExp(`${B}encryption${A}`, 'i'),
58
+ // App key / app secret
59
+ /app[_-]key/i,
60
+ // One-time codes and second factors
61
+ new RegExp(`${B}otp${A}`, 'i'),
62
+ new RegExp(`${B}totp${A}`, 'i'),
63
+ new RegExp(`${B}mfa${A}`, 'i'),
64
+ // Password abbreviations
65
+ new RegExp(`${B}passwd${A}`, 'i'),
66
+ new RegExp(`${B}pwd${A}`, 'i'),
67
+ // Card / identity numbers
68
+ new RegExp(`${B}cvv${A}`, 'i'),
69
+ new RegExp(`${B}cvc${A}`, 'i'),
70
+ new RegExp(`${B}pin${A}`, 'i'),
71
+ new RegExp(`${B}ssn${A}`, 'i'),
72
+ ];
73
+ /**
74
+ * Names that matter for env vars and config keys but NOT for SQL identifiers.
75
+ *
76
+ * An `email` env var is usually an SMTP account; an `email` *column* is ordinary
77
+ * application data, and redacting every binding of every query that touches it
78
+ * would make the query pane useless for debugging auth. Same for the service
79
+ * URLs, which are env-shaped names rather than column names.
80
+ */
81
+ export const CONFIG_ONLY_NAME_PATTERNS = [
82
+ new RegExp(`${B}email${A}`, 'i'),
83
+ new RegExp(`${B}smtp${A}`, 'i'),
84
+ /database[_-]?url/i,
85
+ /redis[_-]?url/i,
86
+ ];
87
+ /** Whether a name identifies a credential. */
88
+ export function isSecretName(name) {
89
+ const normalized = splitCamelCase(name);
90
+ return SECRET_NAME_PATTERNS.some((pattern) => pattern.test(normalized));
91
+ }
92
+ /** Whether a name is sensitive in a config/env context (credentials plus contact/service names). */
93
+ export function isSensitiveConfigName(name) {
94
+ if (isSecretName(name))
95
+ return true;
96
+ const normalized = splitCamelCase(name);
97
+ return CONFIG_ONLY_NAME_PATTERNS.some((pattern) => pattern.test(normalized));
98
+ }
99
+ /** Identifier-ish tokens in a SQL statement: table names, column names, aliases. */
100
+ const SQL_IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_$]*/g;
101
+ /**
102
+ * Whether a SQL statement mentions a credential-shaped identifier.
103
+ *
104
+ * Tokenised first: the name patterns above use `_`/`.`/`-` boundaries, so
105
+ * running them across raw SQL would miss `password` sitting between spaces.
106
+ *
107
+ * Positional bindings cannot be mapped back to specific columns reliably, so a
108
+ * hit means every binding for that statement is redacted. Coarse on purpose —
109
+ * over-redacting one statement's parameters beats storing a password.
110
+ */
111
+ export function sqlMentionsSecret(sql) {
112
+ for (const match of sql.matchAll(SQL_IDENTIFIER_RE)) {
113
+ if (isSecretName(match[0]))
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ /**
119
+ * Whether a value looks like a credential from its shape alone, independent of
120
+ * any name.
121
+ *
122
+ * Deliberately excludes bare email addresses: they are sensitive as *config*
123
+ * (see {@link CONFIG_ONLY_NAME_PATTERNS}) but are ordinary query parameters.
124
+ */
125
+ const CREDENTIAL_VALUE_PATTERNS = [
126
+ // URL with userinfo — credentials embedded in the URL
127
+ /^[a-z][a-z0-9+.-]*:\/\/[^/]*:[^/]*@/i,
128
+ // bcrypt / argon2 / scrypt password hashes
129
+ /^\$(?:2[aby]|argon2[a-z]*|scrypt|s?crypt)\$/i,
130
+ // JWT
131
+ /^ey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./,
132
+ // Well-known provider key prefixes. Underscores allowed in the tail so
133
+ // `sk_live_…` / `pk_test_…` match (Stripe, GitHub, Slack, AWS).
134
+ /^(?:sk|pk|rk|whsec)_[A-Za-z0-9_]{8,}/,
135
+ /^gh[pousr]_[A-Za-z0-9]{8,}/,
136
+ /^xox[baprs]-[A-Za-z0-9-]{8,}/,
137
+ /^(?:AKIA|ASIA)[A-Z0-9]{12,}$/,
138
+ // Long hex digests — session ids, reset tokens, sha hashes
139
+ /^[0-9a-f]{32,}$/i,
140
+ ];
141
+ /** Minimum length before a base64url-shaped blob is treated as high-entropy. */
142
+ const MIN_BLOB_LEN = 40;
143
+ export function looksLikeCredentialValue(value) {
144
+ // Long high-entropy base64url blobs — length-gated, so kept out of the table.
145
+ if (value.length >= MIN_BLOB_LEN && /^[A-Za-z0-9_-]+={0,2}$/.test(value))
146
+ return true;
147
+ return CREDENTIAL_VALUE_PATTERNS.some((pattern) => pattern.test(value));
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // Binding hygiene
151
+ // ---------------------------------------------------------------------------
152
+ /** Max length for a stored string binding before it is truncated. */
153
+ const MAX_BINDING_LEN = 256;
154
+ /** Placeholder stored in place of a binding that looks like a credential. */
155
+ const REDACTED_BINDING = '[redacted]';
156
+ /**
157
+ * Redact and truncate SQL bindings before persistence.
158
+ *
159
+ * Two independent rules, because neither catches everything on its own:
160
+ *
161
+ * 1. **By statement.** If the SQL mentions a credential-shaped identifier
162
+ * (`password`, `remember_token`, `otp`, ...), every binding for that
163
+ * statement is redacted. Positional bindings cannot be mapped back to
164
+ * columns reliably, so this is all-or-nothing per statement — coarse, but
165
+ * it is the only thing that catches a short secret like a 6-digit OTP.
166
+ * 2. **By value shape.** Hashes, JWTs, provider key prefixes, long hex
167
+ * digests, and URLs with embedded credentials are redacted wherever they
168
+ * appear, regardless of the statement.
169
+ *
170
+ * Anything that survives both is truncated at {@link MAX_BINDING_LEN} so an
171
+ * oversized payload cannot bloat the row.
172
+ *
173
+ * Ordinary parameters — ids, flags, emails, timestamps — still pass through, so
174
+ * the query pane stays useful for debugging.
175
+ */
176
+ export function sanitizeBindings(bindings, sqlText) {
177
+ const redactAll = sqlText !== undefined && sqlMentionsSecret(sqlText);
178
+ return sanitizeBindingValue(bindings, redactAll, true);
179
+ }
180
+ /**
181
+ * Redact a structured record (a pino log entry, an event payload) with the
182
+ * same key/shape rules as bindings but WITHOUT length truncation — log data
183
+ * carries stack traces and payload context that must stay whole.
184
+ */
185
+ export function sanitizeRecordValues(record) {
186
+ return sanitizeBindingValue(record, false, false);
187
+ }
188
+ function sanitizeBindingValue(value, redactAll, truncate) {
189
+ if (Array.isArray(value))
190
+ return value.map((v) => sanitizeBindingValue(v, redactAll, truncate));
191
+ if (value !== null && typeof value === 'object') {
192
+ return sanitizeNamedBindings(value, redactAll, truncate);
193
+ }
194
+ if (typeof value === 'string')
195
+ return sanitizeStringBinding(value, redactAll, truncate);
196
+ // A statement touching a secret column may bind it as a non-string — a numeric
197
+ // OTP, for instance — so redact those too rather than only masking strings.
198
+ // Booleans and null carry nothing worth hiding.
199
+ const redactable = value !== null && value !== undefined && typeof value !== 'boolean';
200
+ return redactAll && redactable ? REDACTED_BINDING : value;
201
+ }
202
+ /** Named bindings carry their own key, so use it when it is telling. */
203
+ function sanitizeNamedBindings(value, redactAll, truncate) {
204
+ const out = {};
205
+ for (const [key, nested] of Object.entries(value)) {
206
+ out[key] = sanitizeBindingValue(nested, redactAll || isSecretName(key), truncate);
207
+ }
208
+ return out;
209
+ }
210
+ /** Matches a value this module already truncated, so a second pass is a no-op. */
211
+ const TRUNCATION_MARKER_RE = /…\[truncated \d+ chars\]$/;
212
+ function sanitizeStringBinding(value, redactAll, truncate) {
213
+ if (redactAll || looksLikeCredentialValue(value))
214
+ return REDACTED_BINDING;
215
+ if (truncate && value.length > MAX_BINDING_LEN && !TRUNCATION_MARKER_RE.test(value)) {
216
+ return value.slice(0, MAX_BINDING_LEN) + `…[truncated ${value.length} chars]`;
217
+ }
218
+ return value;
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // URL hygiene
222
+ // ---------------------------------------------------------------------------
223
+ /**
224
+ * Redact credential-bearing query parameters from a URL before it is stored.
225
+ *
226
+ * Request URLs are persisted with their query strings (useful for debugging
227
+ * pagination and filters), but a `?token=…` password-reset link or a
228
+ * `?signature=…` signed URL must not land in SQLite in the clear. Parameters
229
+ * are redacted by name (see {@link isSecretName}) or when the value itself
230
+ * looks like a credential; everything else passes through untouched.
231
+ */
232
+ export function sanitizeUrlQuery(url) {
233
+ const qIdx = url.indexOf('?');
234
+ if (qIdx === -1)
235
+ return url;
236
+ const sanitized = url
237
+ .slice(qIdx + 1)
238
+ .split('&')
239
+ .map((pair) => {
240
+ if (pair === '')
241
+ return pair;
242
+ const eq = pair.indexOf('=');
243
+ const name = eq === -1 ? pair : pair.slice(0, eq);
244
+ const rawValue = eq === -1 ? '' : pair.slice(eq + 1);
245
+ let value = rawValue;
246
+ try {
247
+ value = decodeURIComponent(rawValue);
248
+ }
249
+ catch {
250
+ // Malformed escape — judge the raw text instead.
251
+ }
252
+ if (isSecretName(name) || (value !== '' && looksLikeCredentialValue(value))) {
253
+ return `${name}=[redacted]`;
254
+ }
255
+ return pair;
256
+ })
257
+ .join('&');
258
+ return url.slice(0, qIdx) + '?' + sanitized;
259
+ }
@@ -5,11 +5,18 @@
5
5
  * objects. They are pure (no I/O, no Knex dependency) so they can
6
6
  * be tested in isolation.
7
7
  */
8
+ import { sanitizeBindings } from './sensitive_patterns.js';
8
9
  import type { EventRecord, EmailRecord } from '../debug/types.js';
9
10
  import type { PersistRequestInput } from './dashboard_types.js';
10
11
  import type { Knex } from 'knex';
11
12
  export declare function hasWarned(path: string): boolean;
12
13
  export declare function markWarned(path: string): void;
14
+ /**
15
+ * Forget which write paths have warned. Called when a DashboardStore stops so
16
+ * a re-initialized store in the same process warns afresh instead of
17
+ * inheriting a spent latch from its predecessor.
18
+ */
19
+ export declare function resetWriteWarnings(): void;
13
20
  /**
14
21
  * Normalize a SQL query by replacing literal values with `?` placeholders.
15
22
  * Used for grouping identical query patterns.
@@ -20,14 +27,7 @@ export declare function markWarned(path: string): void;
20
27
  * merged together.
21
28
  */
22
29
  export declare function normalizeSql(sql: string): string;
23
- /**
24
- * Redact/truncate SQL bindings before persistence so secret-looking values
25
- * (long tokens, hashes, keys) are not stored in cleartext.
26
- *
27
- * Conservative: only long strings are truncated; short values (ids, flags,
28
- * emails, ordinary params) pass through so normal capture is unaffected.
29
- */
30
- export declare function sanitizeBindings(bindings: unknown): unknown;
30
+ export { sanitizeBindings };
31
31
  export interface PreparedQuery {
32
32
  sql_text: string;
33
33
  sql_normalized: string;
@@ -51,6 +51,7 @@ export interface PreparedRequest {
51
51
  input: PersistRequestInput;
52
52
  filteredQueries: PreparedQuery[];
53
53
  traceRow: PreparedTraceRow | null;
54
+ eventRows: EventRow[];
54
55
  }
55
56
  export interface PreparedLog {
56
57
  [key: string]: unknown;
@@ -75,7 +76,6 @@ export interface EmailRow {
75
76
  }
76
77
  export interface EventRow {
77
78
  [key: string]: unknown;
78
- request_id: null;
79
79
  event_name: string;
80
80
  data: string | null;
81
81
  }
@@ -87,6 +87,13 @@ export interface EventRow {
87
87
  export declare function prepareRequestRows(requests: PersistRequestInput[]): PreparedRequest[];
88
88
  /**
89
89
  * Transform raw log entries into SQLite-ready row objects.
90
+ *
91
+ * The full pino record lands in `data`, and log lines routinely carry bound
92
+ * request context — authorization headers, cookies, tokens in payloads — so
93
+ * the record is run through the shared redaction rules (secret-named keys and
94
+ * credential-shaped values) before it is stringified. The `message` column is
95
+ * kept verbatim: it is free text, and the shape rules are anchored to whole
96
+ * values, so prose stays readable.
90
97
  */
91
98
  export declare function prepareLogRows(logs: Record<string, unknown>[]): PreparedLog[];
92
99
  /**
@@ -96,19 +103,23 @@ export declare function buildEmailRow(record: EmailRecord): EmailRow;
96
103
  /**
97
104
  * Transform EventRecords into SQLite-ready row objects.
98
105
  */
106
+ /**
107
+ * Build event rows. `request_id` is attached at insert time, once the owning
108
+ * request row has an id — leaving it null here (as this did previously) meant
109
+ * retention never reclaimed them, since events are only pruned via the
110
+ * `server_stats_requests` foreign-key cascade.
111
+ */
99
112
  export declare function buildEventRows(events: EventRecord[]): EventRow[];
100
113
  /**
101
114
  * Insert rows into a table in batches of 50.
115
+ *
116
+ * Yields to the event loop between chunks: better-sqlite3 is fully
117
+ * synchronous, so without a real macrotask hop a large batch would execute
118
+ * back-to-back on microtask continuations and stall the host app — the exact
119
+ * failure mode `migrator_tables.ts` documents and guards against.
102
120
  */
103
121
  export declare function batchInsert(trx: Knex.Transaction, table: string, rows: Record<string, unknown>[]): Promise<void>;
104
122
  export declare function flushRequests(trx: Knex.Transaction, preparedRequests: PreparedRequest[]): Promise<void>;
105
- /**
106
- * Flush pending events into the database.
107
- */
108
- export declare function flushEvents(trx: Knex.Transaction, events: {
109
- requestIndex: number;
110
- events: EventRecord[];
111
- }[]): Promise<void>;
112
123
  /**
113
124
  * Flush pending emails into the database.
114
125
  */