@klarkxy/dsh-safe-auto 0.2.3 → 0.4.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.
@@ -1,227 +0,0 @@
1
- import { isAbsolute } from 'node:path';
2
- import { credentialSourcePath, isCredentialPath, isStructuredCredentialText, isMutationProtectedPath } from './credential-paths.js';
3
- import { redactCredentialText } from './credential-text.js';
4
- import { analyzeShell, isSafeStaticExposure, shellRefusals } from './shell-command.js';
5
-
6
- const MAX_SOURCE_BYTES = 1024 * 1024;
7
- const MAX_WINDOW_BYTES = 8 * MAX_SOURCE_BYTES;
8
- const errorResult = code => {
9
- const message = `safe-auto: ${code}. Use the native read tool for non-secret file content; credentials are withheld.`;
10
- return { isError: true, error: { message, info: { name: 'SafeAutoError', code } }, content: [{ type: 'text', text: message }] };
11
- };
12
- const failure = () => { throw new Error('CREDENTIAL_READ_UNAVAILABLE'); };
13
- const stripCR = line => line.endsWith('\r') ? line.slice(0, -1) : line;
14
- const fresh = value => ({ isError: false, value, content: [] });
15
- const identity = (fs, target) => `${target.targetKey ?? ''}\0${fs.processPath(target)}`;
16
- const fsAvailable = fs => fs && ['resolve', 'stat', 'readBytes', 'processPath'].every(key => typeof fs[key] === 'function');
17
- const textRead = exec => exec.name === 'read' || exec.name === 'str_replace_editor' && exec.arguments?.command === 'view';
18
- const cwdOf = exec => exec.name === 'str_replace_editor' ? undefined : exec.agent?.session?.header?.cwd;
19
-
20
- async function resolveFile(fs, path, exec, cwd = cwdOf(exec)) {
21
- const target = await fs.resolve(path, { cwd, signal: exec.signal });
22
- if (isCredentialPath(path) || isCredentialPath(fs.processPath(target))) throw new Error('CREDENTIAL_PATH');
23
- return { target, path, key: identity(fs, target), info: await fs.stat(target, exec.signal) };
24
- }
25
-
26
- async function unchanged(fs, saved, exec) {
27
- const next = await resolveFile(fs, saved.path, exec);
28
- if (saved.key !== next.key || saved.info?.version !== next.info?.version || saved.info?.type !== next.info?.type) failure();
29
- }
30
-
31
- async function wholeSource(fs, saved, exec) {
32
- const bytes = await fs.readBytes(saved.target, exec.signal, MAX_SOURCE_BYTES);
33
- if (!(bytes instanceof Uint8Array) || bytes.length > MAX_SOURCE_BYTES) failure();
34
- const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
35
- if (source.includes('\0')) failure();
36
- const sanitized = redactCredentialText(source, fs.processPath(saved.target));
37
- await unchanged(fs, saved, exec);
38
- return { source, sanitized };
39
- }
40
-
41
- // Preserve the host's exact window and configured caps. A truncated native
42
- // line is matched to its original prefix, never independently parsed as JSON.
43
- function projectLine(raw, safe, rendered) {
44
- raw = stripCR(raw); safe = stripCR(safe);
45
- if (rendered === raw) return safe;
46
- const suffix = /\.\.\. \(line truncated to (\d+) chars\)$/.exec(rendered);
47
- const cap = suffix && Number(suffix[1]);
48
- if (!cap || raw.length <= cap || rendered !== raw.slice(0, cap) + suffix[0]) failure();
49
- return safe.slice(0, cap) + suffix[0];
50
- }
51
-
52
- async function windowSource(fs, saved, value, exec) {
53
- const name = credentialSourcePath(fs.processPath(saved.target));
54
- const jsonl = /\.(?:jsonl|ndjson)$/i.test(name);
55
- if (isStructuredCredentialText(name) && !jsonl || typeof fs.streamText !== 'function') failure();
56
- const wanted = new Set(value.lines.map(line => line.number));
57
- const selected = new Map();
58
- let pending = '', lineNumber = 0, bytes = 0, inPrivateKey = false;
59
- const consume = () => {
60
- lineNumber++;
61
- const raw = pending; pending = '';
62
- if (raw.includes('\0')) failure();
63
- if (!jsonl && /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(raw)) inPrivateKey = true;
64
- if (wanted.has(lineNumber)) {
65
- bytes += Buffer.byteLength(raw);
66
- if (bytes > MAX_WINDOW_BYTES) failure();
67
- const safe = inPrivateKey ? raw.replace(/[^\s]/g, '*') : redactCredentialText(raw, jsonl ? 'session.jsonl' : name).text;
68
- selected.set(lineNumber, { raw, safe });
69
- }
70
- if (!jsonl && /-----END [A-Z ]*PRIVATE KEY-----/.test(raw)) inPrivateKey = false;
71
- };
72
- for await (const chunk of await fs.streamText(saved.target, exec.signal)) {
73
- exec.signal.throwIfAborted();
74
- if (typeof chunk !== 'string') failure();
75
- const pieces = chunk.split('\n');
76
- for (let i = 0; i < pieces.length; i++) {
77
- pending += pieces[i];
78
- // Complete JSONL lines are bounded independently; the overall session
79
- // may be arbitrarily large. A too-long selected line is withheld.
80
- if (pending.length > MAX_SOURCE_BYTES) {
81
- if (!jsonl || wanted.has(lineNumber + 1)) failure();
82
- pending = pending.slice(0, MAX_SOURCE_BYTES);
83
- }
84
- if (i < pieces.length - 1) consume();
85
- }
86
- }
87
- if (pending.length) consume();
88
- if (lineNumber !== value.totalLines) failure();
89
- await unchanged(fs, saved, exec);
90
- return selected;
91
- }
92
-
93
- async function projectRead(fs, saved, result, exec) {
94
- const value = result.value;
95
- if (!value || !Array.isArray(value.lines) || typeof value.path !== 'string' || !Number.isInteger(value.totalLines)) failure();
96
- let selected;
97
- if (saved.info.size === undefined || saved.info.size > MAX_SOURCE_BYTES) selected = await windowSource(fs, saved, value, exec);
98
- else {
99
- const { source, sanitized } = await wholeSource(fs, saved, exec);
100
- const raw = source.split('\n'), safe = sanitized.text.split('\n');
101
- const total = source ? raw.length - Number(source.endsWith('\n')) : 0;
102
- if (total !== value.totalLines) failure();
103
- selected = new Map(value.lines.map(({ number }) => [number, { raw: raw[number - 1], safe: safe[number - 1] }]));
104
- }
105
- const lines = value.lines.map(({ number, text }) => {
106
- const line = selected.get(number);
107
- if (!line || typeof line.raw !== 'string' || typeof text !== 'string') failure();
108
- return { number, text: projectLine(line.raw, line.safe, text) };
109
- });
110
- return fresh({ path: value.path, offset: value.offset, lines, totalLines: value.totalLines });
111
- }
112
-
113
- async function projectGrep(fs, result, exec) {
114
- if (!Array.isArray(result.value?.matches)) failure();
115
- const sources = new Map();
116
- const matches = [];
117
- for (const match of result.value.matches) {
118
- exec.signal.throwIfAborted();
119
- if (typeof match.path !== 'string' || !Number.isInteger(match.lineNumber) || typeof match.line !== 'string') failure();
120
- let lines = sources.get(match.path);
121
- if (!lines) {
122
- try {
123
- const saved = await resolveFile(fs, match.path, exec);
124
- if (saved.info?.type !== 'file' || saved.info.size > MAX_SOURCE_BYTES || sources.size >= 64) failure();
125
- const { source, sanitized } = await wholeSource(fs, saved, exec);
126
- lines = { raw: source.split('\n'), safe: sanitized.text.split('\n') };
127
- } catch { lines = { withheld: true }; }
128
- sources.set(match.path, lines);
129
- }
130
- const raw = lines.raw?.[match.lineNumber - 1];
131
- const coherent = typeof raw === 'string' && stripCR(raw) === match.line;
132
- matches.push({ path: match.path, lineNumber: match.lineNumber,
133
- line: !lines.withheld && coherent ? stripCR(lines.safe[match.lineNumber - 1]) : '[content withheld; use native read]' });
134
- }
135
- return fresh({ matches });
136
- }
137
-
138
- /** Native tool registrations and their filesystem provider are trusted host
139
- * components. Bind global definitions so scope shadows/replacements cannot
140
- * inherit the verified 0.2.0-rc.2 read/grep pipeline contract.
141
- * Read has no body-owned raw persistence; grep spills only in post-execute.
142
- * Shell jobs persist before around-return, so known secret inputs are refused
143
- * BEFORE their body. This is not an OS sandbox for arbitrary scripts.
144
- */
145
- export function createCredentialReads(ctx, lifetimeSignal) {
146
- const owners = new Map();
147
- const owner = exec => {
148
- const current = ctx.tools?.get?.(exec.name);
149
- if (!current) return false;
150
- if (!owners.has(exec.name)) owners.set(exec.name, current);
151
- return owners.get(exec.name) === current && ctx.tools.get(exec.name, exec.agent) === current;
152
- };
153
- async function shellCheck(fs, exec) {
154
- const command = exec.arguments?.command;
155
- if (typeof command !== 'string') return;
156
- if (isSafeStaticExposure(analyzeShell(command))) return;
157
- const values = new Set([...shellRefusals(command).values,
158
- ...(command.match(/"[^"\r\n]+"|'[^'\r\n]+'|[^\s;&|<>]+/g) ?? []).map(value => value.replace(/^["']|["']$/g, ''))]);
159
- let count = 0;
160
- for (const path of values) {
161
- if (isCredentialPath(path)) throw new Error('CREDENTIAL_PATH');
162
- if (!isAbsolute(path) && !/^[A-Za-z]:[\\/]/.test(path)) continue;
163
- if (++count > 32) throw new Error('CREDENTIAL_READ_UNAVAILABLE');
164
- // Persistent shells may have a different cwd; only absolute operands
165
- // have a provable target here. Relative/opaque commands stay reviewed.
166
- let saved;
167
- try { saved = await resolveFile(fs, path, exec); }
168
- catch (error) { if (error.message === 'CREDENTIAL_PATH') throw error; continue; }
169
- if (saved.info?.type !== 'file') continue;
170
- try {
171
- const { sanitized } = await wholeSource(fs, saved, exec);
172
- if (sanitized.redacted) throw new Error('USE_SAFE_READ');
173
- } catch (error) {
174
- if (error.message === 'USE_SAFE_READ') throw error;
175
- if (isStructuredCredentialText(path)) throw new Error('USE_SAFE_READ');
176
- }
177
- }
178
- }
179
- return { async execute(exec, next) {
180
- if (lifetimeSignal) exec = { ...exec, signal: AbortSignal.any([exec.signal, lifetimeSignal]) };
181
- const fs = typeof ctx.get === 'function' ? ctx.get('fs') : ctx.fs;
182
- // Hosts without the FS seam still retain the lexical hard-risk policy;
183
- // no filesystem projection or extra exposure is claimed for custom tools.
184
- if (!fsAvailable(fs)) return next();
185
- const fileTool = textRead(exec) || ['read_image', 'write', 'edit', 'str_replace_editor'].includes(exec.name);
186
- const grep = exec.name === 'grep';
187
- const shell = ['bash', 'pwsh', 'shell'].includes(exec.name);
188
- if (!fileTool && !grep && !shell) return next();
189
- try {
190
- let saved;
191
- const path = exec.arguments?.file_path ?? exec.arguments?.path;
192
- if (fileTool && typeof path === 'string') {
193
- const cwd = ['write', 'edit'].includes(exec.name)
194
- ? ctx.sandboxPolicy?.resolve({ session: exec.agent?.session })?.workspaceRoot ?? cwdOf(exec)
195
- : cwdOf(exec);
196
- saved = await resolveFile(fs, path, exec, cwd);
197
- }
198
- if (saved && !textRead(exec) && exec.name !== 'read_image' && isMutationProtectedPath(fs.processPath(saved.target))) throw new Error('CREDENTIAL_PATH');
199
- if (grep && typeof path === 'string') await resolveFile(fs, path, exec);
200
- if (shell) await shellCheck(fs, exec);
201
- if (textRead(exec) && saved?.info?.type === 'file' || grep) {
202
- if (!owner(exec)) throw new Error('CREDENTIAL_READER_CHANGED');
203
- }
204
- if (textRead(exec) && exec.name === 'str_replace_editor' && saved?.info?.type === 'file') {
205
- const { sanitized } = await wholeSource(fs, saved, exec);
206
- if (sanitized.redacted) throw new Error('USE_SAFE_READ');
207
- }
208
- exec.signal.throwIfAborted();
209
- const result = await next();
210
- if (result?.isError) return result;
211
- exec.signal.throwIfAborted();
212
- if (exec.name === 'read' && saved?.info?.type === 'file') {
213
- if (!owner(exec)) failure();
214
- return await projectRead(fs, saved, result, exec);
215
- }
216
- if (grep) {
217
- if (!owner(exec)) failure();
218
- return await projectGrep(fs, result, exec);
219
- }
220
- if (exec.name === 'str_replace_editor' && textRead(exec) && saved) await unchanged(fs, saved, exec);
221
- return result;
222
- } catch (error) {
223
- if (exec.signal.aborted) return errorResult('CANCELLED');
224
- return errorResult(['CREDENTIAL_PATH', 'USE_SAFE_READ', 'CREDENTIAL_READER_CHANGED'].includes(error.message) ? error.message : 'CREDENTIAL_READ_UNAVAILABLE');
225
- }
226
- } };
227
- }
@@ -1,361 +0,0 @@
1
- import { createScanner, parseTree, SyntaxKind } from 'jsonc-parser';
2
- import { isAlias, isMap, isScalar, isSeq, parseAllDocuments, Parser } from 'yaml';
3
- import { parseTOML } from 'toml-eslint-parser';
4
- import { Buffer } from 'node:buffer';
5
- import { credentialSourcePath } from './credential-paths.js';
6
-
7
- const SAFE_ERROR = 'Credential text could not be safely redacted.';
8
- const fail = () => { throw new Error(SAFE_ERROR); };
9
- const whitespace = character => /\s/u.test(character);
10
-
11
- /** Match credential names, rather than words such as model, keybindings or token limits. */
12
- export function isCredentialField(name) {
13
- if (typeof name !== 'string') return false;
14
- const normalized = name.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
15
- .replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase()
16
- .replace(/[^a-z\d]+/g, '_').replace(/^_+|_+$/g, '');
17
- return /(?:^|[/:])_auth$/i.test(name) || /(?:^|_)(?:api_key|apikey|auth_token|access_token|refresh_token|id_token|api_token|bearer_token|session_token|security_token|personal_access_token|client_secret|client_key_data|secret_key|secret_access_key|access_key_id|access_key|private_key|privatekey|password|passwd|passphrase|authorization|proxy_authorization|cookie|set_cookie|secret|token)$/.test(normalized);
18
- }
19
-
20
- function isReference(value) {
21
- const text = value.trim();
22
- const header = /^(?:Bearer|Basic)[ \t]+(.+)$/i.exec(text);
23
- if (header) return isReference(header[1]);
24
- return !text || /^(?:\*+|\.{3}|\[redacted\]|<redacted>|redacted|null|none|undefined)$/i.test(text)
25
- || /^(?:\$\{(?:env:)?[\w.]+\}|\$(?:env:)?[\w.]+|%[\w]+%|\$?\{\{\s*(?:env|secrets)\.[\w.]+\s*\}\}|(?:env:|process\.env\.)[\w.]+)$/i.test(text)
26
- || /^<(?:your[_ -]?)?(?:api[_ -]?key|token|password|secret|private[_ -]?key)>$/i.test(text)
27
- || /^<[A-Z][A-Z\d_]*>$/.test(text)
28
- || /^(?:(?:your|example|placeholder|dummy|fake|test)[_-])?(?:api[_-]?key|token|password|secret)(?:[_-](?:here|placeholder|example))?$/i.test(text)
29
- || /^(?:replace[-_ ]?me|change[-_ ]?me|placeholder|dummy|example|not[-_ ]a[-_ ]real[-_ ](?:key|token|secret)|sk-(?:placeholder|your[-_]?(?:key|api[-_]?key)|example))$/i.test(text)
30
- || /^(?:\.{1,2}[\\/]|~[\\/]|[A-Za-z]:[\\/]|file:\/\/)[^\r\n]+$/.test(text)
31
- || /^(?:[\\/]|\.[\w-]+[\\/])[^\r\n]*\.(?:jsonc?|ya?ml|toml|pem|key|txt|env)$/i.test(text);
32
- }
33
-
34
- function addRange(ranges, start, end) {
35
- if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) fail();
36
- if (end > start) ranges.push([start, end]);
37
- }
38
-
39
- function literalRanges(text) {
40
- const ranges = [];
41
- // URL parsing validates the authority; source offsets preserve the original
42
- // username, host, path and percent encoding without URL reserialization.
43
- for (const match of text.matchAll(/\b[A-Za-z][A-Za-z\d+.-]*:\/\//g)) {
44
- const start = match.index + match[0].length;
45
- const tail = text.slice(start);
46
- const authorityEnd = /[/?#\\\s"'`<>]/.exec(tail)?.index ?? tail.length;
47
- const authority = tail.slice(0, authorityEnd);
48
- const at = authority.lastIndexOf('@');
49
- const colon = authority.indexOf(':');
50
- if (at < 0 || colon < 0 || colon >= at || colon + 1 === at) continue;
51
- try { new URL(match[0] + authority + '/'); } catch { fail(); }
52
- const password = authority.slice(colon + 1, at);
53
- let decoded = password;
54
- try { decoded = decodeURIComponent(password); } catch { /* Invalid percent escapes are still password payload. */ }
55
- if (!isReference(decoded)) addRange(ranges, start + colon + 1, start + at);
56
- }
57
- const token = /\b(?:sk-[A-Za-z\d_-]{8,}|gh[pousr]_[A-Za-z\d_]{8,}|github_pat_[A-Za-z\d_]{8,}|glpat-[A-Za-z\d_-]{8,}|xox[baprs]-[A-Za-z\d_-]{8,}|npm_[A-Za-z\d]{8,}|(?:AKIA|ASIA)[A-Z\d]{16}|AIza[A-Za-z\d_-]{20,}|SG\.[A-Za-z\d_-]{8,}\.[A-Za-z\d_-]{8,}|eyJ[A-Za-z\d_-]{8,}\.[A-Za-z\d_-]{8,}\.[A-Za-z\d_-]{8,})(?![A-Za-z\d_-])/g;
58
- for (const match of text.matchAll(token)) {
59
- if (!isReference(match[0])) addRange(ranges, match.index, match.index + match[0].length);
60
- }
61
- for (const match of text.matchAll(/\bBearer[ \t]+([^\s"'`,;<>]+)/gi)) {
62
- if (!isReference(match[1])) {
63
- const start = match.index + match[0].length - match[1].length;
64
- addRange(ranges, start, start + match[1].length);
65
- }
66
- }
67
- for (const match of text.matchAll(/\bBasic[ \t]+([A-Za-z\d+/]+={0,2})(?![A-Za-z\d+/=])/gi)) {
68
- const payload = match[1];
69
- const bytes = Buffer.from(payload, 'base64');
70
- const colon = bytes.indexOf(0x3a);
71
- // A Basic credential is base64(user:password), not any word after Basic.
72
- if (bytes.toString('base64').replace(/=+$/, '') !== payload.replace(/=+$/, '')
73
- || colon < 0 || colon + 1 >= bytes.length || bytes.some(byte => byte < 0x20 || byte === 0x7f)) continue;
74
- const start = match.index + match[0].length - payload.length;
75
- addRange(ranges, start, start + payload.length);
76
- }
77
- const pem = /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY)-----/g;
78
- for (const match of text.matchAll(pem)) {
79
- const start = match.index + match[0].length;
80
- const end = text.indexOf(`-----END ${match[1]}-----`, start);
81
- if (end < 0) fail();
82
- addRange(ranges, start, end);
83
- }
84
- return ranges;
85
- }
86
-
87
- // ENV/INI have no shared grammar. This scanner only recognizes assignments to
88
- // credential names; comments and all other keys retain their original bytes.
89
- function assignmentRanges(text, mode = 'plain') {
90
- const ranges = [];
91
- const assignment = mode === 'config'
92
- ? /^[ \t]*(?:[#;][ \t]*)?(?:export[ \t]+)?(?:((?:[A-Za-z_]|\/\/)[\w./:@-]*)|"([^"\r\n]+)"|'([^'\r\n]+)')[ \t]*[=:][ \t]*/gm
93
- : /(?:^|[\s{,;])(?:((?:\$?(?:env:)?)[A-Za-z_][\w.-]*)|"((?:\\.|[^"\\\r\n])+)"|'([^'\r\n]+)')[ \t]*([=:])[ \t]*/gm;
94
- for (const match of text.matchAll(assignment)) {
95
- let name = match[1] ?? match[2] ?? match[3];
96
- if (mode === 'plain' && match[2]) {
97
- try { name = JSON.parse(`"${name}"`); } catch { continue; }
98
- }
99
- if (!isCredentialField(name)) continue;
100
- const start = match.index + match[0].length;
101
- // In ordinary prose, an authorization heading may describe permission.
102
- if (mode === 'plain' && /(?:authorization|cookie)$/i.test(name) && match[4] === ':'
103
- && !/^(?:["']|Bearer\b|Basic\b|[\w-]+=)/i.test(text.slice(start))) continue;
104
- const quote = text[start];
105
- let end, contentStart = start;
106
- if (quote === '"' || quote === "'") {
107
- contentStart++;
108
- end = contentStart;
109
- while (end < text.length) {
110
- if (text[end] === quote) break;
111
- if (text[end] === '\\' && quote === '"') end++;
112
- end++;
113
- }
114
- if (end >= text.length) fail();
115
- if (mode === 'config') {
116
- const nextLine = text.indexOf('\n', end);
117
- const remainder = text.slice(end + 1, nextLine < 0 ? text.length : nextLine).trim();
118
- if (remainder && !/^[#;]/.test(remainder)) fail();
119
- }
120
- } else {
121
- end = text.indexOf('\n', start);
122
- if (end < 0) end = text.length;
123
- const tail = text.slice(start, end);
124
- if (mode === 'config') {
125
- const stop = /[ \t]+[#;]/.exec(tail);
126
- if (stop) end = start + stop.index;
127
- } else if (!/(?:^|[_.-])cookie$/i.test(name)) {
128
- const prefix = /^(?:(?:Bearer|Basic)[ \t]+)?(?:\$\{[^}\r\n]+\}|\{\{[^}\r\n]+\}\}|[^\s,;}]+)/i.exec(tail);
129
- if (prefix) end = start + prefix[0].length;
130
- }
131
- }
132
- const value = text.slice(contentStart, end).trimEnd();
133
- let decoded = value;
134
- if (quote === '"') {
135
- try { decoded = JSON.parse(`"${value}"`); } catch { /* ENV permits literal newlines and non-JSON escapes. */ }
136
- }
137
- if (!isReference(decoded)) addRange(ranges, contentStart, contentStart + value.length);
138
- }
139
- return ranges;
140
- }
141
-
142
- // The parser owns the grammar and decoded value. This only maps decoded scalar
143
- // characters back to source spans, so escaped literals can be masked in place.
144
- function sourceUnits(text, start, end, style) {
145
- const units = [];
146
- const simple = style === 'yaml' ? { '0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029' }
147
- : { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '/': '/', '\\': '\\' };
148
- for (let i = start; i < end; i++) {
149
- const begin = i;
150
- let decoded = text[i];
151
- if (style === 'yaml-single' && text[i] === "'" && text[i + 1] === "'") i++;
152
- else if (['json', 'toml', 'yaml'].includes(style) && text[i] === '\\') {
153
- const escaped = text[++i];
154
- const length = escaped === 'u' ? 4 : escaped === 'U' && style !== 'json' ? 8 : escaped === 'x' && style === 'yaml' ? 2 : 0;
155
- if (length) {
156
- const digits = text.slice(i + 1, i + length + 1);
157
- if (digits.length !== length || !/^[\da-f]+$/i.test(digits)) fail();
158
- const code = Number.parseInt(digits, 16);
159
- if (code > 0x10ffff) fail();
160
- decoded = String.fromCodePoint(code); i += length;
161
- } else if (Object.hasOwn(simple, escaped)) decoded = simple[escaped];
162
- else if (style !== 'json' && /\s/u.test(escaped)) {
163
- while (i + 1 < end && /\s/u.test(text[i + 1])) i++;
164
- continue;
165
- } else fail();
166
- }
167
- for (let j = 0; j < decoded.length; j++) units.push({ character: decoded[j], start: begin, end: i + 1 });
168
- }
169
- return units;
170
- }
171
-
172
- function maskDecodedLiterals(text, decoded, start, end, style, ranges) {
173
- const matches = [...literalRanges(decoded), ...assignmentRanges(decoded)];
174
- if (!matches.length) return;
175
- const units = sourceUnits(text, start, end, style).filter(unit => !whitespace(unit.character));
176
- const map = new Map();
177
- let position = 0;
178
- for (let i = 0; i < decoded.length; i++) {
179
- if (whitespace(decoded[i])) continue;
180
- if (units[position]?.character !== decoded[i]) fail();
181
- map.set(i, units[position++]);
182
- }
183
- if (position !== units.length) fail();
184
- for (const [from, to] of matches) {
185
- let first, last;
186
- for (let i = from; i < to; i++) {
187
- if (map.has(i)) { first ??= map.get(i); last = map.get(i); }
188
- }
189
- if (first) addRange(ranges, first.start, last.end);
190
- }
191
- }
192
-
193
- function scalar(text, value, start, end, style, sensitive, ranges) {
194
- if (sensitive && typeof value === 'string' && !isReference(value)) addRange(ranges, start, end);
195
- else if (typeof value === 'string') maskDecodedLiterals(text, value, start, end, style, ranges);
196
- else if (sensitive && value != null && typeof value !== 'boolean') addRange(ranges, start, end);
197
- }
198
-
199
- function jsonRanges(text, ranges, offset = 0) {
200
- const errors = [];
201
- const root = parseTree(text, errors, { allowTrailingComma: true, disallowComments: false, allowEmptyContent: true });
202
- if (errors.length) fail();
203
- const local = [];
204
- const queue = root ? [[root, false, []]] : [];
205
- while (queue.length) {
206
- const [node, sensitive, path] = queue.pop();
207
- if (node.type === 'object') {
208
- for (const property of node.children ?? []) {
209
- if (property.type !== 'property' || property.children?.length !== 2) fail();
210
- const [key, value] = property.children;
211
- // Docker's auths registry entries use an otherwise ordinary auth key.
212
- const registryCredential = path.length === 2 && path[0] === 'auths'
213
- && ['auth', 'identitytoken', 'registrytoken'].includes(key.value);
214
- queue.push([value, sensitive || registryCredential || isCredentialField(key.value), [...path, key.value]]);
215
- }
216
- } else if (node.type === 'array') {
217
- for (const [index, child] of (node.children ?? []).entries()) queue.push([child, sensitive, [...path, index]]);
218
- } else {
219
- const quoted = node.type === 'string';
220
- scalar(text, node.value, node.offset + Number(quoted), node.offset + node.length - Number(quoted), 'json', sensitive, local);
221
- }
222
- }
223
- const scanner = createScanner(text, false);
224
- for (let kind = scanner.scan(); kind !== SyntaxKind.EOF; kind = scanner.scan()) {
225
- if ([SyntaxKind.LineCommentTrivia, SyntaxKind.BlockCommentTrivia].includes(kind)) {
226
- const start = scanner.getTokenOffset(), comment = text.slice(start, start + scanner.getTokenLength());
227
- for (const [from, to] of literalRanges(comment)) addRange(local, start + from, start + to);
228
- for (const [from, to] of assignmentRanges(comment)) addRange(local, start + from, start + to);
229
- }
230
- }
231
- for (const [start, end] of local) addRange(ranges, start + offset, end + offset);
232
- }
233
-
234
- function yamlRanges(text, ranges) {
235
- const docs = parseAllDocuments(text, { keepSourceTokens: true, strict: true, uniqueKeys: true });
236
- for (const doc of docs) {
237
- if (doc.errors.length || doc.warnings.length) fail();
238
- const seen = new WeakMap();
239
- const queue = doc.contents ? [[doc.contents, false]] : [];
240
- const keyName = key => {
241
- if (isAlias(key)) key = key.resolve(doc);
242
- if (!isScalar(key)) fail();
243
- return String(key.value ?? '');
244
- };
245
- while (queue.length) {
246
- const [node, sensitive] = queue.pop();
247
- if (!node) continue;
248
- const bit = sensitive ? 2 : 1;
249
- if ((seen.get(node) ?? 0) & bit) continue;
250
- seen.set(node, (seen.get(node) ?? 0) | bit);
251
- if (isAlias(node)) {
252
- const origin = node.resolve(doc);
253
- if (!origin) fail();
254
- queue.push([origin, sensitive]);
255
- } else if (isMap(node)) {
256
- for (const pair of node.items) queue.push([pair.value, sensitive || isCredentialField(keyName(pair.key))]);
257
- } else if (isSeq(node)) {
258
- for (const item of node.items) queue.push([item, sensitive]);
259
- } else if (isScalar(node)) {
260
- if (!node.range) fail();
261
- let [start, end] = node.range;
262
- let style = 'literal';
263
- if (node.type === 'QUOTE_DOUBLE' || node.type === 'QUOTE_SINGLE') {
264
- start++; end--; style = node.type === 'QUOTE_DOUBLE' ? 'yaml' : 'yaml-single';
265
- } else if (node.type === 'BLOCK_LITERAL' || node.type === 'BLOCK_FOLDED') {
266
- if (node.srcToken?.type !== 'block-scalar') fail();
267
- start = node.srcToken.offset + node.srcToken.props.reduce((total, prop) => total + prop.source.length, 0);
268
- end = start + node.srcToken.source.length;
269
- }
270
- if (node.value !== null && !['string', 'number', 'boolean', 'undefined'].includes(typeof node.value)) fail();
271
- scalar(text, node.value, start, end, style, sensitive, ranges);
272
- } else fail();
273
- }
274
- }
275
- // Comments are outside AST value ranges. Restrict recognition to comment text.
276
- // A # within a quoted or block value must not be misread as a comment.
277
- const comments = [];
278
- const collect = token => {
279
- if (!token || typeof token !== 'object') return;
280
- if (token.type === 'comment') comments.push(token);
281
- for (const [key, value] of Object.entries(token)) {
282
- if (key === 'source') continue;
283
- if (Array.isArray(value)) value.forEach(collect);
284
- else if (value && typeof value === 'object') collect(value);
285
- }
286
- };
287
- for (const token of new Parser().parse(text)) collect(token);
288
- for (const token of comments) {
289
- for (const [start, end] of [...literalRanges(token.source), ...assignmentRanges(token.source)]) {
290
- addRange(ranges, token.offset + start, token.offset + end);
291
- }
292
- }
293
- }
294
-
295
- function tomlRanges(text, ranges) {
296
- const root = parseTOML(text, { tomlVersion: '1.0' });
297
- const queue = [[root, false]];
298
- while (queue.length) {
299
- const [node, sensitive] = queue.pop();
300
- if (node.type === 'TOMLKeyValue') {
301
- const names = node.key.keys.map(key => key.name ?? key.value);
302
- queue.push([node.value, sensitive || names.some(isCredentialField)]);
303
- } else if (node.type === 'TOMLTable') {
304
- const inherited = sensitive || node.resolvedKey.some(isCredentialField);
305
- for (const child of node.body) queue.push([child, inherited]);
306
- } else if (['Program', 'TOMLTopLevelTable', 'TOMLInlineTable'].includes(node.type)) {
307
- for (const child of node.body) queue.push([child, sensitive]);
308
- } else if (node.type === 'TOMLArray') {
309
- for (const child of node.elements) queue.push([child, sensitive]);
310
- } else if (node.type === 'TOMLValue') {
311
- let [start, end] = node.range;
312
- let style = 'literal';
313
- if (node.kind === 'string') {
314
- const delimiter = node.multiline ? 3 : 1;
315
- start += delimiter; end -= delimiter;
316
- style = node.style === 'basic' ? 'toml' : 'literal';
317
- }
318
- scalar(text, node.value, start, end, style, sensitive, ranges);
319
- } else fail();
320
- }
321
- for (const comment of root.comments) {
322
- const source = text.slice(...comment.range);
323
- for (const [start, end] of [...literalRanges(source), ...assignmentRanges(source)]) {
324
- addRange(ranges, comment.range[0] + start, comment.range[0] + end);
325
- }
326
- }
327
- }
328
-
329
- /** Pure value redaction. Offsets are UTF-16 and every original newline survives. */
330
- export function redactCredentialText(text, filename = '') {
331
- try {
332
- if (typeof text !== 'string' || typeof filename !== 'string' || /\0/.test(text)) fail();
333
- const sourcePath = credentialSourcePath(filename);
334
- const base = sourcePath.split('/').pop();
335
- const ranges = [];
336
- if (/\.(?:jsonl|ndjson)$/.test(base)) {
337
- let offset = 0;
338
- for (const line of text.split('\n')) { jsonRanges(line, ranges, offset); offset += line.length + 1; }
339
- } else if (/\.jsonc?$/.test(base)) jsonRanges(text, ranges);
340
- else if (/\.ya?ml$/.test(base) || /(?:^|\/)\.kube\/config$/.test(sourcePath)) yamlRanges(text, ranges);
341
- else if (/\.toml$/.test(base)) tomlRanges(text, ranges);
342
- else if (/\.(?:json5|xml|plist|hcl|tfvars|edn)$/.test(base)) fail();
343
- else if (/^[\s\uFEFF]*(?:\{|\[\s*(?:\{|"))/.test(text)) jsonRanges(text, ranges);
344
- else {
345
- const config = /^(?:\.env(?:\..*)?|\.npmrc|\.gitconfig|\.pypirc|credentials|config)$/.test(base)
346
- || /\.(?:env|ini|conf|cfg|properties)$/.test(base);
347
- ranges.push(...literalRanges(text), ...assignmentRanges(text, config ? 'config' : 'plain'));
348
- }
349
- const masked = new Uint8Array(text.length);
350
- for (const [start, end] of ranges) {
351
- if (end > text.length) fail();
352
- masked.fill(1, start, end);
353
- }
354
- let redacted = false;
355
- const result = text.split('');
356
- for (let i = 0; i < result.length; i++) {
357
- if (masked[i] && !whitespace(result[i]) && result[i] !== '*') { result[i] = '*'; redacted = true; }
358
- }
359
- return { text: result.join(''), redacted };
360
- } catch { throw new Error(SAFE_ERROR); }
361
- }