testronaut 1.2.0 β 1.2.2
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/README.md +111 -109
- package/core/redaction.js +161 -33
- package/package.json +1 -1
- package/tests/coreTests/redation.test.js +118 -0
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# π§βπ Testronaut
|
|
2
2
|
|
|
3
|
-
**Testronaut** is an autonomous testing framework powered by
|
|
3
|
+
**Testronaut** is an autonomous testing framework powered by **LLMs and Playwright**.
|
|
4
|
+
It lets you define *mission-based tests* in plain English, then runs them through a real browser to validate UI workflows β all while generating human-readable reports.
|
|
4
5
|
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
## π Join the Mission Control Community
|
|
8
9
|
|
|
9
|
-
Got questions, ideas, or
|
|
10
|
-
Join
|
|
10
|
+
Got questions, ideas, or missions to share?
|
|
11
|
+
Join the Discord to connect with other Testronauts, get support, and help shape the frameworkβs future.
|
|
11
12
|
|
|
12
13
|
[](https://discord.gg/pBfdef92ba)
|
|
13
14
|
|
|
@@ -15,46 +16,52 @@ Join our Discord to connect with other Testronauts, get support, and help shape
|
|
|
15
16
|
|
|
16
17
|
## π Features
|
|
17
18
|
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
19
|
+
- Write tests in plain English β no brittle selectors
|
|
20
|
+
- Runs real browser sessions via **Playwright**
|
|
21
|
+
- Works with **multiple LLM providers** (OpenAI, Google Gemini, and more coming)
|
|
22
|
+
- Modular tool and DOM-reasoning system
|
|
23
|
+
- Dynamic rate-limit and token-tracking logic
|
|
24
|
+
- Generates JSON + HTML reports automatically
|
|
24
25
|
|
|
25
26
|
---
|
|
26
27
|
|
|
27
28
|
## π Documentation
|
|
28
29
|
|
|
29
30
|
Looking for deeper guides, API references, and examples?
|
|
30
|
-
|
|
31
|
+
Check the official docs:
|
|
31
32
|
|
|
32
33
|
[](https://docs.testronaut.app)
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
-
|
|
36
|
-
- Writing advanced missions
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
35
|
+
Includes:
|
|
36
|
+
- Quickstart and setup
|
|
37
|
+
- Writing advanced missions
|
|
38
|
+
- Configuring providers and models
|
|
39
|
+
- CLI options
|
|
40
|
+
- Mission Control integration
|
|
41
|
+
- Troubleshooting and FAQs
|
|
40
42
|
|
|
41
43
|
---
|
|
42
44
|
|
|
43
45
|
## π¦ Installation
|
|
44
46
|
|
|
45
|
-
```
|
|
47
|
+
```bash
|
|
46
48
|
npm install -g testronaut
|
|
47
49
|
```
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
|
|
51
|
+
Then initialize your project:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
50
54
|
npx testronaut --init
|
|
51
55
|
```
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
|
|
57
|
+
Run the sample mission:
|
|
58
|
+
```bash
|
|
54
59
|
npx testronaut welcome.mission.js
|
|
55
60
|
```
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## π Project Structure
|
|
58
65
|
|
|
59
66
|
```
|
|
60
67
|
missions/
|
|
@@ -62,142 +69,137 @@ missions/
|
|
|
62
69
|
βββ logout.mission.js
|
|
63
70
|
βββ dashboard.mission.js
|
|
64
71
|
```
|
|
65
|
-
Each file exports a mission string or function and invokes runMissions.
|
|
66
72
|
|
|
67
|
-
|
|
68
|
-
Create a file in your missions/ directory, e.g. missions/login.mission.js:
|
|
73
|
+
Each mission exports a string or function and calls `runMissions`.
|
|
69
74
|
|
|
70
|
-
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## βοΈ Example Mission
|
|
78
|
+
|
|
79
|
+
```js
|
|
71
80
|
import { runMissions } from 'testronaut';
|
|
72
81
|
|
|
73
82
|
export const loginMission = `
|
|
74
83
|
Visit ${process.env.URL}.
|
|
75
|
-
Fill the username field with ${process.env.USERNAME}.
|
|
84
|
+
Fill the username field with ${process.env.USERNAME}.
|
|
76
85
|
Fill the password field with ${process.env.PASSWORD}.
|
|
77
|
-
|
|
78
|
-
Submit the form.
|
|
86
|
+
Click the Login button.
|
|
79
87
|
Wait for the dashboard to appear.
|
|
80
|
-
Take
|
|
81
|
-
Report SUCCESS if the dashboard is loaded, otherwise
|
|
88
|
+
Take a screenshot.
|
|
89
|
+
Report SUCCESS if the dashboard is loaded, otherwise FAILURE.
|
|
82
90
|
`;
|
|
83
91
|
|
|
84
92
|
export async function executeMission() {
|
|
85
|
-
await runMissions({
|
|
86
|
-
mission: loginMission
|
|
87
|
-
}, "Login Mission");
|
|
93
|
+
await runMissions({ mission: loginMission }, "Login Mission");
|
|
88
94
|
}
|
|
89
95
|
```
|
|
90
96
|
|
|
91
|
-
|
|
97
|
+
Create a `.env` file with your credentials **and LLM API key** (depending on your chosen provider):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# For OpenAI
|
|
101
|
+
OPENAI_API_KEY=sk-...
|
|
102
|
+
|
|
103
|
+
# Or for Gemini
|
|
104
|
+
GEMINI_API_KEY=AIza...
|
|
92
105
|
|
|
93
|
-
```
|
|
94
|
-
OPENAI_API_KEY=sk-proj-############
|
|
95
106
|
URL=https://example.com/login
|
|
96
107
|
USERNAME=example@example.com
|
|
97
108
|
PASSWORD=********
|
|
98
109
|
```
|
|
99
110
|
|
|
100
|
-
|
|
111
|
+
---
|
|
101
112
|
|
|
102
|
-
|
|
103
|
-
import { runMissions } from 'testronaut';
|
|
104
|
-
import { loginMission } from './login.mission.js';
|
|
105
|
-
import { logoutMission } from './logout.mission.js';
|
|
106
|
-
import { navigateToContactFormMission } from './navigateToContactForm.mission.js';
|
|
107
|
-
|
|
108
|
-
export const fillContactFormMission =
|
|
109
|
-
`Input the text "example@example.com" in the email field.
|
|
110
|
-
Input the phone number (555)555-5555 into the phone number field.
|
|
111
|
-
Click the submit button.
|
|
112
|
-
Upon submission success there should be a toast notification indicating the form information was saved successfully.
|
|
113
|
-
Take a screenshot.
|
|
114
|
-
If found, report SUCCESS. Otherwise, report FAILURE.`
|
|
113
|
+
## π§ LLM Provider Support
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
preMission: [loginMission, navigateToContactFormMission],
|
|
119
|
-
mission: fillContactFormMission,
|
|
120
|
-
postMission: logoutMission,
|
|
121
|
-
}, "Fill Contact Form Mission");
|
|
122
|
-
}
|
|
123
|
-
```
|
|
115
|
+
Testronaut is provider-agnostic.
|
|
116
|
+
Choose your preferred LLM at init or via environment variables.
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
118
|
+
```bash
|
|
119
|
+
# During init
|
|
120
|
+
npx testronaut --init
|
|
127
121
|
|
|
122
|
+
# Or override anytime
|
|
123
|
+
TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-2.5-pro npx testronaut
|
|
128
124
|
```
|
|
125
|
+
|
|
126
|
+
Current supported providers:
|
|
127
|
+
|
|
128
|
+
| Provider | Example Models |
|
|
129
|
+
|-----------|----------------|
|
|
130
|
+
| **OpenAI** | gpt-4o, gpt-4.1, o3, gpt-5, etc. |
|
|
131
|
+
| **Google Gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-8b |
|
|
132
|
+
|
|
133
|
+
More providers coming soon (Anthropic, Mistral, etc.).
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## π Running Missions
|
|
138
|
+
|
|
139
|
+
Run all missions:
|
|
140
|
+
```bash
|
|
129
141
|
npx testronaut
|
|
130
142
|
```
|
|
131
|
-
Run a specific file:
|
|
132
143
|
|
|
133
|
-
|
|
144
|
+
Run a specific mission:
|
|
145
|
+
```bash
|
|
134
146
|
npx testronaut login.mission.js
|
|
135
147
|
```
|
|
136
148
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
Chain missions together:
|
|
150
|
+
```js
|
|
151
|
+
await runMissions({
|
|
152
|
+
preMission: [loginMission],
|
|
153
|
+
mission: fillContactFormMission,
|
|
154
|
+
postMission: logoutMission,
|
|
155
|
+
}, "Contact Form Flow");
|
|
141
156
|
```
|
|
142
|
-
|
|
157
|
+
---
|
|
143
158
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
β Optional suite syntax
|
|
159
|
+
## π Reports
|
|
160
|
+
|
|
161
|
+
Testronaut generates both JSON and HTML reports automatically under:
|
|
148
162
|
|
|
149
163
|
```
|
|
150
|
-
|
|
164
|
+
missions/mission_reports/
|
|
151
165
|
```
|
|
152
|
-
β Executes all registered test objectives
|
|
153
|
-
|
|
154
|
-
### π Reports
|
|
155
|
-
HTML and JSON reports automatically created in the ```missions/mission_reports/``` directory for each testronaut run
|
|
156
166
|
|
|
157
|
-
|
|
158
|
-
|
|
167
|
+
Each includes:
|
|
168
|
+
- Steps executed
|
|
169
|
+
- Token usage
|
|
170
|
+
- Screenshots
|
|
171
|
+
- Pass/Fail summaries
|
|
159
172
|
|
|
160
|
-
|
|
173
|
+
---
|
|
161
174
|
|
|
162
|
-
|
|
175
|
+
## π§ͺ Under the Hood
|
|
163
176
|
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
- **Playwright** for browser automation
|
|
178
|
+
- **LLMs** for reasoning, DOM parsing, and tool use
|
|
179
|
+
- **Token throttling** + adaptive cooldowns
|
|
180
|
+
- **Extensible architecture** for custom tools and workflows
|
|
166
181
|
|
|
167
|
-
|
|
168
|
-
npm install
|
|
169
|
-
npm link
|
|
170
|
-
```
|
|
171
|
-
Now you can use testronaut in any local project.
|
|
182
|
+
---
|
|
172
183
|
|
|
173
|
-
|
|
174
|
-
MIT
|
|
184
|
+
## π§ Mission Control
|
|
175
185
|
|
|
176
|
-
|
|
186
|
+
[Mission Control](https://mission.testronaut.app) lets you:
|
|
187
|
+
- View all reports in one dashboard
|
|
188
|
+
- Track mission history and success rates
|
|
189
|
+
- Compare results across environments
|
|
190
|
+
- Access screenshots and step details anytime
|
|
177
191
|
|
|
178
|
-
|
|
179
|
-
Want to see your test results evolve, spot trends, and keep all your reports in one place?
|
|
180
|
-
Sign up for **Mission Control** β your personal dashboard for Testronaut.
|
|
192
|
+
---
|
|
181
193
|
|
|
182
|
-
|
|
183
|
-
- π Track your mission reports historically
|
|
184
|
-
- π Drill into past runs with full step-by-step details
|
|
185
|
-
- π οΈ Compare results between different builds or environments
|
|
186
|
-
- βοΈ Access reports from anywhere, without digging through local files
|
|
194
|
+
## π License
|
|
187
195
|
|
|
188
|
-
|
|
189
|
-
1. [](https://mission.testronaut.app)
|
|
190
|
-
2. Link your account to Testronaut CLI
|
|
191
|
-
3. Watch your mission history grow!
|
|
196
|
+
MIT
|
|
192
197
|
|
|
193
198
|
---
|
|
194
199
|
|
|
195
200
|
## β Support the Mission
|
|
196
|
-
π€ Built with β€οΈ by [Shane Fast](https://github.com/scfast)
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
Your support helps cover hosting, development time, and the occasional coffee needed to debug at 2am.
|
|
202
|
+
π€ Built with β€οΈ by [Shane Fast](https://github.com/scfast)
|
|
200
203
|
|
|
204
|
+
If Testronaut saves you time, consider fueling the mission:
|
|
201
205
|
[](https://buymeacoffee.com/testronaut)
|
|
202
|
-
|
|
203
|
-
---
|
package/core/redaction.js
CHANGED
|
@@ -1,78 +1,206 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
34
|
-
* -
|
|
35
|
-
* -
|
|
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
|
|
144
|
+
// Mask direct props with sensitive-looking keys
|
|
41
145
|
for (const k of Object.keys(out || {})) {
|
|
42
|
-
if (
|
|
146
|
+
if (hasSensitiveHint(k)) {
|
|
43
147
|
out[k] = maskPreview(out[k], showLength);
|
|
44
148
|
}
|
|
45
149
|
}
|
|
46
150
|
|
|
47
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
69
|
-
(_m, pre,
|
|
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:
|
|
191
|
+
// Case 2: keyword ... (with|as|to|=) VALUE
|
|
73
192
|
s = s.replace(
|
|
74
|
-
|
|
75
|
-
(_m, pre,
|
|
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
|
@@ -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
|
+
});
|