testronaut 1.2.0 → 1.2.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.
package/core/redaction.js CHANGED
@@ -1,78 +1,206 @@
1
- // core/redaction.js
1
+ /**
2
+ * redaction.js
3
+ * -------------
4
+ * Purpose:
5
+ * Centralized redaction utilities for Testronaut logs and plans.
6
+ * - Single source of truth for sensitive keywords
7
+ * - Masks tool args when targeting sensitive fields (e.g., #access, password inputs)
8
+ * - Masks free-form instructions that reveal credentials
9
+ *
10
+ * Design goals:
11
+ * - No side effects (pure functions)
12
+ * - Easy to extend by editing one keyword list
13
+ * - Unit testable via Vitest
14
+ *
15
+ * Related tests:
16
+ * See `tests/core/redaction.test.js`
17
+ *
18
+ * Used by:
19
+ * - CLI log/plan renderers
20
+ * - Tool call serializers (type/fill)
21
+ *
22
+ * Example usage:
23
+ * const safeArgs = redactArgs('fill', { selector: '#access', text: 'P@ssw0rd' });
24
+ * const safePlan = redactPasswordInText(`Type "P@ssw0rd" into #access`);
25
+ */
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Sensitive Keywords (Single Source of Truth)
29
+ // Add/remove terms here and every consumer updates automatically.
30
+ // Hyphenated forms are expanded to also match `_` and spaces (api-key/api_key/api key).
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ export const SENSITIVE_KEYWORDS = [
34
+ 'pass',
35
+ 'password',
36
+ 'pwd',
37
+ 'passwd',
38
+ 'access',
39
+ 'secret',
40
+ 'token',
41
+ 'api-key',
42
+ 'apikey',
43
+ 'bearer',
44
+ 'auth',
45
+ 'pin',
46
+ 'otp',
47
+ ];
48
+
49
+ /**
50
+ * Builds a single RegExp that treats hyphens as `[-_\s]?` to catch variants.
51
+ * @returns {RegExp}
52
+ */
53
+ function buildSensitiveRegex() {
54
+ // Expand hyphen variants: "api-key" -> "api[-_\s]?key"
55
+ const parts = SENSITIVE_KEYWORDS.map(k => k.replace(/-/g, '[-_\\s]?'));
56
+ return new RegExp(`\\b(${parts.join('|')})\\b`, 'i');
57
+ }
2
58
 
59
+ const SENSITIVE_RE = buildSensitiveRegex();
60
+
61
+ // ─────────────────────────────────────────────────────────────────────────────
62
+ // Core helpers
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Safe clone: prefer structuredClone, fallback to JSON.
67
+ * @template T
68
+ * @param {T} obj
69
+ * @returns {T}
70
+ */
3
71
  function clone(obj) {
4
72
  try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj ?? {})); }
5
73
  }
6
74
 
75
+ /**
76
+ * Returns a masked preview. If showLength is true, includes length for debugging.
77
+ * @param {unknown} val
78
+ * @param {boolean} [showLength=true]
79
+ * @returns {string}
80
+ */
7
81
  export function maskPreview(val, showLength = true) {
8
82
  const s = String(val ?? '');
9
83
  if (!s) return '••••••';
10
- // Show length for debugging without leaking the value
11
84
  return showLength ? `•••••• (${s.length})` : '••••••';
12
85
  }
13
86
 
14
- function hasPasswordHint(str = '') {
15
- return /\b(pass(word)?|pwd|passwd)\b/i.test(String(str || ''));
87
+ /**
88
+ * True if the provided string contains sensitive hints (password, token, access, etc.).
89
+ * @param {string} [str='']
90
+ * @returns {boolean}
91
+ */
92
+ function hasSensitiveHint(str = '') {
93
+ return SENSITIVE_RE.test(String(str || ''));
16
94
  }
17
95
 
18
- function isPasswordCall(fnName, args = {}) {
96
+ /**
97
+ * True if an HTML input type itself is sensitive.
98
+ * @param {string} [t='']
99
+ * @returns {boolean}
100
+ */
101
+ function isSensitiveInputType(t = '') {
102
+ return String(t || '').toLowerCase() === 'password';
103
+ }
104
+
105
+ /**
106
+ * Returns true if a tool call (type/fill) looks like it targets a sensitive field.
107
+ * We check selector/label/placeholder/name/role/testId OR inputType=password.
108
+ *
109
+ * @param {'type'|'fill'|string} fnName
110
+ * @param {object} [args={}]
111
+ * @returns {boolean}
112
+ */
113
+ function isSensitiveCall(fnName, args = {}) {
19
114
  if (!(fnName === 'type' || fnName === 'fill')) return false;
20
- const { selector, label, placeholder, name, inputType, role, testId } = args;
115
+ const { selector, label, placeholder, name, inputType, role, testId } = args || {};
21
116
  return (
22
- hasPasswordHint(selector) ||
23
- hasPasswordHint(label) ||
24
- hasPasswordHint(placeholder) ||
25
- hasPasswordHint(name) ||
26
- String(inputType || '').toLowerCase() === 'password' ||
27
- hasPasswordHint(role) ||
28
- hasPasswordHint(testId)
117
+ hasSensitiveHint(selector) ||
118
+ hasSensitiveHint(label) ||
119
+ hasSensitiveHint(placeholder) ||
120
+ hasSensitiveHint(name) ||
121
+ isSensitiveInputType(inputType) ||
122
+ hasSensitiveHint(role) ||
123
+ hasSensitiveHint(testId)
29
124
  );
30
125
  }
31
126
 
127
+ // ─────────────────────────────────────────────────────────────────────────────
128
+ // Public API
129
+ // ─────────────────────────────────────────────────────────────────────────────
130
+
32
131
  /**
33
- * Redact ONLY password values in tool args:
34
- * - Applies to type/fill on password-looking fields
35
- * - Also masks any arg property explicitly named password/pwd/passwd (shallow)
132
+ * Redact ONLY secret-like values in tool args (shallow).
133
+ * - Masks any property whose *key* name looks sensitive (e.g., apiKey, token)
134
+ * - If the call targets a sensitive field, masks common value props (text/value/input/keys)
135
+ *
136
+ * @param {'type'|'fill'|string} fnName
137
+ * @param {Record<string, unknown>} [args={}]
138
+ * @param {{ showLength?: boolean }} [opts]
139
+ * @returns {Record<string, unknown>}
36
140
  */
37
141
  export function redactArgs(fnName, args = {}, { showLength = true } = {}) {
38
142
  const out = clone(args);
39
143
 
40
- // Mask direct props named password/pwd/passwd if present
144
+ // Mask direct props with sensitive-looking keys
41
145
  for (const k of Object.keys(out || {})) {
42
- if (hasPasswordHint(k)) {
146
+ if (hasSensitiveHint(k)) {
43
147
  out[k] = maskPreview(out[k], showLength);
44
148
  }
45
149
  }
46
150
 
47
- // For typing into a password field, mask the value
48
- if (isPasswordCall(fnName, args)) {
49
- if ('text' in out) out.text = maskPreview(out.text, showLength);
50
- if ('value' in out) out.value = maskPreview(out.value, showLength);
51
- if ('input' in out) out.input = maskPreview(out.input, showLength);
52
- if ('keys' in out) out.keys = maskPreview(out.keys, showLength);
151
+ // Mask value-like props when the call targets a sensitive field
152
+ if (isSensitiveCall(fnName, args)) {
153
+ for (const key of ['text', 'value', 'input', 'keys']) {
154
+ if (key in out) out[key] = maskPreview(out[key], showLength);
155
+ }
53
156
  }
54
157
 
55
158
  return out;
56
159
  }
57
160
 
58
161
  /**
59
- * Redact password literals in freeform mission text.
60
- * Heuristic: look for the word "password" then the next quoted string OR
61
- * a token after "with|as|to|=" within ~80 chars.
162
+ * Redact sensitive literals in freeform mission text.
163
+ *
164
+ * Cases:
165
+ * 1) Sensitive-word ... "VALUE"
166
+ * e.g., `password is "hunter2"`
167
+ *
168
+ * 2) Sensitive-word ... (with|as|to|=) VALUE
169
+ * e.g., `token=abcd1234`, `auth with xyz`
170
+ *
171
+ * 3) "VALUE" ... into/in/on/to TARGET (TARGET contains a sensitive hint)
172
+ * e.g., `Type "Imp0st3r123!" into #access`
173
+ *
174
+ * @param {string} [text='']
175
+ * @param {{ showLength?: boolean }} [opts]
176
+ * @returns {string}
62
177
  */
63
178
  export function redactPasswordInText(text = '', { showLength = true } = {}) {
64
179
  let s = String(text ?? '');
65
180
 
66
- // Case 1: password ... "VALUE"
181
+ // Build a reusable source pattern for SENSITIVE_KEYWORDS with hyphen expansion
182
+ const kwParts = SENSITIVE_KEYWORDS.map(k => k.replace(/-/g, '[-_\\s]?'));
183
+ const kwGroup = `(${kwParts.join('|')})`;
184
+
185
+ // Case 1: keyword ... "VALUE"
67
186
  s = s.replace(
68
- /(\bpassword\b[^"'\\\n]{0,80}["'])([^"']+)(["'])/gi,
69
- (_m, pre, pwd, post) => pre + maskPreview(pwd, showLength) + post
187
+ new RegExp(`(\\b${kwGroup}\\b[^"'\\\\\\n]{0,80}["'])([^"']+)(["'])`, 'gi'),
188
+ (_m, pre, _kw, secret, post) => pre + maskPreview(secret, showLength) + post
70
189
  );
71
190
 
72
- // Case 2: password ... (with|as|to|=) VALUE
191
+ // Case 2: keyword ... (with|as|to|=) VALUE
73
192
  s = s.replace(
74
- /(\bpassword\b[^.\n]{0,80}?\b(with|as|to|=)\s*)([^\s"'`]+)/gi,
75
- (_m, pre, _kw, pwd) => pre + maskPreview(pwd, showLength)
193
+ new RegExp(`(\\b${kwGroup}\\b[^.\\n]{0,80}?\\b(with|as|to|=)\\s*)([^\\s"'\\\`]+)`, 'gi'),
194
+ (_m, pre, _kw1, _kw2, secret) => pre + maskPreview(secret, showLength)
195
+ );
196
+
197
+ // Case 3: "VALUE" ... into/in/on/to TARGET (TARGET looks sensitive)
198
+ s = s.replace(
199
+ /(["'`])([^"'`]+)\1\s+(?:into|in|on|to)\s+([^\s,.;:]+)/gi,
200
+ (_m, q, val, target) => {
201
+ if (hasSensitiveHint(target)) return q + maskPreview(val, showLength) + q + ' into ' + target;
202
+ return _m;
203
+ }
76
204
  );
77
205
 
78
206
  return s;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -0,0 +1,118 @@
1
+ /**
2
+ * redaction.test.js
3
+ * -----------------
4
+ * Purpose:
5
+ * Vitest unit tests for redaction utilities:
6
+ * - Centralized keyword usage
7
+ * - Tool arg masking (selector & key-name based)
8
+ * - Free-text masking for plans/instructions
9
+ *
10
+ * Test runner:
11
+ * Vitest (https://vitest.dev/)
12
+ *
13
+ * Location:
14
+ * tests/core/redaction.test.js
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import {
19
+ SENSITIVE_KEYWORDS,
20
+ maskPreview,
21
+ redactArgs,
22
+ redactPasswordInText,
23
+ } from '../../core/redaction.js';
24
+
25
+ describe('redaction utilities', () => {
26
+ describe('SENSITIVE_KEYWORDS (centralized list)', () => {
27
+ it('contains expected baseline keywords', () => {
28
+ const expected = ['password', 'token', 'access', 'api-key', 'auth'];
29
+ for (const k of expected) {
30
+ expect(SENSITIVE_KEYWORDS).toContain(k);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe('maskPreview', () => {
36
+ it('masks and includes length by default', () => {
37
+ expect(maskPreview('hunter2')).toBe('•••••• (7)');
38
+ });
39
+
40
+ it('masks empty-ish values without length', () => {
41
+ expect(maskPreview('')).toBe('••••••');
42
+ expect(maskPreview(null)).toBe('••••••');
43
+ expect(maskPreview(undefined)).toBe('••••••');
44
+ });
45
+
46
+ it('respects showLength=false', () => {
47
+ expect(maskPreview('abcdef', false)).toBe('••••••');
48
+ });
49
+ });
50
+
51
+ describe('redactArgs', () => {
52
+ it('masks when selector contains #access', () => {
53
+ const args = { selector: '#access', text: 'Imp0st3r123!' };
54
+ const redacted = redactArgs('fill', args);
55
+ expect(redacted.text).toMatch(/^•••••• \(\d+\)$/);
56
+ expect(redacted.selector).toBe('#access'); // selector remains visible
57
+ });
58
+
59
+ it('masks when inputType is password', () => {
60
+ const args = { selector: '#login', inputType: 'password', value: 'superSecret' };
61
+ const redacted = redactArgs('type', args);
62
+ expect(redacted.value).toBe('•••••• (11)');
63
+ });
64
+
65
+ it('masks values by sensitive arg key names (e.g., apiKey, token)', () => {
66
+ const args = { apiKey: 'sk-live-abc', token: 'abcd1234', other: 'notSecret' };
67
+ const redacted = redactArgs('noop', args); // fnName not type/fill still masks by key name
68
+ expect(redacted.apiKey).toBe('•••••• (11)');
69
+ expect(redacted.token).toBe('•••••• (8)');
70
+ expect(redacted.other).toBe('notSecret');
71
+ });
72
+
73
+ it('does not mask non-sensitive calls', () => {
74
+ const args = { selector: '#search', text: 'hello world' };
75
+ const redacted = redactArgs('fill', args);
76
+ expect(redacted.text).toBe('hello world');
77
+ });
78
+ });
79
+
80
+ describe('redactPasswordInText', () => {
81
+ it('Case 1: keyword ... "VALUE"', () => {
82
+ const input = `My password is "hunter2".`;
83
+ const out = redactPasswordInText(input);
84
+ expect(out).toContain(`password is "•••••• (7)"`);
85
+ });
86
+
87
+ it('Case 2: keyword ... (with|as|to|=) VALUE', () => {
88
+ const input = `token=abcd1234 and auth with xyz`;
89
+ const out = redactPasswordInText(input);
90
+ expect(out).toContain(`token=•••••• (8)`);
91
+ expect(out).toContain(`auth with •••••• (3)`);
92
+ });
93
+
94
+ it('Case 3: "VALUE" into sensitive target (#access)', () => {
95
+ const input = `Type "Imp0st3r123!" into #access`;
96
+ const out = redactPasswordInText(input);
97
+ expect(out).toBe(`Type "•••••• (12)" into #access`);
98
+ });
99
+
100
+ it('non-sensitive text is unchanged', () => {
101
+ const input = `Click the button and type "hello" into #search`;
102
+ const out = redactPasswordInText(input);
103
+ expect(out).toBe(input);
104
+ });
105
+
106
+ it('handles api key forms with spaces/underscores/hyphens', () => {
107
+ const cases = [
108
+ `api-key "abcDEF123"`,
109
+ `api key "abcDEF123"`,
110
+ `api_key "abcDEF123"`,
111
+ ];
112
+ for (const text of cases) {
113
+ const out = redactPasswordInText(text);
114
+ expect(out).toContain(`"•••••• (9)"`);
115
+ }
116
+ });
117
+ });
118
+ });