@wenrwa/marketplace-cli 0.8.4 → 0.8.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/dist/__tests__/bridge-changes-report.test.js +25 -0
- package/dist/__tests__/bridge-changes-reporter.test.js +98 -0
- package/dist/__tests__/bridge-read-transcript-frame.test.js +22 -0
- package/dist/__tests__/bridge-save-env.test.d.ts +1 -0
- package/dist/__tests__/bridge-save-env.test.js +343 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.d.ts +1 -0
- package/dist/__tests__/bridge-server-phantom-takeover.test.js +84 -0
- package/dist/__tests__/bridge-server.test.js +9 -11
- package/dist/__tests__/secrets-egress.test.d.ts +1 -0
- package/dist/__tests__/secrets-egress.test.js +237 -0
- package/dist/__tests__/secrets-redact.test.d.ts +1 -0
- package/dist/__tests__/secrets-redact.test.js +318 -0
- package/dist/__tests__/term-snapshot.test.js +136 -0
- package/dist/bridge/changesReport.js +10 -3
- package/dist/bridge/changesReporter.js +0 -0
- package/dist/bridge/contextWriter.js +5 -1
- package/dist/bridge/envSaver.d.ts +63 -0
- package/dist/bridge/envSaver.js +256 -0
- package/dist/bridge/hiveopSettings.js +5 -0
- package/dist/bridge/launchSupervisor.js +4 -1
- package/dist/bridge/localContextWriter.js +6 -2
- package/dist/bridge/paneClient.d.ts +32 -0
- package/dist/bridge/paneClient.js +30 -0
- package/dist/bridge/realPty.js +5 -1
- package/dist/bridge/relayClient.d.ts +0 -18
- package/dist/bridge/relayClient.js +21 -6
- package/dist/bridge/repoEnv.d.ts +34 -0
- package/dist/bridge/repoEnv.js +186 -0
- package/dist/bridge/server.js +69 -26
- package/dist/bridge/sessionManager.d.ts +4 -0
- package/dist/bridge/sessionManager.js +28 -8
- package/dist/bridge/termSnapshot.js +114 -2
- package/dist/bridge/workGraphPointer.d.ts +25 -0
- package/dist/bridge/workGraphPointer.js +36 -1
- package/dist/commands/bridge.js +4 -0
- package/dist/secrets/console.d.ts +4 -0
- package/dist/secrets/console.js +34 -0
- package/dist/secrets/redact.d.ts +79 -0
- package/dist/secrets/redact.js +637 -0
- package/hooks/opencode-plugin.mjs +55 -2
- package/hooks/pre-tool-use.js +96 -0
- package/package.json +1 -1
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Secret masking for terminal output (owner-approved 2026-10-17).
|
|
4
|
+
*
|
|
5
|
+
* A secret pasted or printed in any HiveOp terminal must never reach anyone's
|
|
6
|
+
* screen — the owner's own view included (they stream and screenshot). The PTY
|
|
7
|
+
* child still gets the real bytes; only what is DISPLAYED or SENT is masked.
|
|
8
|
+
*
|
|
9
|
+
* ONE SOURCE OF TRUTH. backend/src/lib/secrets/redact.ts is a byte-identical
|
|
10
|
+
* copy (the backend cannot import from cli/), and a backend test fails the
|
|
11
|
+
* moment the two differ. Edit this file, then copy it over.
|
|
12
|
+
*
|
|
13
|
+
* Exports:
|
|
14
|
+
* - findSecrets(text) spans a plain string would mask
|
|
15
|
+
* - containsSecret(text) true when findSecrets finds anything
|
|
16
|
+
* - redact(text) one-shot, ANSI-aware masking of a whole string
|
|
17
|
+
* - createStreamRedactor(opts) the same for a chunked PTY stream
|
|
18
|
+
*
|
|
19
|
+
* Masking is LENGTH-PRESERVING: every hidden character becomes one '*'. That
|
|
20
|
+
* keeps every column a TUI drew where it drew it (a shorter line would leave
|
|
21
|
+
* stale cells behind a cursor-addressed repaint), keeps patch byte sizes, and
|
|
22
|
+
* lets a value that arrives in two pieces be masked piece by piece. Prefixed
|
|
23
|
+
* tokens keep their first 4 characters (`npm_****…`); a NAME=value keeps the
|
|
24
|
+
* name (`RAILWAY_API_TOKEN=****…`). '*' is ASCII, so it is one cell wide in
|
|
25
|
+
* every terminal, and a re-scan of masked text finds nothing (idempotent).
|
|
26
|
+
*
|
|
27
|
+
* Streaming: output arrives in arbitrary chunks, interleaved with escape
|
|
28
|
+
* sequences. Escapes pass through untouched (OSC titles are masked inside);
|
|
29
|
+
* text is matched on a "visible" view with SGR colour codes removed, so a
|
|
30
|
+
* token painted in two colours is still one token. A tail that could still be
|
|
31
|
+
* the start of a secret is held back (bounded) and released on the next chunk
|
|
32
|
+
* or after a short idle, so interactive echo stays snappy.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.MASK_CHAR = void 0;
|
|
36
|
+
exports.isSecretName = isSecretName;
|
|
37
|
+
exports.findSecrets = findSecrets;
|
|
38
|
+
exports.containsSecret = containsSecret;
|
|
39
|
+
exports.createStreamRedactor = createStreamRedactor;
|
|
40
|
+
exports.redact = redact;
|
|
41
|
+
exports.redactInSlices = redactInSlices;
|
|
42
|
+
exports.MASK_CHAR = '*';
|
|
43
|
+
// ── value / name rules ───────────────────────────────────────────────────────
|
|
44
|
+
/** Characters a secret value may be made of. Excludes whitespace, quotes,
|
|
45
|
+
* backslash (keeps JSON escapes intact), `$` (a `$VAR` reference is never a
|
|
46
|
+
* literal), shell/code punctuation and control characters. */
|
|
47
|
+
const VCH = "[^\\s\"'`\\\\;,(){}\\[\\]<>$|&\\x00-\\x1f\\x7f]";
|
|
48
|
+
const PLACEHOLDER_WORDS = /(x{5,}|your[-_ ]?|example|placeholder|changeme|change[-_]me|redacted|dummy|sample|<|>|\.\.\.|…)/i;
|
|
49
|
+
/** True when `v` reads as a real secret value, not code, a path or a placeholder. */
|
|
50
|
+
function plausibleValue(v, opts) {
|
|
51
|
+
if (v.length < opts.minLen)
|
|
52
|
+
return false;
|
|
53
|
+
if (/^[*•.\-_xX0#=]+$/.test(v))
|
|
54
|
+
return false; // masks and filler
|
|
55
|
+
if (/^(true|false|null|undefined|none|nil|yes|no|on|off|empty|unset|hidden)$/i.test(v))
|
|
56
|
+
return false;
|
|
57
|
+
if (/^(\/|\.\.?\/|~\/|[A-Za-z]:[\\/])/.test(v))
|
|
58
|
+
return false; // file paths
|
|
59
|
+
if (/^[A-Za-z][A-Za-z0-9+.\-]*:\/\//.test(v))
|
|
60
|
+
return false; // URLs (creds handled separately)
|
|
61
|
+
if (PLACEHOLDER_WORDS.test(v))
|
|
62
|
+
return false;
|
|
63
|
+
if (/^v?\d+([.:\-]\d+)*$/.test(v))
|
|
64
|
+
return false; // numbers, versions, times
|
|
65
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(v))
|
|
66
|
+
return false; // ISO dates
|
|
67
|
+
if (/^v?\d+\.\d+\.\d+/.test(v))
|
|
68
|
+
return false; // semver-ish
|
|
69
|
+
if (/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(v))
|
|
70
|
+
return false; // a.b.c code path
|
|
71
|
+
if (/^pk_(live|test)_/.test(v))
|
|
72
|
+
return false; // Stripe publishable keys are public
|
|
73
|
+
if (opts.needDigit && !/\d/.test(v))
|
|
74
|
+
return false;
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
const STRONG_SEGMENTS = new Set([
|
|
78
|
+
'token', 'secret', 'secrets', 'password', 'passwords', 'passwd', 'passphrase', 'pwd', 'pass',
|
|
79
|
+
'credential', 'credentials', 'creds', 'apikey', 'privatekey', 'secretkey', 'accesskey', 'auth',
|
|
80
|
+
'jwt', 'bearer', 'cookie',
|
|
81
|
+
]);
|
|
82
|
+
/** `<word>_KEY` names that are NOT secrets (cache keys, sort keys, …). */
|
|
83
|
+
const KEY_EXCLUDE = new Set([
|
|
84
|
+
'cache', 'sort', 'partition', 'primary', 'foreign', 'query', 'storage', 'idempotency', 'map',
|
|
85
|
+
'object', 'hash', 'row', 'unique', 'composite', 'lookup', 'dedupe', 'dedup', 'group', 'routing',
|
|
86
|
+
'shard', 'public', 'publishable', 'react', 'list', 'dict', 'index', 'cursor', 'sequence', 'sort',
|
|
87
|
+
'message', 'translation', 'i18n', 'locale', 'host', 'item', 'node', 'field', 'column', 'table',
|
|
88
|
+
'record', 'entry', 'state', 'store', 'redis', 'bucket', 'file', 'path', 'meta', 'event',
|
|
89
|
+
]);
|
|
90
|
+
/** True when an identifier names a secret: RAILWAY_API_TOKEN, apiKey, --password, x-api-key. */
|
|
91
|
+
function isSecretName(name) {
|
|
92
|
+
const segs = name
|
|
93
|
+
.replace(/^-+/, '')
|
|
94
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.split(/[_.\-]+/)
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
if (segs.length === 0 || segs.length > 10)
|
|
99
|
+
return false;
|
|
100
|
+
if (segs.includes('public') || segs.includes('publishable'))
|
|
101
|
+
return false;
|
|
102
|
+
for (let i = 0; i < segs.length; i++) {
|
|
103
|
+
const s = segs[i];
|
|
104
|
+
if (STRONG_SEGMENTS.has(s))
|
|
105
|
+
return true;
|
|
106
|
+
if (s.length > 5 && (s.endsWith('token') || s.endsWith('secret') || s.endsWith('password') || s.endsWith('apikey')))
|
|
107
|
+
return true;
|
|
108
|
+
if ((s === 'key' || s === 'keys') && i > 0 && !KEY_EXCLUDE.has(segs[i - 1]))
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
// ── patterns ─────────────────────────────────────────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// Every pattern either starts with a literal or is guarded by a lookbehind that
|
|
116
|
+
// stops it starting in the middle of a run of its own leading class — without
|
|
117
|
+
// that, a long run (a hex dump, a TUI line of 60k cells) is scanned once per
|
|
118
|
+
// start position, which is quadratic. The adversarial perf test pins this.
|
|
119
|
+
const B = '(?<![A-Za-z0-9_\\-])'; // token boundary
|
|
120
|
+
/** Known prefixed token formats. Group 1 is the token; the first 4 chars stay visible. */
|
|
121
|
+
const PREFIXED = [
|
|
122
|
+
/sk-ant-[A-Za-z0-9_\-]{20,}/,
|
|
123
|
+
/sk-(?:or-|proj-|svcacct-|admin-)?[A-Za-z0-9_\-]{20,}/,
|
|
124
|
+
/npm_[A-Za-z0-9]{20,}/,
|
|
125
|
+
/(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/,
|
|
126
|
+
/github_pat_[A-Za-z0-9_]{20,}/,
|
|
127
|
+
/glpat-[A-Za-z0-9_\-]{20,}/,
|
|
128
|
+
/xox[abprse]-[A-Za-z0-9\-]{10,}/,
|
|
129
|
+
/(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/,
|
|
130
|
+
/(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/,
|
|
131
|
+
/AIza[A-Za-z0-9_\-]{30,}/,
|
|
132
|
+
/eyJ[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}\.[A-Za-z0-9_\-]{6,}/,
|
|
133
|
+
/wm_(?:sk|ct)_[A-Za-z0-9_\-]{12,}/,
|
|
134
|
+
/hf_[A-Za-z0-9]{30,}/,
|
|
135
|
+
/sbp_[a-f0-9]{30,}/,
|
|
136
|
+
/sb_secret_[A-Za-z0-9_\-]{16,}/,
|
|
137
|
+
/SG\.[A-Za-z0-9_\-]{16,}\.[A-Za-z0-9_\-]{16,}/,
|
|
138
|
+
/fm[12]_[A-Za-z0-9_\-+\/=,]{20,}/,
|
|
139
|
+
/lin_api_[A-Za-z0-9]{20,}/,
|
|
140
|
+
/pypi-[A-Za-z0-9_\-]{30,}/,
|
|
141
|
+
/dop_v1_[a-f0-9]{30,}/,
|
|
142
|
+
].map((r) => new RegExp(`${B}(${r.source})`, 'g'));
|
|
143
|
+
/** Prefixes for the hold-back test: a trailing run starting with one may still be growing. */
|
|
144
|
+
const KNOWN_PREFIXES = [
|
|
145
|
+
'sk-', 'npm_', 'ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_', 'glpat-', 'xoxa-', 'xoxb-',
|
|
146
|
+
'xoxp-', 'xoxr-', 'xoxs-', 'xoxe-', 'sk_live_', 'sk_test_', 'rk_live_', 'rk_test_', 'AKIA', 'ASIA',
|
|
147
|
+
'AIza', 'eyJ', 'wm_sk_', 'wm_ct_', 'hf_', 'sbp_', 'sb_secret_', 'SG.', 'fm1_', 'fm2_', 'lin_api_',
|
|
148
|
+
'pypi-', 'dop_v1_',
|
|
149
|
+
];
|
|
150
|
+
const NAME = '[A-Za-z_][A-Za-z0-9_.\\-]*';
|
|
151
|
+
/** A NAME that contains a secret-ish word (the regex prefilter; isSecretName
|
|
152
|
+
* decides). Bounded so a 60k-cell identifier run cannot backtrack for long. */
|
|
153
|
+
const KW_NAME = '(?=[A-Za-z_])[A-Za-z0-9_.\\-]{0,64}?(?:[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss]|[Pp][Ww][Dd]|[Cc][Rr][Ee][Dd]|[Aa][Uu][Tt][Hh]|[Kk][Ee][Yy]|[Jj][Ww][Tt]|[Bb][Ee][Aa][Rr][Ee][Rr]|[Cc][Oo][Oo][Kk][Ii][Ee])[A-Za-z0-9_.\\-]{0,64}';
|
|
154
|
+
const HS = '[^\\S\\r\\n]*'; // horizontal whitespace
|
|
155
|
+
/** NAME=value, NAME: value, "name": "value", export NAME='value'. Groups: 1 name, 2 value. */
|
|
156
|
+
const ASSIGN_RE = new RegExp(`(?<![A-Za-z0-9_.\\-])(?:\\\\?["'])?(${KW_NAME})(?![A-Za-z0-9_.\\-])(?:\\\\?["'])?${HS}(?:=|:(?!\\/\\/))(?!=)${HS}(?:\\\\?["'])?(${VCH}+)`, 'g');
|
|
157
|
+
/** --token value / --api-key=value. Groups: 1 flag name, 2 value. */
|
|
158
|
+
const FLAG_RE = new RegExp(`(?<![A-Za-z0-9_\\-])--?([A-Za-z][A-Za-z0-9_\\-]*)(?:=|[^\\S\\r\\n]+)["']?(${VCH}+)`, 'g');
|
|
159
|
+
/** Authorization: <scheme> <value>. Group 1 value. */
|
|
160
|
+
const AUTH_HEADER_RE = new RegExp(`(?<![A-Za-z0-9_\\-])(?:Proxy-)?Authorization["']?${HS}[:=]${HS}["']?(?:(?:Bearer|Basic|Token|Bot|Digest)[^\\S\\r\\n]+)?(${VCH}+)`, 'gi');
|
|
161
|
+
/** Bearer <value> anywhere. Group 1 value. */
|
|
162
|
+
const BEARER_RE = new RegExp(`(?<![A-Za-z0-9_\\-])Bearer[^\\S\\r\\n]+(${VCH}+)`, 'gi');
|
|
163
|
+
/** scheme://user:password@host. Group 1 password. */
|
|
164
|
+
const URL_CRED_RE = /:\/\/[^\s:\/@"'<>]+:([^\s\/@"'<>$]+)@/g;
|
|
165
|
+
const PEM_BEGIN_RE = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
|
|
166
|
+
const PEM_END_RE = /-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
|
|
167
|
+
const PEM_RUN_RE = /[A-Za-z0-9+\/=]{8,}/g;
|
|
168
|
+
/** Give up on an unterminated key block after this many characters. */
|
|
169
|
+
const PEM_BUDGET = 16_384;
|
|
170
|
+
// ── hold-back (streaming) ────────────────────────────────────────────────────
|
|
171
|
+
/** Trailing, still-growing forms (anchored at the end of a line tail). Group 1 = value so far. */
|
|
172
|
+
const TRAIL_ASSIGN_RE = new RegExp(`(?<![A-Za-z0-9_.\\-])(?:\\\\?["'])?(${NAME})(?:\\\\?["'])?${HS}(?:=|:(?!\\/\\/))(?!=)${HS}(?:\\\\?["'])?(${VCH}*)$`);
|
|
173
|
+
const TRAIL_FLAG_RE = new RegExp(`(?<![A-Za-z0-9_\\-])--?([A-Za-z][A-Za-z0-9_\\-]*)(?:=|[^\\S\\r\\n]+)["']?(${VCH}*)$`);
|
|
174
|
+
const TRAIL_BEARER_RE = new RegExp(`(?:Authorization["']?${HS}[:=]${HS}["']?(?:(?:Bearer|Basic|Token|Bot|Digest)[^\\S\\r\\n]+)?|Bearer[^\\S\\r\\n]+)(${VCH}*)$`, 'i');
|
|
175
|
+
const TRAIL_URL_RE = /:\/\/[^\s:\/@"'<>]+:([^\s\/@"'<>$]*)$/;
|
|
176
|
+
const TRAIL_RUN_RE = /(?<![A-Za-z0-9_\-.+\/=,])[A-Za-z0-9_\-.+\/=,]+$/;
|
|
177
|
+
/** How far back (visible chars) the trailing checks look. */
|
|
178
|
+
const TAIL_WINDOW = 600;
|
|
179
|
+
function pushSpan(out, start, end, kind) {
|
|
180
|
+
if (end > start)
|
|
181
|
+
out.push({ start, end, kind });
|
|
182
|
+
}
|
|
183
|
+
function detectStatic(v, out) {
|
|
184
|
+
for (const re of PREFIXED) {
|
|
185
|
+
re.lastIndex = 0;
|
|
186
|
+
let m;
|
|
187
|
+
while ((m = re.exec(v))) {
|
|
188
|
+
const tok = m[1];
|
|
189
|
+
const body = tok.slice(4);
|
|
190
|
+
if (/\d/.test(body) && /[A-Za-z]/.test(body) && !PLACEHOLDER_WORDS.test(tok) && !/^(.)\1+$/.test(body)) {
|
|
191
|
+
pushSpan(out, m.index + 4, m.index + tok.length, 'prefixed');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const valueAt = (m, group) => m.index + m[0].length - m[group].length;
|
|
196
|
+
let m;
|
|
197
|
+
ASSIGN_RE.lastIndex = 0;
|
|
198
|
+
while ((m = ASSIGN_RE.exec(v))) {
|
|
199
|
+
if (isSecretName(m[1]) && plausibleValue(m[2], { minLen: 12, needDigit: true })) {
|
|
200
|
+
const s = valueAt(m, 2);
|
|
201
|
+
pushSpan(out, s, s + m[2].length, 'assignment');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
FLAG_RE.lastIndex = 0;
|
|
205
|
+
while ((m = FLAG_RE.exec(v))) {
|
|
206
|
+
if (isSecretName(m[1]) && plausibleValue(m[2], { minLen: 12, needDigit: true })) {
|
|
207
|
+
const s = valueAt(m, 2);
|
|
208
|
+
pushSpan(out, s, s + m[2].length, 'assignment');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
AUTH_HEADER_RE.lastIndex = 0;
|
|
212
|
+
while ((m = AUTH_HEADER_RE.exec(v))) {
|
|
213
|
+
if (plausibleValue(m[1], { minLen: 8, needDigit: false }) && !/^(Bearer|Basic|Token|Bot|Digest)$/i.test(m[1])) {
|
|
214
|
+
const s = valueAt(m, 1);
|
|
215
|
+
pushSpan(out, s, s + m[1].length, 'bearer');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
BEARER_RE.lastIndex = 0;
|
|
219
|
+
while ((m = BEARER_RE.exec(v))) {
|
|
220
|
+
if (plausibleValue(m[1], { minLen: 12, needDigit: true })) {
|
|
221
|
+
const s = valueAt(m, 1);
|
|
222
|
+
pushSpan(out, s, s + m[1].length, 'bearer');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
URL_CRED_RE.lastIndex = 0;
|
|
226
|
+
while ((m = URL_CRED_RE.exec(v))) {
|
|
227
|
+
const pw = m[1];
|
|
228
|
+
if (!/^[*]+$/.test(pw)) {
|
|
229
|
+
const s = m.index + m[0].length - 1 - pw.length;
|
|
230
|
+
pushSpan(out, s, s + pw.length, 'url-credential');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Private-key blocks: mask base64 runs between BEGIN and END, across chunks. */
|
|
235
|
+
function detectPem(v, boundary, state, out) {
|
|
236
|
+
let inPem = state.inPem;
|
|
237
|
+
let budget = state.pemBudget;
|
|
238
|
+
let cur = inPem ? boundary : 0;
|
|
239
|
+
for (;;) {
|
|
240
|
+
if (inPem) {
|
|
241
|
+
PEM_END_RE.lastIndex = Math.max(0, cur - 64);
|
|
242
|
+
let end;
|
|
243
|
+
while ((end = PEM_END_RE.exec(v)) && end.index + end[0].length <= cur) { /* skip already-seen */ }
|
|
244
|
+
const regionEnd = end ? end.index : v.length;
|
|
245
|
+
const region = v.slice(cur, regionEnd);
|
|
246
|
+
PEM_RUN_RE.lastIndex = 0;
|
|
247
|
+
let r;
|
|
248
|
+
while ((r = PEM_RUN_RE.exec(region)))
|
|
249
|
+
pushSpan(out, cur + r.index, cur + r.index + r[0].length, 'private-key');
|
|
250
|
+
if (end) {
|
|
251
|
+
inPem = false;
|
|
252
|
+
cur = end.index + end[0].length;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (cur >= boundary)
|
|
256
|
+
budget -= region.length;
|
|
257
|
+
if (budget <= 0)
|
|
258
|
+
inPem = false;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
PEM_BEGIN_RE.lastIndex = Math.max(0, cur, boundary - 64);
|
|
262
|
+
let begin;
|
|
263
|
+
while ((begin = PEM_BEGIN_RE.exec(v)) && begin.index + begin[0].length <= boundary) { /* emitted before */ }
|
|
264
|
+
if (!begin || begin.index + begin[0].length < cur)
|
|
265
|
+
break;
|
|
266
|
+
inPem = true;
|
|
267
|
+
budget = PEM_BUDGET;
|
|
268
|
+
cur = begin.index + begin[0].length;
|
|
269
|
+
}
|
|
270
|
+
return { inPem, pemBudget: budget };
|
|
271
|
+
}
|
|
272
|
+
/** Where a still-growing secret starts at the end of `v` (v.length when none). */
|
|
273
|
+
function holdPoint(v, state) {
|
|
274
|
+
let lineStart = Math.max(v.lastIndexOf('\n'), v.lastIndexOf('\r')) + 1;
|
|
275
|
+
if (state.inPem) {
|
|
276
|
+
// Hold the unfinished key line so an END marker is never split.
|
|
277
|
+
return v.length - lineStart <= 256 ? lineStart : v.length;
|
|
278
|
+
}
|
|
279
|
+
lineStart = Math.max(lineStart, v.length - TAIL_WINDOW);
|
|
280
|
+
const tail = v.slice(lineStart);
|
|
281
|
+
let hold = v.length;
|
|
282
|
+
const at = (m, group) => m ? lineStart + m.index + m[0].length - m[group].length : v.length;
|
|
283
|
+
const a = TRAIL_ASSIGN_RE.exec(tail);
|
|
284
|
+
if (a && a[2] && isSecretName(a[1]))
|
|
285
|
+
hold = Math.min(hold, at(a, 2));
|
|
286
|
+
const f = TRAIL_FLAG_RE.exec(tail);
|
|
287
|
+
if (f && f[2] && isSecretName(f[1]))
|
|
288
|
+
hold = Math.min(hold, at(f, 2));
|
|
289
|
+
const b = TRAIL_BEARER_RE.exec(tail);
|
|
290
|
+
if (b && b[1])
|
|
291
|
+
hold = Math.min(hold, at(b, 1));
|
|
292
|
+
const u = TRAIL_URL_RE.exec(tail);
|
|
293
|
+
if (u && u[1])
|
|
294
|
+
hold = Math.min(hold, at(u, 1));
|
|
295
|
+
const run = TRAIL_RUN_RE.exec(tail);
|
|
296
|
+
if (run && KNOWN_PREFIXES.some((p) => run[0].startsWith(p))) {
|
|
297
|
+
hold = Math.min(hold, lineStart + run.index + Math.min(4, run[0].length));
|
|
298
|
+
}
|
|
299
|
+
return hold;
|
|
300
|
+
}
|
|
301
|
+
/** In-progress values at the very end, masked on a final flush (typed secrets). */
|
|
302
|
+
function trailingInProgress(v, out) {
|
|
303
|
+
const lineStart = Math.max(Math.max(v.lastIndexOf('\n'), v.lastIndexOf('\r')) + 1, v.length - TAIL_WINDOW);
|
|
304
|
+
const tail = v.slice(lineStart);
|
|
305
|
+
const a = TRAIL_ASSIGN_RE.exec(tail) ?? TRAIL_FLAG_RE.exec(tail);
|
|
306
|
+
if (a && a[2] && isSecretName(a[1]) &&
|
|
307
|
+
plausibleValue(a[2], { minLen: 3, needDigit: false }) && (/\d/.test(a[2]) || a[2].length >= 8)) {
|
|
308
|
+
const s = lineStart + a.index + a[0].length - a[2].length;
|
|
309
|
+
pushSpan(out, s, v.length, 'assignment');
|
|
310
|
+
}
|
|
311
|
+
const run = TRAIL_RUN_RE.exec(tail);
|
|
312
|
+
if (run && run[0].length > 4 && KNOWN_PREFIXES.some((p) => run[0].startsWith(p) && run[0].length > p.length)) {
|
|
313
|
+
pushSpan(out, lineStart + run.index + 4, v.length, 'prefixed');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function scan(v, boundary, state, final, maskTrailing = false) {
|
|
317
|
+
const spans = [];
|
|
318
|
+
detectStatic(v, spans);
|
|
319
|
+
const next = detectPem(v, boundary, state, spans);
|
|
320
|
+
let hold = v.length;
|
|
321
|
+
if (!final)
|
|
322
|
+
hold = Math.max(boundary, holdPoint(v, next));
|
|
323
|
+
else if (maskTrailing)
|
|
324
|
+
trailingInProgress(v, spans);
|
|
325
|
+
return { spans: spans.filter((s) => s.end > boundary), hold, state: next };
|
|
326
|
+
}
|
|
327
|
+
/** Spans a plain (escape-free) string would mask, sorted and merged. */
|
|
328
|
+
function findSecrets(text) {
|
|
329
|
+
const { spans } = scan(text, 0, { inPem: false, pemBudget: 0 }, true);
|
|
330
|
+
spans.sort((x, y) => x.start - y.start);
|
|
331
|
+
const merged = [];
|
|
332
|
+
for (const s of spans) {
|
|
333
|
+
const last = merged[merged.length - 1];
|
|
334
|
+
if (last && s.start <= last.end)
|
|
335
|
+
last.end = Math.max(last.end, s.end);
|
|
336
|
+
else
|
|
337
|
+
merged.push({ ...s });
|
|
338
|
+
}
|
|
339
|
+
return merged;
|
|
340
|
+
}
|
|
341
|
+
/** True when `text` holds anything findSecrets would mask. */
|
|
342
|
+
function containsSecret(text) {
|
|
343
|
+
return findSecrets(text).length > 0;
|
|
344
|
+
}
|
|
345
|
+
function maskPlain(text) {
|
|
346
|
+
const spans = findSecrets(text);
|
|
347
|
+
if (spans.length === 0)
|
|
348
|
+
return text;
|
|
349
|
+
let out = '';
|
|
350
|
+
let pos = 0;
|
|
351
|
+
for (const s of spans) {
|
|
352
|
+
out += text.slice(pos, s.start) + exports.MASK_CHAR.repeat(s.end - s.start);
|
|
353
|
+
pos = s.end;
|
|
354
|
+
}
|
|
355
|
+
return out + text.slice(pos);
|
|
356
|
+
}
|
|
357
|
+
const ESC = '\x1b';
|
|
358
|
+
const MAX_OSC = 4096;
|
|
359
|
+
const MAX_STRING_HOLD = 65_536;
|
|
360
|
+
/** Classify a complete CSI by its final byte. */
|
|
361
|
+
function csiVisible(seq) {
|
|
362
|
+
const final = seq[seq.length - 1];
|
|
363
|
+
if (final === 'm')
|
|
364
|
+
return ''; // colours
|
|
365
|
+
if (final === 'C')
|
|
366
|
+
return ' '; // cursor forward stands in for spaces
|
|
367
|
+
if (/[ABDEFGHJKLMPSTXdfr@`ae]/.test(final))
|
|
368
|
+
return '\n'; // moves / erases separate words
|
|
369
|
+
return ''; // modes, queries, sync markers…
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Split raw PTY output into text runs and escape sequences. `inString` says
|
|
373
|
+
* the previous chunk ended inside an opaque string (DCS/APC/…) that has
|
|
374
|
+
* already been passed through; its continuation is consumed opaquely.
|
|
375
|
+
*/
|
|
376
|
+
function tokenize(raw, inString, final) {
|
|
377
|
+
const elements = [];
|
|
378
|
+
let i = 0;
|
|
379
|
+
const findST = (from) => {
|
|
380
|
+
for (let j = from; j < raw.length; j++) {
|
|
381
|
+
if (raw[j] === '\x07')
|
|
382
|
+
return j + 1;
|
|
383
|
+
if (raw[j] === ESC && raw[j + 1] === '\\')
|
|
384
|
+
return j + 2;
|
|
385
|
+
}
|
|
386
|
+
return -1;
|
|
387
|
+
};
|
|
388
|
+
if (inString) {
|
|
389
|
+
const end = findST(0);
|
|
390
|
+
if (end === -1) {
|
|
391
|
+
// Still inside; pass it all through (a lone trailing ESC may begin ST).
|
|
392
|
+
const keep = !final && raw.endsWith(ESC) ? 1 : 0;
|
|
393
|
+
if (raw.length - keep > 0)
|
|
394
|
+
elements.push({ raw: raw.slice(0, raw.length - keep), vis: '', text: false });
|
|
395
|
+
return { elements, rest: raw.slice(raw.length - keep), inString: true };
|
|
396
|
+
}
|
|
397
|
+
elements.push({ raw: raw.slice(0, end), vis: '', text: false });
|
|
398
|
+
i = end;
|
|
399
|
+
}
|
|
400
|
+
let textStart = i;
|
|
401
|
+
const flushText = (to) => {
|
|
402
|
+
if (to > textStart)
|
|
403
|
+
elements.push({ raw: raw.slice(textStart, to), vis: raw.slice(textStart, to), text: true });
|
|
404
|
+
};
|
|
405
|
+
while (i < raw.length) {
|
|
406
|
+
if (raw[i] !== ESC) {
|
|
407
|
+
i++;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
flushText(i);
|
|
411
|
+
const start = i;
|
|
412
|
+
const next = raw[i + 1];
|
|
413
|
+
if (next === undefined) {
|
|
414
|
+
if (final) {
|
|
415
|
+
elements.push({ raw: ESC, vis: '', text: false });
|
|
416
|
+
i++;
|
|
417
|
+
textStart = i;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
return { elements, rest: raw.slice(start), inString: false };
|
|
421
|
+
}
|
|
422
|
+
if (next === '[') {
|
|
423
|
+
let j = i + 2;
|
|
424
|
+
while (j < raw.length && raw.charCodeAt(j) >= 0x20 && raw.charCodeAt(j) <= 0x3f)
|
|
425
|
+
j++;
|
|
426
|
+
if (j >= raw.length) {
|
|
427
|
+
if (final) {
|
|
428
|
+
elements.push({ raw: raw.slice(start), vis: '', text: false });
|
|
429
|
+
i = raw.length;
|
|
430
|
+
textStart = i;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
return { elements, rest: raw.slice(start), inString: false };
|
|
434
|
+
}
|
|
435
|
+
const seq = raw.slice(start, j + 1);
|
|
436
|
+
elements.push({ raw: seq, vis: csiVisible(seq), text: false });
|
|
437
|
+
i = j + 1;
|
|
438
|
+
}
|
|
439
|
+
else if (next === ']') {
|
|
440
|
+
const end = findST(i + 2);
|
|
441
|
+
if (end === -1) {
|
|
442
|
+
if (!final && raw.length - start <= MAX_OSC)
|
|
443
|
+
return { elements, rest: raw.slice(start), inString: false };
|
|
444
|
+
// Too long (or flushing): pass through unmasked as an opaque string.
|
|
445
|
+
elements.push({ raw: raw.slice(start), vis: '', text: false });
|
|
446
|
+
return { elements, rest: '', inString: !final };
|
|
447
|
+
}
|
|
448
|
+
const seq = raw.slice(start, end);
|
|
449
|
+
const termLen = seq.endsWith('\x07') ? 1 : 2;
|
|
450
|
+
const body = seq.slice(2, seq.length - termLen);
|
|
451
|
+
elements.push({ raw: ESC + ']' + maskPlain(body) + seq.slice(seq.length - termLen), vis: '', text: false });
|
|
452
|
+
i = end;
|
|
453
|
+
}
|
|
454
|
+
else if (next === 'P' || next === '_' || next === '^' || next === 'X') {
|
|
455
|
+
const end = findST(i + 2);
|
|
456
|
+
if (end === -1) {
|
|
457
|
+
if (!final && raw.length - start <= MAX_STRING_HOLD)
|
|
458
|
+
return { elements, rest: raw.slice(start), inString: false };
|
|
459
|
+
elements.push({ raw: raw.slice(start), vis: '', text: false });
|
|
460
|
+
return { elements, rest: '', inString: !final };
|
|
461
|
+
}
|
|
462
|
+
elements.push({ raw: raw.slice(start, end), vis: '', text: false });
|
|
463
|
+
i = end;
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
// ESC [intermediates] final — charset selects, save/restore cursor, keypad modes.
|
|
467
|
+
let j = i + 1;
|
|
468
|
+
while (j < raw.length && raw.charCodeAt(j) >= 0x20 && raw.charCodeAt(j) <= 0x2f)
|
|
469
|
+
j++;
|
|
470
|
+
if (j >= raw.length) {
|
|
471
|
+
if (final) {
|
|
472
|
+
elements.push({ raw: raw.slice(start), vis: '', text: false });
|
|
473
|
+
i = raw.length;
|
|
474
|
+
textStart = i;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
return { elements, rest: raw.slice(start), inString: false };
|
|
478
|
+
}
|
|
479
|
+
const seq = raw.slice(start, j + 1);
|
|
480
|
+
elements.push({ raw: seq, vis: next === '7' || next === '8' || next === 'D' || next === 'E' || next === 'M' ? '\n' : '', text: false });
|
|
481
|
+
i = j + 1;
|
|
482
|
+
}
|
|
483
|
+
textStart = i;
|
|
484
|
+
}
|
|
485
|
+
flushText(raw.length);
|
|
486
|
+
return { elements, rest: '', inString: false };
|
|
487
|
+
}
|
|
488
|
+
/** `text` with every position flagged in `masked` (offset by `base`) replaced by MASK_CHAR. */
|
|
489
|
+
function maskText(text, masked, base) {
|
|
490
|
+
if (!masked)
|
|
491
|
+
return text;
|
|
492
|
+
let out = '';
|
|
493
|
+
let from = 0;
|
|
494
|
+
for (let c = 0; c < text.length; c++) {
|
|
495
|
+
if (masked[base + c]) {
|
|
496
|
+
out += text.slice(from, c) + exports.MASK_CHAR;
|
|
497
|
+
from = c + 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return out + text.slice(from);
|
|
501
|
+
}
|
|
502
|
+
/** Visible characters of already-emitted output kept for matching across chunks. */
|
|
503
|
+
const CONTEXT = 512;
|
|
504
|
+
function createStreamRedactor(opts) {
|
|
505
|
+
const idleMs = opts.idleMs ?? 30;
|
|
506
|
+
const maxHold = opts.maxHold ?? 512;
|
|
507
|
+
const maskTrailing = opts.maskTrailing ?? true;
|
|
508
|
+
const setTimer = opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
509
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
|
510
|
+
let pending = '';
|
|
511
|
+
let context = '';
|
|
512
|
+
let inString = false;
|
|
513
|
+
let state = { inPem: false, pemBudget: 0 };
|
|
514
|
+
let timer = null;
|
|
515
|
+
const run = (raw, final) => {
|
|
516
|
+
if (!raw)
|
|
517
|
+
return;
|
|
518
|
+
const tok = tokenize(raw, inString, final);
|
|
519
|
+
let vis = '';
|
|
520
|
+
for (const el of tok.elements)
|
|
521
|
+
vis += el.vis;
|
|
522
|
+
const v = context + vis;
|
|
523
|
+
const boundary = context.length;
|
|
524
|
+
const res = scan(v, boundary, state, final, final && maskTrailing);
|
|
525
|
+
let hold = res.hold;
|
|
526
|
+
if (v.length - hold > maxHold)
|
|
527
|
+
hold = v.length;
|
|
528
|
+
const holdLocal = hold - boundary; // index into vis
|
|
529
|
+
let masked = null;
|
|
530
|
+
for (const s of res.spans) {
|
|
531
|
+
masked ??= new Uint8Array(vis.length);
|
|
532
|
+
for (let k = Math.max(s.start, boundary); k < s.end && k < v.length; k++)
|
|
533
|
+
masked[k - boundary] = 1;
|
|
534
|
+
}
|
|
535
|
+
// Emit elements until the one owning the first held visible char.
|
|
536
|
+
let out = '';
|
|
537
|
+
let held = '';
|
|
538
|
+
let k = 0; // visible index at the start of the current element
|
|
539
|
+
let cut = false;
|
|
540
|
+
for (const el of tok.elements) {
|
|
541
|
+
if (cut) {
|
|
542
|
+
held += el.raw;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const end = k + el.vis.length;
|
|
546
|
+
if (holdLocal < end) {
|
|
547
|
+
// The hold starts inside this element.
|
|
548
|
+
cut = true;
|
|
549
|
+
if (!el.text) {
|
|
550
|
+
held += el.raw;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const off = holdLocal - k;
|
|
554
|
+
out += maskText(el.raw.slice(0, off), masked, k);
|
|
555
|
+
held += el.raw.slice(off);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
out += el.text ? maskText(el.raw, masked, k) : el.raw;
|
|
559
|
+
k = end;
|
|
560
|
+
}
|
|
561
|
+
held += tok.rest;
|
|
562
|
+
// Only state that covers EMITTED text advances; a held tail is rescanned.
|
|
563
|
+
state = holdLocal >= vis.length ? res.state : scan(v.slice(0, hold), boundary, state, true).state;
|
|
564
|
+
inString = tok.inString;
|
|
565
|
+
context = (context + vis.slice(0, Math.max(0, holdLocal))).slice(-CONTEXT);
|
|
566
|
+
pending = held;
|
|
567
|
+
if (out)
|
|
568
|
+
opts.emit(out);
|
|
569
|
+
};
|
|
570
|
+
const stopTimer = () => {
|
|
571
|
+
if (timer !== null) {
|
|
572
|
+
clearTimer(timer);
|
|
573
|
+
timer = null;
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
const flush = () => {
|
|
577
|
+
stopTimer();
|
|
578
|
+
const raw = pending;
|
|
579
|
+
pending = '';
|
|
580
|
+
run(raw, true);
|
|
581
|
+
// A final pass never holds; anything left is an escape it could not finish.
|
|
582
|
+
if (pending) {
|
|
583
|
+
const p = pending;
|
|
584
|
+
pending = '';
|
|
585
|
+
opts.emit(p);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
return {
|
|
589
|
+
push(chunk) {
|
|
590
|
+
if (!chunk)
|
|
591
|
+
return;
|
|
592
|
+
const raw = pending + chunk;
|
|
593
|
+
pending = '';
|
|
594
|
+
run(raw, false);
|
|
595
|
+
if (pending.length > MAX_STRING_HOLD + MAX_OSC) {
|
|
596
|
+
flush();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (pending && timer === null)
|
|
600
|
+
timer = setTimer(() => { timer = null; flush(); }, idleMs);
|
|
601
|
+
if (!pending)
|
|
602
|
+
stopTimer();
|
|
603
|
+
},
|
|
604
|
+
flush,
|
|
605
|
+
dispose() {
|
|
606
|
+
flush();
|
|
607
|
+
stopTimer();
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/** One-shot, ANSI-aware masking of a complete string (logs, replays, screens, files). */
|
|
612
|
+
function redact(text) {
|
|
613
|
+
if (!text)
|
|
614
|
+
return text;
|
|
615
|
+
let out = '';
|
|
616
|
+
const r = createStreamRedactor({ emit: (s) => { out += s; }, maskTrailing: false, setTimer: () => null, clearTimer: () => { } });
|
|
617
|
+
r.push(text);
|
|
618
|
+
r.flush();
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* redact() for large text (a multi-MB transcript) without blocking the event
|
|
623
|
+
* loop: the same stream redactor fed in slices, yielding between them so live
|
|
624
|
+
* terminal frames keep flowing. Same result as redact(text).
|
|
625
|
+
*/
|
|
626
|
+
async function redactInSlices(text, sliceChars = 1 << 20) {
|
|
627
|
+
if (text.length <= sliceChars)
|
|
628
|
+
return redact(text);
|
|
629
|
+
let out = '';
|
|
630
|
+
const r = createStreamRedactor({ emit: (s) => { out += s; }, maskTrailing: false, setTimer: () => null, clearTimer: () => { } });
|
|
631
|
+
for (let i = 0; i < text.length; i += sliceChars) {
|
|
632
|
+
r.push(text.slice(i, i + sliceChars));
|
|
633
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
634
|
+
}
|
|
635
|
+
r.flush();
|
|
636
|
+
return out;
|
|
637
|
+
}
|