@dreamforgestudiollc/emet 0.0.0-stage → 0.2.6
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/.env.example +189 -0
- package/CHANGELOG.md +360 -0
- package/CHARTER.md +137 -0
- package/LICENSE +22 -0
- package/README.md +868 -2
- package/docs/AUTHORSHIP.md +26 -0
- package/docs/BENCHMARKS.md +261 -0
- package/docs/BOOTSTRAP.md +127 -0
- package/docs/BOTS.md +83 -0
- package/docs/COVERAGE.md +64 -0
- package/docs/DEPLOY.md +251 -0
- package/docs/DISCIPLINES.md +29 -0
- package/docs/GUARDRAILS-AND-MONORAILS.md +108 -0
- package/docs/GUIDED-SETUP.md +301 -0
- package/docs/PERMISSIONS.md +121 -0
- package/docs/ROADMAP.md +62 -0
- package/docs/SETUP.md +265 -0
- package/docs/SUBKEYS.md +59 -0
- package/gems/artifact-index.json +12 -0
- package/gems/bot-team-backup/floor.json +15 -0
- package/gems/bot-team-backup/gem.json +43 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_BACKUP.md +27 -0
- package/gems/bot-team-backup/guides/BOT_TEAM_RECOVERY.md +23 -0
- package/gems/fieldnote/floor.json +14 -0
- package/gems/fieldnote/gem.json +70 -0
- package/gems/fieldnote/runes/fieldnote.js +4 -0
- package/gems/lean-six-sigma/CHANGELOG.md +10 -0
- package/gems/lean-six-sigma/README.md +92 -0
- package/gems/lean-six-sigma/floor.json +31 -0
- package/gems/lean-six-sigma/gem.json +1296 -0
- package/gems/lean-six-sigma/guides/ASSUMPTIONS.md +45 -0
- package/gems/lean-six-sigma/guides/BELTS.md +52 -0
- package/gems/lean-six-sigma/guides/DMADV.md +56 -0
- package/gems/lean-six-sigma/guides/DMAIC.md +127 -0
- package/gems/lean-six-sigma/guides/FORMULAS.md +330 -0
- package/gems/lean-six-sigma/guides/LEAN.md +97 -0
- package/gems/lean-six-sigma/guides/TEMPLATES.md +279 -0
- package/gems/lean-six-sigma/runes/lss_capability.js +132 -0
- package/gems/lean-six-sigma/runes/lss_control_limits.js +240 -0
- package/gems/lean-six-sigma/runes/lss_dpmo_sigma.js +81 -0
- package/gems/lean-six-sigma/runes/lss_hypothesis.js +298 -0
- package/gems/lean-six-sigma/runes/lss_sample_size.js +81 -0
- package/gems/neurendipity/floor.json +65 -0
- package/gems/neurendipity/gem.json +42 -0
- package/gems/neurendipity/guides/AUTISTIC_BURNOUT.md +81 -0
- package/gems/neurendipity/guides/NEURODIVERGENT_SUPPORT.md +287 -0
- package/guides/README.md +3 -0
- package/package.json +76 -4
- package/server.json +79 -0
- package/src/aleph.js +395 -0
- package/src/annotations.js +170 -0
- package/src/approve-throttle.js +54 -0
- package/src/atlas-role.js +58 -0
- package/src/attribution.js +555 -0
- package/src/conformance.js +688 -0
- package/src/content_analyzer.js +450 -0
- package/src/database.js +812 -0
- package/src/disciplines.js +26 -0
- package/src/dispatch.js +382 -0
- package/src/env.js +97 -0
- package/src/floor.js +228 -0
- package/src/gaps.js +509 -0
- package/src/gem-check.js +1359 -0
- package/src/guides.js +75 -0
- package/src/host-reach.js +23 -0
- package/src/http-remote.js +313 -0
- package/src/index.js +397 -0
- package/src/invites.js +167 -0
- package/src/layers.js +449 -0
- package/src/lessons-to-checks.js +366 -0
- package/src/logger.js +300 -0
- package/src/metadata.js +61 -0
- package/src/module-path.js +32 -0
- package/src/mongo-connect.js +91 -0
- package/src/named-items.js +208 -0
- package/src/oauth-provider.js +838 -0
- package/src/owner-approvals.js +281 -0
- package/src/persons.js +136 -0
- package/src/provenance.js +257 -0
- package/src/redact.js +237 -0
- package/src/request-log.js +124 -0
- package/src/rune-dispatch.js +360 -0
- package/src/rune-registry.js +71 -0
- package/src/rune-scope.js +42 -0
- package/src/scopes.js +100 -0
- package/src/security-headers.js +26 -0
- package/src/session-graph.js +947 -0
- package/src/session.js +1097 -0
- package/src/setup.js +1580 -0
- package/src/socket.js +545 -0
- package/src/startup-pages.js +371 -0
- package/src/startup-payload.js +129 -0
- package/src/subkey-store.js +357 -0
- package/src/subkeys.js +947 -0
- package/src/tools.js +4508 -0
- package/src/transcript-session.js +106 -0
- package/src/validation.js +1187 -0
- package/src/version.js +2 -0
- package/templates/BOARD.md +72 -0
- package/templates/BOTHANDOFF.md +57 -0
- package/templates/CHARACTER.md +30 -0
- package/templates/HANDOFF.md +63 -0
- package/templates/IDENTITY.md +29 -0
- package/templates/INDEX.md +82 -0
- package/templates/INSTALL.md +29 -0
- package/templates/PROCEDURE.md +39 -0
- package/templates/ROSTER.md +43 -0
- package/templates/SEED.md +65 -0
- package/templates/STAGING.md +37 -0
- package/templates/STANDARD.md +99 -0
- package/templates/THREAD.md +43 -0
- package/templates/USER.md +32 -0
- package/verification/load-gate/corpus.json +1765 -0
- package/verification/load-gate/load-gate.mjs +1246 -0
package/src/redact.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT License - see LICENSE
|
|
6
|
+
*
|
|
7
|
+
* Secret redaction at the write path.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists (2026-09-07): an OAuth passphrase was pasted into a chat and a
|
|
10
|
+
* screenshot during a connector re-add, and the session transcript that would
|
|
11
|
+
* carry it verbatim was minutes away from being written to the store. A rule
|
|
12
|
+
* that says "do not paste secrets" is an instruction; a scrubber the content
|
|
13
|
+
* passes through before it lands anywhere is a control. This module is the
|
|
14
|
+
* control. It sits inside the single write path (THE DOOR, procedural ref 280):
|
|
15
|
+
* memory saves, transcript saves, document writes, and log output all pass
|
|
16
|
+
* through it, so an instance cannot opt out by forgetting.
|
|
17
|
+
*
|
|
18
|
+
* What it masks, in order:
|
|
19
|
+
* 1. Literal values of secret-bearing environment variables (anything whose
|
|
20
|
+
* name contains PASSPHRASE, PASSWORD, SECRET, TOKEN, API_KEY, PRIVATE_KEY,
|
|
21
|
+
* PEPPER, or MONGODB_URI), plus the user:password pair inside any URI among them.
|
|
22
|
+
* These are exact-string matches and cannot false-positive.
|
|
23
|
+
* 2. Credentials embedded in URIs: scheme://user:password@host.
|
|
24
|
+
* 3. Bearer tokens and JWTs.
|
|
25
|
+
* 4. key/value pairs whose key names a secret (passphrase, password, secret,
|
|
26
|
+
* token, api_key, client_secret, access_token, refresh_token, authorization)
|
|
27
|
+
* followed by : or = and a value. The key survives, the value does not.
|
|
28
|
+
*
|
|
29
|
+
* What it deliberately does NOT mask: hex digests (sha256 values are 64 hex
|
|
30
|
+
* chars and are part of the integrity record), ids, and ordinary prose. Rule 4
|
|
31
|
+
* can mask a word in prose that follows "token:" - that is accepted. Masking a
|
|
32
|
+
* word costs a word; missing a secret costs the secret. (An English prefix
|
|
33
|
+
* hyphenated onto a key word - "re-authorization:" - is not treated as a key;
|
|
34
|
+
* that one masked prose on 2026-09-07 and is excluded.)
|
|
35
|
+
*
|
|
36
|
+
* The mask is visible on purpose: '*****' says "something was here and was
|
|
37
|
+
* removed", which is the honest record. A silent drop would be a summary.
|
|
38
|
+
*
|
|
39
|
+
* The hit count reports CHANGES. Text that already carries the mask is matched
|
|
40
|
+
* and left as it is, uncounted - otherwise every read-modify-write of a
|
|
41
|
+
* document re-reports the masks it already held (observed 2026-09-07, meta ref 83).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export const MASK = '*****';
|
|
45
|
+
|
|
46
|
+
const SECRET_ENV_NAME = /(PASSPHRASE|PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PEPPER|MONGODB_URI)/i;
|
|
47
|
+
const MIN_LITERAL_LENGTH = 6;
|
|
48
|
+
|
|
49
|
+
// Registered literal secrets. A Set so re-registering is a no-op.
|
|
50
|
+
const literals = new Set();
|
|
51
|
+
|
|
52
|
+
function escapeRegex(s) {
|
|
53
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Registers one literal secret value. Anything shorter than MIN_LITERAL_LENGTH
|
|
58
|
+
* is ignored - masking "abc" everywhere would shred ordinary text.
|
|
59
|
+
* If the value is a URI with credentials, the user:password pair is registered
|
|
60
|
+
* as well, so a URI quoted with a different host or query still masks.
|
|
61
|
+
*/
|
|
62
|
+
export function registerSecret(value) {
|
|
63
|
+
if (typeof value !== 'string') return false;
|
|
64
|
+
const v = value.trim();
|
|
65
|
+
if (v.length < MIN_LITERAL_LENGTH) return false;
|
|
66
|
+
literals.add(v);
|
|
67
|
+
literalOrder = null;
|
|
68
|
+
const m = v.match(/^[a-z][a-z0-9+.-]*:\/\/([^/@\s]+)@/i);
|
|
69
|
+
if (m && m[1].includes(':')) {
|
|
70
|
+
const [, pass] = m[1].split(/:(.*)/s);
|
|
71
|
+
if (pass && pass.length >= MIN_LITERAL_LENGTH) literals.add(pass);
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Registers every secret-bearing value in an env object (defaults to process.env). */
|
|
77
|
+
export function registerSecretsFromEnv(env = process.env) {
|
|
78
|
+
let n = 0;
|
|
79
|
+
for (const [k, v] of Object.entries(env)) {
|
|
80
|
+
if (SECRET_ENV_NAME.test(k) && registerSecret(v)) n++;
|
|
81
|
+
}
|
|
82
|
+
return n;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Test seam. Not for production use. */
|
|
86
|
+
export function _clearRegisteredSecrets() {
|
|
87
|
+
literals.clear();
|
|
88
|
+
literalOrder = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Longest-first order, computed once per registry change rather than once per
|
|
92
|
+
// write. The registry changes at startup and effectively never again; sorting
|
|
93
|
+
// it on every scrub was work repeated for no reason.
|
|
94
|
+
let literalOrder = null;
|
|
95
|
+
function orderedLiterals() {
|
|
96
|
+
if (literalOrder === null) literalOrder = [...literals].sort((a, b) => b.length - a.length);
|
|
97
|
+
return literalOrder;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function registeredSecretCount() {
|
|
101
|
+
return literals.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A necessary-condition prefilter for the key/value rule. Every alternative in
|
|
105
|
+
// KEY_NAMES contains one of these substrings, so text without any of them
|
|
106
|
+
// cannot match - and skipping the full pattern then is free correctness.
|
|
107
|
+
const KEY_HINT = /pass|secret|token|key|authorization|credential/i;
|
|
108
|
+
|
|
109
|
+
// Pattern rules. Each replaces with a function so the hit count is exact.
|
|
110
|
+
// `pre` is a cheap NECESSARY condition: a substring the full pattern cannot
|
|
111
|
+
// match without. It never decides a match - it only decides whether the
|
|
112
|
+
// expensive scan is worth starting.
|
|
113
|
+
const KEY_NAMES = '(?:pass(?:phrase|word|wd)|secret|token|api[_-]?key|client_secret|access_token|refresh_token|authorization|credential)';
|
|
114
|
+
const RULES = [
|
|
115
|
+
// scheme://user:password@host -> scheme://*****:*****@host
|
|
116
|
+
{ name: 'uri-credentials', pre: (t) => t.includes('://'),
|
|
117
|
+
re: /([a-z][a-z0-9+.-]*:\/\/)([^/@\s:]+):([^/@\s]+)@/gi, sub: (_m, scheme) => `${scheme}${MASK}:${MASK}@` },
|
|
118
|
+
// Bearer <token>
|
|
119
|
+
{ name: 'bearer', pre: (t) => t.includes('Bearer'),
|
|
120
|
+
re: /\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/g, sub: (_m, w) => `${w} ${MASK}` },
|
|
121
|
+
// JWT
|
|
122
|
+
{ name: 'jwt', pre: (t) => t.includes('eyJ'),
|
|
123
|
+
re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, sub: () => MASK },
|
|
124
|
+
// Delegated client sub-keys (EMET-0001). The full key, the session handle
|
|
125
|
+
// and the claim nonce. A bare key_id (32 hex, no prefix) is an index and
|
|
126
|
+
// stays visible. Lengths are exact so a shorter hex id is not masked.
|
|
127
|
+
{ name: 'subkey', pre: (t) => t.includes('emk_') || t.includes('emh_') || t.includes('emn_'),
|
|
128
|
+
re: /(?<![A-Za-z0-9_])(?:emk_[0-9a-f]{32}_[A-Za-z0-9_-]{43}|emh_[A-Za-z0-9_-]{43}|emn_[A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/g,
|
|
129
|
+
sub: () => MASK },
|
|
130
|
+
// key: value / key = value / "key": "value" (key survives, value is masked)
|
|
131
|
+
{ name: 'key-value',
|
|
132
|
+
// Every alternative in KEY_NAMES contains one of these substrings, and the
|
|
133
|
+
// rule also requires a ':' or '='. Both are NECESSARY conditions, so
|
|
134
|
+
// failing this check cannot skip a match the full pattern would have found.
|
|
135
|
+
pre: (t) => KEY_HINT.test(t) && (t.includes(':') || t.includes('=')),
|
|
136
|
+
// The scheme words of an Authorization header are not the secret; the
|
|
137
|
+
// bearer rule above has already masked what followed them.
|
|
138
|
+
// G5 (2026-09-07): the prefix class lets a header name like x-api-key
|
|
139
|
+
// through, but it also let "re-authorization:" in prose count as a key and
|
|
140
|
+
// mask the word after it (thread §32). An English prefix joined by a hyphen
|
|
141
|
+
// is not a key name: re-, pre-, de-, un-, non- are excluded right before
|
|
142
|
+
// the key word.
|
|
143
|
+
// G6 (2026-09-07): the value stops at a backtick and does not swallow a
|
|
144
|
+
// trailing period or closing parenthesis - prose like `passphrase: x`. kept
|
|
145
|
+
// losing its closing backtick and full stop. A secret that itself ends in
|
|
146
|
+
// "." or ")" keeps that one character visible; accepted.
|
|
147
|
+
re: new RegExp(`(["']?\\b[\\w.-]*(?<!\\b(?:re|pre|de|un|non)-)${KEY_NAMES}["']?\\s*[:=]\\s*)(["']?)(?!(?:Bearer|Basic|Digest)\\b)(?=[^\\s"'\`,;}\\]]{4})([^\\s"'\`,;}\\]]*[^\\s"'\`,;}\\].)])([.)]*)(\\2)`, 'gi'),
|
|
148
|
+
sub: (_m, key, q, _v, trail, q2) => `${key}${q}${MASK}${trail}${q2}` }
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Redacts a string. Returns { text, hits } where hits is the number of
|
|
153
|
+
* replacements made. Non-strings are returned unchanged with hits 0.
|
|
154
|
+
*/
|
|
155
|
+
export function redactWithReport(input) {
|
|
156
|
+
if (typeof input !== 'string' || input.length === 0) return { text: input, hits: 0 };
|
|
157
|
+
let text = input;
|
|
158
|
+
let hits = 0;
|
|
159
|
+
// Literals first: exact, longest first so a value that contains another is
|
|
160
|
+
// masked whole rather than leaving a fragment.
|
|
161
|
+
//
|
|
162
|
+
// These are literal strings, so they need no regex at all. The previous
|
|
163
|
+
// version compiled one RegExp per literal PER CALL and scanned the whole body
|
|
164
|
+
// with it whether or not the literal was present. indexOf answers "is it
|
|
165
|
+
// here" far more cheaply than a regex scan, and split/join is the same
|
|
166
|
+
// operation as a global replace on an escaped literal - identical output,
|
|
167
|
+
// identical hit count.
|
|
168
|
+
for (const lit of orderedLiterals()) {
|
|
169
|
+
if (!text.includes(lit)) continue;
|
|
170
|
+
const parts = text.split(lit);
|
|
171
|
+
hits += parts.length - 1;
|
|
172
|
+
text = parts.join(MASK);
|
|
173
|
+
}
|
|
174
|
+
// G4 (2026-09-07): a hit is a CHANGE. Text that already carries the mask
|
|
175
|
+
// ("passphrase: *****") matches the key/value rule and is replaced with
|
|
176
|
+
// itself; counting that made every read-modify-write of a document report
|
|
177
|
+
// the masks it already held as new redactions. The receipt's `redacted` is
|
|
178
|
+
// the number of spans this write changed - nothing else.
|
|
179
|
+
for (const rule of RULES) {
|
|
180
|
+
// The prefilter is a necessary condition for the pattern, so skipping on a
|
|
181
|
+
// miss cannot change the output. It exists because the scrubber was paying
|
|
182
|
+
// nearly full price on documents containing no secrets at all: measured
|
|
183
|
+
// 2026-09-08, a clean 300 KB body cost 6.40 ms against 7.25 ms for a
|
|
184
|
+
// secret-bearing one. The cost was scanning, not replacing.
|
|
185
|
+
if (rule.pre && !rule.pre(text)) continue;
|
|
186
|
+
text = text.replace(rule.re, (...args) => {
|
|
187
|
+
const out = rule.sub(...args);
|
|
188
|
+
if (out !== args[0]) hits++;
|
|
189
|
+
return out;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return { text, hits };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Convenience: the redacted string only. */
|
|
196
|
+
export function redactText(input) {
|
|
197
|
+
return redactWithReport(input).text;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const SECRET_KEY = new RegExp(`^[\\w.-]*${KEY_NAMES}$`, 'i');
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Deep-redacts any value: strings are scrubbed; object keys that name a secret
|
|
204
|
+
* have their value masked outright regardless of content; arrays and plain
|
|
205
|
+
* objects are walked. Other types pass through. Returns { value, hits }.
|
|
206
|
+
*/
|
|
207
|
+
export function redactDeep(value, _seen = new WeakSet()) {
|
|
208
|
+
if (typeof value === 'string') {
|
|
209
|
+
const r = redactWithReport(value);
|
|
210
|
+
return { value: r.text, hits: r.hits };
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
let hits = 0;
|
|
214
|
+
const out = value.map(v => { const r = redactDeep(v, _seen); hits += r.hits; return r.value; });
|
|
215
|
+
return { value: out, hits };
|
|
216
|
+
}
|
|
217
|
+
if (value && typeof value === 'object') {
|
|
218
|
+
if (_seen.has(value)) return { value, hits: 0 };
|
|
219
|
+
_seen.add(value);
|
|
220
|
+
if (value instanceof Date || Buffer.isBuffer?.(value)) return { value, hits: 0 };
|
|
221
|
+
let hits = 0;
|
|
222
|
+
const out = {};
|
|
223
|
+
for (const [k, v] of Object.entries(value)) {
|
|
224
|
+
if (SECRET_KEY.test(k) && v !== null && v !== undefined && v !== '' && typeof v !== 'object') {
|
|
225
|
+
// G4: an already-masked value is left as it is and not counted.
|
|
226
|
+
if (v === MASK) { out[k] = v; } else { out[k] = MASK; hits++; }
|
|
227
|
+
} else {
|
|
228
|
+
const r = redactDeep(v, _seen); hits += r.hits; out[k] = r.value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { value: out, hits };
|
|
232
|
+
}
|
|
233
|
+
return { value, hits: 0 };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Load-time registration: the process's own secrets are always known to it.
|
|
237
|
+
registerSecretsFromEnv();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMET - Enduring Memory & Epistemic Tiers
|
|
3
|
+
* Copyright (c) 2026 Dreamforge Studio LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT License - see LICENSE
|
|
6
|
+
*
|
|
7
|
+
* Request / auth logging for the HTTP transport (work list H4, 2026-09-07).
|
|
8
|
+
*
|
|
9
|
+
* Why: on 2026-09-07 a connector was added at the wrong URL, the browser
|
|
10
|
+
* authorization ran to completion, the client then POSTed to `/` and got a 404,
|
|
11
|
+
* and the user saw "an error". Railway's logs held three startup lines and
|
|
12
|
+
* nothing else. A server that cannot say what it answered cannot be diagnosed
|
|
13
|
+
* without reproducing the failure by hand - which is what happened.
|
|
14
|
+
*
|
|
15
|
+
* What is logged: one line per request on the OAuth and MCP paths, plus any
|
|
16
|
+
* request anywhere that ends 4xx/5xx (so a POST to a path that does not exist
|
|
17
|
+
* is visible). Fields: method, path, status, latency, client ip, and the
|
|
18
|
+
* identifying-but-not-secret parts of the exchange - client_id (a CIMD URL or
|
|
19
|
+
* an opaque registration id), grant_type, response_type, resource, the JSON-RPC
|
|
20
|
+
* method, tool name and the NAMES of the tool's parameters on /mcp (never their
|
|
21
|
+
* values - added 2026-09-27 so parameter use can be measured), and a reason on failures.
|
|
22
|
+
*
|
|
23
|
+
* What is never logged: request or response bodies, authorization codes,
|
|
24
|
+
* tokens, code verifiers, passphrases, or query strings as a whole. The line
|
|
25
|
+
* is still passed through the redaction scrubber before it is written -
|
|
26
|
+
* belt and braces; a control that depends on the author remembering is a rule.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { redactText } from './redact.js';
|
|
30
|
+
|
|
31
|
+
// Paths always logged, whatever the status.
|
|
32
|
+
export const LOGGED_PATH = /^\/(?:authorize|token|register|revoke|oauth\/approve|mcp|\.well-known\/[^\s]*)$/;
|
|
33
|
+
|
|
34
|
+
const CLIP = 160;
|
|
35
|
+
const clip = (v) => String(v).slice(0, CLIP);
|
|
36
|
+
|
|
37
|
+
/** Whether a finished request should produce a log line. */
|
|
38
|
+
export function shouldLog(path, statusCode) {
|
|
39
|
+
return LOGGED_PATH.test(path) || statusCode >= 400;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Builds the log entry for one finished request. Pure: takes the request
|
|
44
|
+
* fields it needs rather than the request object, so it is testable without
|
|
45
|
+
* express. `body` is whatever the parser left on req.body (may be undefined
|
|
46
|
+
* on a 401 that stopped before parsing), `query` is req.query.
|
|
47
|
+
*/
|
|
48
|
+
export function requestLogEntry({ method, path, statusCode, ms, ip, query = {}, body, wwwAuthenticate, reason }) {
|
|
49
|
+
const entry = { t: new Date().toISOString(), req: `${method} ${path}`, status: statusCode, ms: Math.round(ms) };
|
|
50
|
+
if (ip) entry.ip = ip;
|
|
51
|
+
|
|
52
|
+
const q = (query && typeof query === 'object') ? query : {};
|
|
53
|
+
const b = (body && typeof body === 'object' && !Array.isArray(body)) ? body : {};
|
|
54
|
+
|
|
55
|
+
const clientId = q.client_id ?? b.client_id;
|
|
56
|
+
if (clientId !== undefined) entry.client = clip(clientId);
|
|
57
|
+
if (b.grant_type) entry.grant = clip(b.grant_type);
|
|
58
|
+
if (q.response_type) entry.response_type = clip(q.response_type);
|
|
59
|
+
const resource = q.resource ?? b.resource;
|
|
60
|
+
if (resource !== undefined && resource !== null && resource !== '') entry.resource = clip(resource);
|
|
61
|
+
if (q.redirect_uri ?? b.redirect_uri) {
|
|
62
|
+
try { entry.redirect_host = new URL(q.redirect_uri ?? b.redirect_uri).host; } catch { entry.redirect_host = 'invalid'; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (path === '/mcp') {
|
|
66
|
+
if (Array.isArray(body)) entry.rpc = `batch(${body.length})`;
|
|
67
|
+
else if (b.method) {
|
|
68
|
+
entry.rpc = clip(b.method);
|
|
69
|
+
if (b.method === 'tools/call' && b.params && b.params.name) {
|
|
70
|
+
entry.tool = clip(b.params.name);
|
|
71
|
+
// Parameter NAMES only (Alex, 2026-09-27, code audit 1.6) - values never.
|
|
72
|
+
const a = b.params.arguments;
|
|
73
|
+
if (a && typeof a === 'object' && !Array.isArray(a)) entry.params = Object.keys(a).sort().map(clip);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (reason) entry.reason = clip(reason);
|
|
79
|
+
else if (statusCode === 401 && wwwAuthenticate) {
|
|
80
|
+
const m = String(wwwAuthenticate).match(/error="([^"]+)"/);
|
|
81
|
+
if (m) entry.reason = m[1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The rendered line. Scrubbed even though nothing secret should be in it. */
|
|
88
|
+
export function formatRequestLog(entry) {
|
|
89
|
+
return '[emet][req] ' + redactText(JSON.stringify(entry));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Express middleware. Installed before the auth router so it sees every
|
|
94
|
+
* request; logs on `finish`, when the status and any parsed body are known.
|
|
95
|
+
*/
|
|
96
|
+
export function requestLogger({ enabled = true, write = (line) => console.error(line) } = {}) {
|
|
97
|
+
return function emetRequestLog(req, res, next) {
|
|
98
|
+
if (!enabled) return next();
|
|
99
|
+
const t0 = process.hrtime.bigint();
|
|
100
|
+
// Capture the path NOW. The SDK's auth router mounts /authorize, /token,
|
|
101
|
+
// /register and the well-known documents as sub-routers, and express
|
|
102
|
+
// strips the mount prefix from req.path while a sub-router is handling
|
|
103
|
+
// the request - at `finish` it can read as '/' with status 200, which the
|
|
104
|
+
// filter would then skip. Found live on the first deploy (2026-09-07): the
|
|
105
|
+
// /.well-known line was the one missing from the log.
|
|
106
|
+
const path = String(req.originalUrl || req.url || '/').split('?')[0] || '/';
|
|
107
|
+
res.on('finish', () => {
|
|
108
|
+
try {
|
|
109
|
+
if (!shouldLog(path, res.statusCode)) return;
|
|
110
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
111
|
+
const entry = requestLogEntry({
|
|
112
|
+
method: req.method, path, statusCode: res.statusCode, ms, ip: req.ip,
|
|
113
|
+
query: req.query, body: req.body,
|
|
114
|
+
wwwAuthenticate: res.getHeader && res.getHeader('www-authenticate'),
|
|
115
|
+
reason: res.locals && res.locals.reason
|
|
116
|
+
});
|
|
117
|
+
write(formatRequestLog(entry));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
write('[emet][req] log failed: ' + (e && e.message));
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
next();
|
|
123
|
+
};
|
|
124
|
+
}
|