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.
- package/README.md +14 -2
- package/dist/core/core/dashboard-api.d.ts +6 -4
- package/dist/core/index.js +9 -6
- package/dist/core/types.d.ts +24 -1
- package/dist/react/{CacheSection-DkLtukWz.js → CacheSection-C-X2Tnca.js} +48 -44
- package/dist/react/{CacheTab-DWeXnEBI.js → CacheTab-D55mfmeE.js} +1 -1
- package/dist/react/{ConfigSection-DaVbFPDg.js → ConfigSection-C-HGnZfd.js} +1 -1
- package/dist/react/{ConfigTab-D4640WJZ.js → ConfigTab-CNnsOXBh.js} +1 -1
- package/dist/react/{CustomPaneTab-DglmAVMC.js → CustomPaneTab-D-267UVd.js} +1 -1
- package/dist/react/{EmailsSection-vYx6ExTb.js → EmailsSection-Bqw4DI4r.js} +65 -57
- package/dist/react/{EmailsTab-DKDrRmRH.js → EmailsTab-CkHOhj2F.js} +1 -1
- package/dist/react/{EventsSection-CapH5xut.js → EventsSection-CMeyjzVC.js} +1 -1
- package/dist/react/{EventsTab-BNVEySAm.js → EventsTab-DgS2v1HX.js} +1 -1
- package/dist/react/{JobsSection-ZIQsJWid.js → JobsSection-oWH6pbrG.js} +1 -1
- package/dist/react/{JobsTab-DXQzXrDt.js → JobsTab-zdxX_as-.js} +1 -1
- package/dist/react/{LogEntryRow-BWkHE51-.js → LogEntryRow-Cn8Ee_X0.js} +1 -1
- package/dist/react/{LogsSection-ConXdBkL.js → LogsSection-XmiI2MMs.js} +2 -2
- package/dist/react/{LogsTab-CJM47LPn.js → LogsTab-ixusY4v7.js} +2 -2
- package/dist/react/{OverviewSection-DOMu2qvl.js → OverviewSection-CvhObTLP.js} +119 -111
- package/dist/react/{QueriesSection-DFsaOSJI.js → QueriesSection-BvledwBI.js} +1 -1
- package/dist/react/{QueriesTab-CY9CG_7L.js → QueriesTab-smQQJvJa.js} +1 -1
- package/dist/react/{RequestsSection-CC-eVHsl.js → RequestsSection-DKuYpViv.js} +2 -2
- package/dist/react/{RoutesSection-p1DMq41y.js → RoutesSection-5RNVurQ6.js} +1 -1
- package/dist/react/{RoutesTab-Cnqy8UcK.js → RoutesTab-CpXNO-Rx.js} +1 -1
- package/dist/react/{SplitPaneWrapper-XgkA0QxE.js → SplitPaneWrapper-DQvJnsuk.js} +1 -1
- package/dist/react/{TimelineTab-1YOERxe5.js → TimelineTab-LHQHHKkO.js} +2 -2
- package/dist/react/components/Dashboard/overview-data-context.d.ts +15 -0
- package/dist/react/components/Dashboard/sections/OverviewSection.d.ts +9 -0
- package/dist/react/{index-DOSlCpZ9.js → index-LlFvooUN.js} +148 -141
- package/dist/react/index.js +1 -1
- package/dist/src/collectors/app_collector.d.ts +11 -0
- package/dist/src/collectors/app_collector.js +11 -0
- package/dist/src/collectors/redis_collector.js +13 -13
- package/dist/src/dashboard/cache_handlers.d.ts +17 -3
- package/dist/src/dashboard/cache_handlers.js +14 -19
- package/dist/src/dashboard/coalesce_cache.d.ts +2 -8
- package/dist/src/dashboard/coalesce_cache.js +25 -0
- package/dist/src/dashboard/dashboard_controller.d.ts +1 -0
- package/dist/src/dashboard/dashboard_controller.js +8 -3
- package/dist/src/dashboard/dashboard_store.d.ts +1 -3
- package/dist/src/dashboard/dashboard_store.js +14 -9
- package/dist/src/dashboard/dashboard_types.d.ts +2 -0
- package/dist/src/dashboard/filter_handlers.js +26 -4
- package/dist/src/dashboard/flush_manager.d.ts +2 -6
- package/dist/src/dashboard/flush_manager.js +9 -11
- package/dist/src/dashboard/format_helpers.d.ts +0 -4
- package/dist/src/dashboard/format_helpers.js +0 -19
- package/dist/src/dashboard/integrations/config_inspector.js +15 -52
- package/dist/src/dashboard/migrator_tables.js +4 -0
- package/dist/src/dashboard/paginate_helper.js +21 -11
- package/dist/src/dashboard/sensitive_patterns.d.ts +76 -0
- package/dist/src/dashboard/sensitive_patterns.js +259 -0
- package/dist/src/dashboard/write_queue.d.ts +27 -16
- package/dist/src/dashboard/write_queue.js +50 -47
- package/dist/src/debug/event_collector.d.ts +8 -0
- package/dist/src/debug/event_collector.js +12 -0
- package/dist/src/debug/query_collector.js +6 -1
- package/dist/src/debug/trace_collector.js +26 -13
- package/dist/src/edge/client/dashboard.js +2 -2
- package/dist/src/edge/client-vue/dashboard.js +5 -5
- package/dist/src/edge/views/dashboard.edge +1 -1
- package/dist/src/edge/views/debug-panel.edge +1 -1
- package/dist/src/edge/views/stats-bar.edge +1 -1
- package/dist/src/middleware/request_tracking_middleware.js +7 -2
- package/dist/src/provider/dashboard_init.d.ts +9 -1
- package/dist/src/provider/dashboard_init.js +19 -0
- package/dist/src/provider/email_helpers.js +20 -5
- package/dist/src/provider/server_stats_provider.d.ts +9 -0
- package/dist/src/provider/server_stats_provider.js +26 -7
- package/dist/src/provider/shutdown_helpers.js +3 -1
- package/dist/src/provider/toolbar_setup.js +21 -15
- package/dist/src/types.d.ts +24 -1
- package/dist/vue/CacheSection-Bm0goqX6.js +161 -0
- package/dist/vue/{ConfigSection-BiRAiaHj.js → ConfigSection-BrYslIMN.js} +1 -1
- package/dist/vue/{EmailsSection-Dl44qyqY.js → EmailsSection-B8N5vaWA.js} +89 -85
- package/dist/vue/{EventsSection-CWjeitjU.js → EventsSection-D2Y_xt0m.js} +1 -1
- package/dist/vue/{JobsSection-DOBb4LjZ.js → JobsSection-cEKqELLC.js} +1 -1
- package/dist/vue/{LogsSection-CXx-HOWJ.js → LogsSection-C3H2c0S3.js} +1 -1
- package/dist/vue/{OverviewSection-CyfNQ8uV.js → OverviewSection-I2swuHDa.js} +1 -1
- package/dist/vue/{QueriesSection-CXBsFp-y.js → QueriesSection-CF1IE3SN.js} +1 -1
- package/dist/vue/{RequestsSection-YTIaZGZd.js → RequestsSection-CuwgXzmU.js} +1 -1
- package/dist/vue/{RoutesSection-CDKMey49.js → RoutesSection-BhJCF7Rn.js} +1 -1
- package/dist/vue/composables/useDashboardData.d.ts +1 -1
- package/dist/vue/{index-14x39RI_.js → index-DUsImuIN.js} +95 -95
- package/dist/vue/index.js +1 -1
- package/package.json +12 -3
- package/dist/src/dashboard/explain_query.d.ts +0 -8
- package/dist/src/dashboard/explain_query.js +0 -22
- package/dist/src/provider/boot_initializer.d.ts +0 -28
- package/dist/src/provider/boot_initializer.js +0 -35
- 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
|
|
3
|
+
// Sensitive key detection
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
31
|
+
const countSub = trx(opts.table).select(trx.raw('1')).limit(COUNT_SCAN_CAP);
|
|
25
32
|
if (opts.applyFilters)
|
|
26
|
-
opts.applyFilters(
|
|
27
|
-
const
|
|
28
|
-
const total = Number(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
50
|
+
page,
|
|
41
51
|
perPage,
|
|
42
|
-
lastPage
|
|
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
|
*/
|