@mnemonik/shared 7.140.3 → 7.141.1

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.
@@ -42,6 +42,86 @@
42
42
 
43
43
  export const SECRET_REDACTION_PLACEHOLDER = '[REDACTED]';
44
44
 
45
+ const SECRET_LABEL = String.raw`[\w$-]*(?:api[_-]?key|secret|token|password|credential|auth)|aws_secret_access_key`;
46
+ export const SECRET_LABEL_RE = new RegExp(`^(?:${SECRET_LABEL}|MYSQL_PWD|SSHPASS)$`, 'i');
47
+ const LABELED_VALUE_RE = new RegExp(
48
+ String.raw`(?<![\w$-])((["']?)(?:${SECRET_LABEL})\2\s*[:=]\s*)(?=\S)`,
49
+ 'gi'
50
+ );
51
+
52
+ /** Keep physical and escaped newline sequences, including JS line continuations. */
53
+ export function redactSecretValue(value: string): string {
54
+ return (
55
+ SECRET_REDACTION_PLACEHOLDER +
56
+ (value.match(/\\(?:\r\n|[\r\n])|\r\n|[\r\n]|\\[rn]/g)?.join('') ?? '')
57
+ );
58
+ }
59
+
60
+ function redactLabeledValues(text: string): string {
61
+ let result = '';
62
+ let offset = 0;
63
+ LABELED_VALUE_RE.lastIndex = 0;
64
+ for (const match of text.matchAll(LABELED_VALUE_RE)) {
65
+ if (match.index < offset) continue;
66
+ const start = match.index + match[0].length;
67
+ const escaped = text[start] === '\\' && /^["']/.test(text.charAt(start + 1));
68
+ const quote = escaped
69
+ ? text.charAt(start + 1)
70
+ : /^["'`]/.test(text.charAt(start))
71
+ ? text.charAt(start)
72
+ : '';
73
+ const delimiter = (escaped ? '\\' : '') + quote;
74
+ let end = start;
75
+ let closed = false;
76
+ if (quote) {
77
+ end += delimiter.length;
78
+ while (end < text.length) {
79
+ if (text[end] === '\\') {
80
+ if (escaped) {
81
+ const begin = end;
82
+ while (text[end] === '\\') end++;
83
+ // A quoted value embedded in a string: \" closes, \\\" escapes its quote.
84
+ if (text[end] === quote && (end - begin) % 4 === 1) {
85
+ end++;
86
+ closed = true;
87
+ break;
88
+ }
89
+ end = Math.min(end + 1, text.length);
90
+ } else {
91
+ end = Math.min(end + 2, text.length);
92
+ }
93
+ } else if (text[end++] === quote && !escaped) {
94
+ closed = true;
95
+ break;
96
+ }
97
+ }
98
+ } else {
99
+ // Never split an escape pair and leave its quote as executable syntax.
100
+ end += /^(?:\\[^\r\n]|[^\s"'`,;()[\]{}\\])+/.exec(text.slice(start))?.[0].length ?? 0;
101
+ }
102
+ const value = text.slice(start, end);
103
+ if (!/[A-Za-z0-9]/.test(value) || ENV_READ_VALUE_RE.test(match[0] + value)) continue;
104
+ // The quote closes a command string or anchored label search, such as '^API_KEY='.
105
+ if (
106
+ quote &&
107
+ !escaped &&
108
+ (text[match.index - 1] === quote ||
109
+ (text[match.index - 1] === '^' &&
110
+ text[match.index - 2] === quote &&
111
+ (start + 1 === text.length || /\s/.test(text.charAt(start + 1)))))
112
+ )
113
+ continue;
114
+ if (/aws_secret_access_key/i.test(match[0]) && /^<[^>]*>$/.test(value)) continue;
115
+ const body = quote
116
+ ? value.slice(delimiter.length, closed ? -delimiter.length : undefined)
117
+ : value.replace(/(?:\*\/|[.!?]+)$/, '');
118
+ const tail = quote ? (closed ? delimiter : '') : value.slice(body.length);
119
+ result += text.slice(offset, start) + delimiter + redactSecretValue(body) + tail;
120
+ offset = end;
121
+ }
122
+ return result + text.slice(offset);
123
+ }
124
+
45
125
  // Shared by the scanner daemon and server-side scrub paths.
46
126
  //
47
127
  // A value must hold a letter or a digit, in every pattern whose value is free
@@ -50,7 +130,7 @@ export const SECRET_REDACTION_PLACEHOLDER = '[REDACTED]';
50
130
  // flag: "--password=; repeated copies" in a stored checkpoint became
51
131
  // "--[REDACTED] repeated copies" (2026-10-13), and so did "password: ***".
52
132
  export const SECRET_PATTERNS: ReadonlyArray<RegExp> = [
53
- /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*(?=\S*[A-Za-z0-9])\S+/gi,
133
+ LABELED_VALUE_RE, // Select the label; the linear reader consumes its complete value.
54
134
  // Stripe-shape: (sk|pk)_(live|test)_<24+ alphanumerics>. Catches modern
55
135
  // Stripe keys whose body is split by an environment separator that
56
136
  // breaks the contiguous-alphanum pattern below. Required `live|test`
@@ -71,10 +151,7 @@ export const SECRET_PATTERNS: ReadonlyArray<RegExp> = [
71
151
  // \b-delimited, so prose ("AKIAMIA is a place name") cannot match.
72
152
  /\bAKIA[0-9A-Z]{16}\b/g,
73
153
  /\bASIA[0-9A-Z]{16}\b/g,
74
- // AWS secret access key, labeled. The generic key=value pattern above does
75
- // NOT cover this: its `secret` alternative must be followed immediately by
76
- // `[:=]`, and here it is followed by `_access_key`.
77
- /\baws_secret_access_key\s*[:=]\s*[A-Za-z0-9/+=]{40}\b/gi,
154
+ // Labeled AWS secret access keys are covered by LABELED_VALUE_RE, including quotes.
78
155
  // JWT: three base64url segments. Length minimums keep dotted identifiers
79
156
  // (`payload.header.signature`) out.
80
157
  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g,
@@ -253,9 +330,13 @@ export function scrubSecrets(text: string): string {
253
330
  if (!text) return text;
254
331
  let result = text;
255
332
  for (const pattern of SECRET_PATTERNS) {
256
- result = result.replace(pattern, (match) =>
257
- ENV_READ_VALUE_RE.test(match) ? match : SECRET_REDACTION_PLACEHOLDER
258
- );
333
+ if (pattern === LABELED_VALUE_RE) {
334
+ result = redactLabeledValues(result);
335
+ } else {
336
+ result = result.replace(pattern, (match) =>
337
+ ENV_READ_VALUE_RE.test(match) ? match : redactSecretValue(match)
338
+ );
339
+ }
259
340
  }
260
341
  // Backstop for credentials with no recognizable prefix. Runs last so the
261
342
  // precise provider patterns get first claim on their own shapes.
@@ -0,0 +1,149 @@
1
+ import { extname } from 'node:path';
2
+ import { Parser, type Node, type Tree } from 'web-tree-sitter';
3
+ import { loadGrammar } from './ast/grammars.js';
4
+ import { MAX_AST_PARSE_BYTES } from './ast/astChunker.js';
5
+ import { redactCredentials } from './redactCredentials.js';
6
+ import {
7
+ redactHighEntropyTokens,
8
+ redactSecretValue,
9
+ scrubSecrets,
10
+ SECRET_LABEL_RE,
11
+ } from './secretPatterns.js';
12
+
13
+ function scrubPayload(text: string): string {
14
+ return redactCredentials(scrubSecrets(text), { commandLineOnly: true }).text;
15
+ }
16
+
17
+ /** Source syntax stays executable; unsupported or incomplete input uses the conservative scrubber. */
18
+ export async function scrubSourceSecrets(text: string, filePath: string): Promise<string> {
19
+ const extension = extname(filePath).toLowerCase();
20
+ const language = ['.ts', '.mts', '.cts'].includes(extension)
21
+ ? 'typescript'
22
+ : extension === '.tsx'
23
+ ? 'tsx'
24
+ : ['.js', '.jsx', '.mjs', '.cjs'].includes(extension)
25
+ ? 'javascript'
26
+ : null;
27
+ if (!text || !language || Buffer.byteLength(text, 'utf8') > MAX_AST_PARSE_BYTES) {
28
+ return scrubPayload(text);
29
+ }
30
+ let parser: Parser | undefined;
31
+ let tree: Tree | null = null;
32
+ try {
33
+ const grammar = await loadGrammar(language);
34
+ if ('unavailable' in grammar) return scrubPayload(text);
35
+ parser = new Parser();
36
+ parser.setLanguage(grammar.ok.language);
37
+ tree = parser.parse(text);
38
+ if (!tree || tree.rootNode.hasError) return scrubPayload(text);
39
+ const root = tree.rootNode;
40
+ return text.slice(0, root.startIndex) + redactNode(root) + text.slice(root.endIndex);
41
+ } catch {
42
+ return scrubPayload(text);
43
+ } finally {
44
+ tree?.delete();
45
+ parser?.delete();
46
+ }
47
+ }
48
+
49
+ function secretLabel(node: Node | null): boolean {
50
+ if (!node) return false;
51
+ const property = node.childForFieldName('property') ?? node;
52
+ const name = property.type === 'string' ? property.text.slice(1, -1) : property.text;
53
+ return SECRET_LABEL_RE.test(name);
54
+ }
55
+
56
+ function literalPayload(text: string, sensitive: boolean): string {
57
+ if (sensitive && /[A-Za-z0-9]/.test(text)) return redactSecretValue(text);
58
+ // Only the entropy backstop needs an opening quote; labeled values must not consume it.
59
+ return redactHighEntropyTokens('"' + scrubPayload(text) + '"').slice(1, -1);
60
+ }
61
+
62
+ function redactNode(node: Node, sensitive = false): string {
63
+ const original = node.text;
64
+ if (node.type === 'comment') {
65
+ return original.startsWith('/*')
66
+ ? '/*' + scrubPayload(original.slice(2, -2)) + '*/'
67
+ : '//' + scrubPayload(original.slice(2));
68
+ }
69
+ if (node.type === 'string') {
70
+ return original[0] + literalPayload(original.slice(1, -1), sensitive) + original.at(-1);
71
+ }
72
+ if (
73
+ node.type === 'property_identifier' &&
74
+ node.parent?.type === 'pair' &&
75
+ node.parent.childForFieldName('key')?.id === node.id
76
+ ) {
77
+ const payload = literalPayload(original, false);
78
+ return payload === original ? original : '"' + payload + '"';
79
+ }
80
+ if (node.type === 'number' && sensitive) return `'${redactSecretValue(original)}'`;
81
+ if (node.type === 'template_string') {
82
+ let result = '`';
83
+ let offset = node.startIndex + 1;
84
+ for (const child of node.namedChildren) {
85
+ if (child.type !== 'template_substitution') continue;
86
+ result += literalPayload(
87
+ original.slice(offset - node.startIndex, child.startIndex - node.startIndex),
88
+ sensitive
89
+ );
90
+ result += redactNode(child);
91
+ offset = child.endIndex;
92
+ }
93
+ return result + literalPayload(original.slice(offset - node.startIndex, -1), sensitive) + '`';
94
+ }
95
+ if (node.type === 'regex_pattern' || node.type === 'jsx_text') return scrubPayload(original);
96
+ // A bare `token: value` fragment is also a valid JS labeled statement.
97
+ if (node.type === 'labeled_statement' && secretLabel(node.childForFieldName('label'))) {
98
+ const body = node.childForFieldName('body');
99
+ if (body?.firstNamedChild?.type === 'identifier') return scrubPayload(original);
100
+ }
101
+
102
+ let value: Node | null = null;
103
+ let key: Node | null = null;
104
+ if (
105
+ [
106
+ 'variable_declarator',
107
+ 'pair',
108
+ 'public_field_definition',
109
+ 'assignment_pattern',
110
+ 'required_parameter',
111
+ ].includes(node.type)
112
+ ) {
113
+ key =
114
+ node.childForFieldName('name') ??
115
+ node.childForFieldName('key') ??
116
+ node.childForFieldName('pattern') ??
117
+ node.childForFieldName('left');
118
+ value = node.childForFieldName('value') ?? node.childForFieldName('right');
119
+ } else if (node.type === 'assignment_expression') {
120
+ key = node.childForFieldName('left');
121
+ value = node.childForFieldName('right');
122
+ } else if (node.type === 'jsx_attribute') {
123
+ [key = null, value = null] = node.namedChildren;
124
+ }
125
+ const labeled = secretLabel(key);
126
+ let result = '';
127
+ let offset = node.startIndex;
128
+ for (const child of node.namedChildren) {
129
+ result += original.slice(offset - node.startIndex, child.startIndex - node.startIndex);
130
+ // Carry labels through value wrappers, never through call arguments or conditions.
131
+ const carry =
132
+ sensitive &&
133
+ (([
134
+ 'parenthesized_expression',
135
+ 'as_expression',
136
+ 'satisfies_expression',
137
+ 'non_null_expression',
138
+ ].includes(node.type) &&
139
+ child.id === node.firstNamedChild?.id) ||
140
+ (node.type === 'type_assertion' && child.id === node.lastNamedChild?.id) ||
141
+ (node.type === 'binary_expression' &&
142
+ ['+', '||', '&&', '??'].includes(node.childForFieldName('operator')?.text ?? '')) ||
143
+ (node.type === 'ternary_expression' &&
144
+ child.id !== node.childForFieldName('condition')?.id));
145
+ result += redactNode(child, carry || (labeled && child.id === value?.id));
146
+ offset = child.endIndex;
147
+ }
148
+ return result + original.slice(offset - node.startIndex);
149
+ }