scenescout 3.23.3 → 3.23.4
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/CHANGELOG.md +11 -0
- package/README.md +20 -3
- package/dist/browsers.js +7 -6
- package/dist/ci-run.js +3 -2
- package/dist/cli.js +2 -1
- package/dist/clients.js +3 -3
- package/dist/code-routes.js +2 -2
- package/dist/commands.js +4 -4
- package/dist/engine/args.js +21 -0
- package/dist/engine/bench.js +1 -1
- package/dist/engine/brief.js +1 -1
- package/dist/engine/browser.js +59 -15
- package/dist/engine/capture.js +4 -4
- package/dist/engine/check-replay.js +82 -8
- package/dist/engine/check-report.js +8 -8
- package/dist/engine/check.js +15 -15
- package/dist/engine/ci.js +20 -20
- package/dist/engine/claims.js +4 -4
- package/dist/engine/collector.js +7 -7
- package/dist/engine/crawl.js +1 -1
- package/dist/engine/dedup.js +4 -4
- package/dist/engine/design.js +7 -7
- package/dist/engine/expiry.js +1 -1
- package/dist/engine/export.js +7 -7
- package/dist/engine/flow.js +10 -10
- package/dist/engine/forms.js +1 -1
- package/dist/engine/from-run.js +6 -6
- package/dist/engine/injection.js +1 -1
- package/dist/engine/lane.js +1 -1
- package/dist/engine/limits.js +2 -2
- package/dist/engine/live.js +30 -0
- package/dist/engine/memory.js +24 -7
- package/dist/engine/open.js +1 -1
- package/dist/engine/oracles.js +2 -2
- package/dist/engine/plain.js +1 -1
- package/dist/engine/png.js +2 -2
- package/dist/engine/policy.js +2 -2
- package/dist/engine/postmessage.js +7 -7
- package/dist/engine/project-folder.js +1 -1
- package/dist/engine/provider.js +1 -1
- package/dist/engine/replay.js +3 -1
- package/dist/engine/report.js +3 -3
- package/dist/engine/sarif.js +2 -2
- package/dist/engine/script-literal.js +11 -0
- package/dist/engine/sensitive.js +254 -0
- package/dist/engine/signed-in.js +2 -2
- package/dist/engine/status-pane-page.js +4 -3
- package/dist/engine/task.js +1 -1
- package/dist/engine/teardown.js +2 -0
- package/dist/engine/tickets.js +6 -6
- package/dist/engine/verify.js +3 -3
- package/dist/export-run.js +1 -1
- package/dist/installer.js +4 -4
- package/dist/login-run.js +2 -2
- package/dist/mcp-server.js +6 -9
- package/dist/playbook.js +1 -1
- package/package.json +2 -2
package/dist/engine/memory.js
CHANGED
|
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { laneRoutePaths, normalizePath, shortHash, stripRouteQuery } from "./fingerprint.js";
|
|
5
5
|
import { isFormBookkeeping } from "./forms.js";
|
|
6
|
+
import { maskSecretFieldValues, maskSecretParams, redactPersonal } from "./sensitive.js";
|
|
6
7
|
import { readFromRunNotes, readRunRecords, recordFromJson, unionFromRunNotes, unionRecords } from "./from-run.js";
|
|
7
8
|
import { addReading, addVerdict, MAX_TICKETS_KEPT, mergeTicketData } from "./tickets.js";
|
|
8
9
|
/**
|
|
@@ -230,20 +231,30 @@ const SECRET_PATTERNS = [
|
|
|
230
231
|
[/\b(api[-_]?key|secret|password|token)(["'\s:=]+)(?=[A-Za-z0-9._~+/=-]*\d)(?=[A-Za-z0-9._~+/=-]*[A-Za-z])[A-Za-z0-9._~+/=]{12,}/gi, "$1$2[redacted]"],
|
|
231
232
|
];
|
|
232
233
|
/**
|
|
233
|
-
* Strip anything that looks like a credential from text we are about to
|
|
234
|
+
* Strip anything that looks like a credential from text we are about to
|
|
235
|
+
* persist: credential shapes, a value typed into a field whose label names a
|
|
236
|
+
* secret (`textbox "Password" ← "…"`, whatever its length), the value of a
|
|
237
|
+
* parameter named for one (`?password=…`), and personal data
|
|
238
|
+
* (email addresses and card numbers, sensitive.ts).
|
|
234
239
|
*
|
|
235
240
|
* Silent edits are their own hazard, so a redaction announces itself: a reader
|
|
236
241
|
* who sees mangled-looking text needs to know the tool did it deliberately.
|
|
242
|
+
* Credentials and masked values are counted in a trailing note; personal data
|
|
243
|
+
* is replaced by a marker that names it (`[email redacted]`).
|
|
237
244
|
*/
|
|
238
245
|
export function redactSecrets(text) {
|
|
239
|
-
|
|
240
|
-
|
|
246
|
+
const masked = maskSecretFieldValues(text);
|
|
247
|
+
// A parameter named for a secret (`?pin=…` from a GET form) is masked whatever its value looks like.
|
|
248
|
+
const params = maskSecretParams(masked.text);
|
|
249
|
+
let out = params.text;
|
|
250
|
+
let hits = masked.hits + params.hits;
|
|
241
251
|
for (const [re, replacement] of SECRET_PATTERNS) {
|
|
242
252
|
out = out.replace(re, (...args) => {
|
|
243
253
|
hits += 1;
|
|
244
254
|
return replacement.replace(/\$(\d)/g, (_, n) => String(args[Number(n)] ?? ""));
|
|
245
255
|
});
|
|
246
256
|
}
|
|
257
|
+
out = redactPersonal(out);
|
|
247
258
|
return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
|
|
248
259
|
}
|
|
249
260
|
/**
|
|
@@ -1927,11 +1938,17 @@ export class MemoryStore {
|
|
|
1927
1938
|
* Append to the action log — the choke point where redaction belongs.
|
|
1928
1939
|
*
|
|
1929
1940
|
* Redacting only a finding's title/detail/evidence left the same secrets
|
|
1930
|
-
* flowing by a parallel route: `repro` is built from these entries,
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1941
|
+
* flowing by a parallel route: `repro` is built from these entries, a `type`
|
|
1942
|
+
* step logs the text that was typed, and every entry carries a full URL that
|
|
1943
|
+
* may hold `?token=…`. All of it lands in memory.json and is rendered into
|
|
1944
|
+
* the report, the live view's feed and exported issues. One filter here
|
|
1934
1945
|
* covers the log, the repro traces and the JSONL trail at once.
|
|
1946
|
+
*
|
|
1947
|
+
* A value typed into a secret field (type=password, a credential or card
|
|
1948
|
+
* autocomplete token, or a name or label that says so) is masked by the
|
|
1949
|
+
* browser before it gets here (sensitive.ts secretFieldKind). redactSecrets
|
|
1950
|
+
* masks it again where only the label is left to go on, and catches
|
|
1951
|
+
* credential shapes, email addresses and card numbers in anything else.
|
|
1935
1952
|
*/
|
|
1936
1953
|
logAction(entry) {
|
|
1937
1954
|
const full = {
|
package/dist/engine/open.js
CHANGED
|
@@ -34,7 +34,7 @@ export function openChoiceFromEnv(env) {
|
|
|
34
34
|
* browser would open on the remote machine if at all, or a Linux or other
|
|
35
35
|
* Unix machine with no display server. macOS and Windows always have one.
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
function noDisplay(env, platform) {
|
|
38
38
|
if (env.SSH_CONNECTION || env.SSH_TTY)
|
|
39
39
|
return true;
|
|
40
40
|
if (platform === "darwin" || platform === "win32")
|
package/dist/engine/oracles.js
CHANGED
|
@@ -196,7 +196,7 @@ export function isRouteCancellation(message, c) {
|
|
|
196
196
|
return ROUTE_CANCEL_RE.test(message) || (c.viaLink && c.dialogOpened);
|
|
197
197
|
}
|
|
198
198
|
/** Appended to a page error read as a route-change cancellation, saying which evidence it was read on. */
|
|
199
|
-
|
|
199
|
+
function routeCancelNote(message) {
|
|
200
200
|
const why = ROUTE_CANCEL_RE.test(message)
|
|
201
201
|
? "the click left the URL unchanged and the error says the route change was cancelled"
|
|
202
202
|
: "the click on a link left the URL unchanged and the page opened a dialog instead";
|
|
@@ -456,7 +456,7 @@ export class OracleMonitor {
|
|
|
456
456
|
* uncaught exception, a failed request, typed markup coming back, a page
|
|
457
457
|
* contradicting the server, a token posted to any origin) stops a plan either way.
|
|
458
458
|
*/
|
|
459
|
-
|
|
459
|
+
const CONTINUABLE_KINDS = new Set(["http_error", "console_error"]);
|
|
460
460
|
/** Whether these violations, drained after one plan step, end the plan. Repeats of already-reported violations never do. */
|
|
461
461
|
export function planStopsAt(violations, rule) {
|
|
462
462
|
const fresh = violations.filter((v) => !v.repeat);
|
package/dist/engine/plain.js
CHANGED
|
@@ -184,7 +184,7 @@ function joinAnd(parts) {
|
|
|
184
184
|
return parts.length <= 1 ? parts.join("") : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
185
185
|
}
|
|
186
186
|
/** The short summary that opens the plain section. */
|
|
187
|
-
|
|
187
|
+
function plainSummary(input) {
|
|
188
188
|
const lines = [];
|
|
189
189
|
const { current } = input;
|
|
190
190
|
const by = (s) => current.filter((f) => f.severity === s).length;
|
package/dist/engine/png.js
CHANGED
|
@@ -12,7 +12,7 @@ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
12
12
|
/** Channels a pixel has, by PNG colour type: grey, RGB, grey and alpha, RGBA. Palette images are not read. */
|
|
13
13
|
const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
|
|
14
14
|
/** The largest picture read or diffed, in pixels. An element's capture is far smaller; this bounds the memory a bad file can ask for. */
|
|
15
|
-
|
|
15
|
+
const MAX_PIXELS = 40_000_000;
|
|
16
16
|
const CRC_TABLE = (() => {
|
|
17
17
|
const t = new Uint32Array(256);
|
|
18
18
|
for (let n = 0; n < 256; n++) {
|
|
@@ -162,7 +162,7 @@ export function encodePng(img) {
|
|
|
162
162
|
* match exactly; the allowance absorbs the odd colour rounded one step
|
|
163
163
|
* differently, not a change anyone would see.
|
|
164
164
|
*/
|
|
165
|
-
|
|
165
|
+
const DIFF_THRESHOLD = 8;
|
|
166
166
|
/** The colour a changed pixel is painted in the diff picture. */
|
|
167
167
|
export const DIFF_HIGHLIGHT = [255, 0, 80];
|
|
168
168
|
/**
|
package/dist/engine/policy.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* with elements whose accessible name / testid / id matches these patterns —
|
|
4
4
|
* enforcement lives here, at the tool layer, never in the model's prompt.
|
|
5
5
|
*/
|
|
6
|
+
import { scriptLiteral } from "./script-literal.js";
|
|
6
7
|
const DESTRUCTIVE_PATTERNS = [
|
|
7
8
|
/\bdelete\b/i,
|
|
8
9
|
// "Remove" a filter chip drops a condition from the view: "Remove Status:
|
|
@@ -634,9 +635,8 @@ export function offAppPageWrite(appUrl, pageUrl, destinationUrl, movedByEmbed) {
|
|
|
634
635
|
* the browser without asking, and the page it lands on was not sandboxed.
|
|
635
636
|
*/
|
|
636
637
|
export function sandboxedRedirectPage(target) {
|
|
637
|
-
const json = JSON.stringify(target).replace(/</g, "\\u003c");
|
|
638
638
|
// No referrer: the next hop would otherwise name this stand-in page, where a real redirect names the app.
|
|
639
|
-
return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><script>location.replace(${
|
|
639
|
+
return `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><script>location.replace(${scriptLiteral(target)});</script>`;
|
|
640
640
|
}
|
|
641
641
|
/** The last path segment of a page that is a sign-in page, and nothing else: not a verification step, where a payment provider's frame sits. */
|
|
642
642
|
const SIGN_IN_SEGMENT_RE = /^(login|log-in|signin|sign-in|signup|sign-up|sso|oauth)$/i;
|
|
@@ -50,18 +50,18 @@ import { normalizePath } from "./fingerprint.js";
|
|
|
50
50
|
/** The binding the capture script reports through. */
|
|
51
51
|
export const POSTMESSAGE_BINDING = "__scenescoutPostMessage";
|
|
52
52
|
/** Strings shorter than this are not sent to the engine: nothing token-shaped is shorter. */
|
|
53
|
-
|
|
53
|
+
const MIN_CANDIDATE_LENGTH = 16;
|
|
54
54
|
/** Longest string sent; a JWT carrying many claims runs to a few kilobytes. */
|
|
55
|
-
|
|
55
|
+
const MAX_CANDIDATE_LENGTH = 8192;
|
|
56
56
|
/** Longest JSON-looking string sent whole, so the engine can parse it; a longer one is cut and its contents go unread. */
|
|
57
|
-
|
|
57
|
+
const MAX_JSON_LENGTH = 262144;
|
|
58
58
|
/** Most values the walk visits per message: a large array or a binary buffer must not stall the app's call. */
|
|
59
|
-
|
|
59
|
+
const MAX_VISITS = 2000;
|
|
60
60
|
/** Most items read from any one array or object. */
|
|
61
61
|
export const MAX_ITEMS = 200;
|
|
62
62
|
/** Most strings sent per message, and the deepest nesting walked. */
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const MAX_CANDIDATES = 50;
|
|
64
|
+
const MAX_DEPTH = 6;
|
|
65
65
|
/** Whether a targetOrigin lets any origin read the message. Only "*" does; "/" is the sender's own origin. */
|
|
66
66
|
export function postsToAnyOrigin(targetOrigin) {
|
|
67
67
|
return targetOrigin === "*";
|
|
@@ -139,7 +139,7 @@ export function postMessageCaptureScript(binding = POSTMESSAGE_BINDING) {
|
|
|
139
139
|
})();`;
|
|
140
140
|
}
|
|
141
141
|
/** Key names, lower-cased with `_` and `-` removed, whose value is a credential. */
|
|
142
|
-
|
|
142
|
+
const CREDENTIAL_KEYS = new Set([
|
|
143
143
|
"accesstoken",
|
|
144
144
|
"idtoken",
|
|
145
145
|
"refreshtoken",
|
|
@@ -25,7 +25,7 @@ import { MEMORY_DIRNAME } from "./memory.js";
|
|
|
25
25
|
/** The setting: an absolute folder that holds one folder per tested site, or `off`. */
|
|
26
26
|
export const PROJECTS_DIR_ENV = "SCENESCOUT_PROJECTS_DIR";
|
|
27
27
|
/** The folder made under the documents folder when the setting is unset. */
|
|
28
|
-
|
|
28
|
+
const DEFAULT_PROJECTS_DIRNAME = "SceneScout";
|
|
29
29
|
/**
|
|
30
30
|
* The user's documents folder. Windows: `%USERPROFILE%\Documents`; Linux and
|
|
31
31
|
* others: XDG_DOCUMENTS_DIR from the environment or user-dirs.dirs (which the
|
package/dist/engine/provider.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export class MalformedReply extends Error {
|
|
3
3
|
}
|
|
4
4
|
/** The most output one model call may produce. Large enough for a plan of steps; small enough that one reply cannot spend the budget. */
|
|
5
|
-
|
|
5
|
+
const MAX_OUTPUT_TOKENS = 16_000;
|
|
6
6
|
/** A plain object as a record, or null for anything else (an array, null, a string). */
|
|
7
7
|
export const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
|
|
8
8
|
const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
package/dist/engine/replay.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import crypto from "node:crypto";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { isSafeRelativePath } from "./plain.js";
|
|
24
|
+
import { scriptLiteral } from "./script-literal.js";
|
|
24
25
|
/** Most frames one recorded session keeps. A long run is thousands of actions, and a project folder is not a video store. */
|
|
25
26
|
export const RECORD_MAX_FRAMES = 600;
|
|
26
27
|
/** A name reduced to one plain path segment: letters, digits, dot, dash and underscore, never leading with a dot or dash. */
|
|
@@ -407,7 +408,8 @@ details.evidence figcaption { margin-top:4px; color:var(--muted); font:11px/1.5
|
|
|
407
408
|
function exitWatch(savedFile) {
|
|
408
409
|
return ("<script>(function () {\n" +
|
|
409
410
|
" var saved = " +
|
|
410
|
-
|
|
411
|
+
// Escaped: a folder named with "</script>" must not end this element.
|
|
412
|
+
scriptLiteral(savedFile) +
|
|
411
413
|
";\n" +
|
|
412
414
|
" var gone = false;\n" +
|
|
413
415
|
" var bar = document.getElementById('gone-bar');\n" +
|
package/dist/engine/report.js
CHANGED
|
@@ -117,7 +117,7 @@ function projectName(dir) {
|
|
|
117
117
|
* when there are none. The first bullet names the id, as a finding's does, so
|
|
118
118
|
* the live view hangs the frames recorded around it under the entry.
|
|
119
119
|
*/
|
|
120
|
-
|
|
120
|
+
function formatWorthALook(items, sessionStart) {
|
|
121
121
|
if (items.length === 0)
|
|
122
122
|
return [];
|
|
123
123
|
const lines = [
|
|
@@ -139,7 +139,7 @@ export function formatWorthALook(items, sessionStart) {
|
|
|
139
139
|
return lines;
|
|
140
140
|
}
|
|
141
141
|
/** A finding's picture as a bullet, its path relative to report.md, or nothing when it has none. */
|
|
142
|
-
|
|
142
|
+
function pictureLine(f) {
|
|
143
143
|
const p = readFindingPicture(f);
|
|
144
144
|
return p ? [`- **Picture:** \`${p.file}\` (${describePicture(p)})`] : [];
|
|
145
145
|
}
|
|
@@ -182,7 +182,7 @@ function replaySessions(memory) {
|
|
|
182
182
|
}));
|
|
183
183
|
}
|
|
184
184
|
/** The frames that were on screen while each finding was being found. */
|
|
185
|
-
|
|
185
|
+
function findingEvidence(memory, sessions) {
|
|
186
186
|
const all = sessions.flatMap((s) => s.steps).sort((a, b) => a.at.localeCompare(b.at));
|
|
187
187
|
return memory.findings
|
|
188
188
|
.map((f) => {
|
package/dist/engine/sarif.js
CHANGED
|
@@ -80,11 +80,11 @@ export function resolveSarifAnchor(o) {
|
|
|
80
80
|
return { ...chosen, warning: `SARIF: ${missed.map(describe).join(" and ")} is not in the repository; results point at ${chosen.file} instead` };
|
|
81
81
|
}
|
|
82
82
|
/** The repository root the paths are relative to: the Actions checkout when there is one, else the project. */
|
|
83
|
-
|
|
83
|
+
function repositoryRoot(env, projectDir) {
|
|
84
84
|
return env.GITHUB_WORKSPACE || projectDir;
|
|
85
85
|
}
|
|
86
86
|
/** `target` relative to `root` with forward slashes, or null when it is outside it. */
|
|
87
|
-
|
|
87
|
+
function repoRelative(root, target) {
|
|
88
88
|
const rel = path.relative(path.resolve(root), path.resolve(target));
|
|
89
89
|
if (rel === "")
|
|
90
90
|
return "";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value written into an inline `<script>` as a JavaScript literal.
|
|
3
|
+
*
|
|
4
|
+
* JSON alone is not enough there: the HTML parser ends a script element at the
|
|
5
|
+
* first `</script`, and `<!--` or `<script` inside it change how the rest is
|
|
6
|
+
* read, before any JavaScript runs. Every `<` is written as `<`, which
|
|
7
|
+
* the string reads back as the same character, so none of those can appear.
|
|
8
|
+
*/
|
|
9
|
+
export function scriptLiteral(value) {
|
|
10
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
11
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What must never be written down in clear: the value typed into a secret
|
|
3
|
+
* field, and personal data (email addresses, card numbers) in any text the
|
|
4
|
+
* tool keeps. Pure functions of what the page and the text say, so
|
|
5
|
+
* memory-test table-tests them; browser.ts only gathers the facts.
|
|
6
|
+
*
|
|
7
|
+
* Masking errs towards hiding. A field wrongly taken for a secret costs a repro
|
|
8
|
+
* one typed value; a password wrongly taken for an ordinary field is published
|
|
9
|
+
* in the action log, the report and every issue exported from it.
|
|
10
|
+
*/
|
|
11
|
+
/** The autocomplete tokens (HTML's autofill field names) that mark a credential or a payment card. */
|
|
12
|
+
const AUTOCOMPLETE_KINDS = {
|
|
13
|
+
"current-password": "password",
|
|
14
|
+
"new-password": "password",
|
|
15
|
+
"one-time-code": "one-time code",
|
|
16
|
+
"cc-number": "card",
|
|
17
|
+
"cc-csc": "card",
|
|
18
|
+
"cc-exp": "card",
|
|
19
|
+
"cc-exp-month": "card",
|
|
20
|
+
"cc-exp-year": "card",
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Words that name a secret, matched against a name's words (camelCase, `_`,
|
|
24
|
+
* `-` and spaces split them; trailing digits dropped, so `password2` and
|
|
25
|
+
* `cvv2` count). A word in `whole` must be the whole word, so "spinner",
|
|
26
|
+
* "pinned", "tokens" and "tokenizer" are not a PIN or a token. A word in
|
|
27
|
+
* `suffix` may also end a longer one (`userpassword`, `clientsecret`, `totp`), but not
|
|
28
|
+
* start it: "passwordless" and "secretary" are not secrets.
|
|
29
|
+
*/
|
|
30
|
+
const WORD_KINDS = [
|
|
31
|
+
{ kind: "password", whole: ["pwd"], suffix: ["password", "passwd", "passphrase"], phrases: [] },
|
|
32
|
+
{
|
|
33
|
+
kind: "one-time code",
|
|
34
|
+
whole: ["pin", "onetime", "otpcode", "mfa", "authenticator"],
|
|
35
|
+
suffix: ["otp", "passcode"],
|
|
36
|
+
phrases: ["one time", "verification code", "pass code", "auth code", "authentication code", "two factor"],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
kind: "card",
|
|
40
|
+
whole: ["cvc", "cvv", "csc", "ccv", "ccnum"],
|
|
41
|
+
suffix: ["cardnumber", "ccnumber"],
|
|
42
|
+
phrases: ["security code", "card number", "card no", "credit card", "cc number", "cc num", "cc csc", "cc exp"],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: "secret",
|
|
46
|
+
whole: ["token"],
|
|
47
|
+
suffix: ["secret", "apikey"],
|
|
48
|
+
phrases: ["api key", "recovery code", "backup code", "private key", "seed phrase", "recovery phrase", "security answer"],
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
/** A name's words, lower-case: `newPassword` → new password, `PINCode` → pin code, `cvv2` → cvv. */
|
|
52
|
+
function wordsOf(text) {
|
|
53
|
+
return text
|
|
54
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
55
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
58
|
+
.map((w) => w.replace(/\d+$/, ""))
|
|
59
|
+
.filter((w) => w.length > 0);
|
|
60
|
+
}
|
|
61
|
+
/** Which kind of secret a field's name, label or placeholder names, if any. */
|
|
62
|
+
export function secretWordKind(text) {
|
|
63
|
+
// "2FA" splits into "2" and "fa" below, so it is read before the split.
|
|
64
|
+
if (/(?:^|[^\p{L}\p{N}])2fa(?![\p{L}\p{N}])/iu.test(text))
|
|
65
|
+
return "one-time code";
|
|
66
|
+
const words = wordsOf(text);
|
|
67
|
+
if (words.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
const joined = ` ${words.join(" ")} `;
|
|
70
|
+
for (const { kind, whole, suffix, phrases } of WORD_KINDS) {
|
|
71
|
+
if (words.some((w) => whole.includes(w) || suffix.some((s) => w.endsWith(s))))
|
|
72
|
+
return kind;
|
|
73
|
+
if (phrases.some((p) => joined.includes(` ${p} `)))
|
|
74
|
+
return kind;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** The input types a split code's boxes use (scripted-login.ts reads the same boxes). */
|
|
79
|
+
const SINGLE_CHARACTER_TYPES = new Set(["", "text", "tel", "number", "password"]);
|
|
80
|
+
/** Whether what is typed into this field is a secret, and which kind; null for an ordinary field. */
|
|
81
|
+
export function secretFieldKind(facts) {
|
|
82
|
+
if ((facts.type ?? "").trim().toLowerCase() === "password")
|
|
83
|
+
return "password";
|
|
84
|
+
for (const token of (facts.autocomplete ?? "").toLowerCase().split(/\s+/)) {
|
|
85
|
+
const kind = AUTOCOMPLETE_KINDS[token];
|
|
86
|
+
if (kind)
|
|
87
|
+
return kind;
|
|
88
|
+
}
|
|
89
|
+
// One box per character is a split one-time code, whatever each box is called ("Digit 1").
|
|
90
|
+
if (facts.maxLength === 1 && SINGLE_CHARACTER_TYPES.has((facts.type ?? "").trim().toLowerCase()))
|
|
91
|
+
return "one-time code";
|
|
92
|
+
const labels = typeof facts.label === "string" ? [facts.label] : (facts.label ?? []);
|
|
93
|
+
for (const text of [facts.name, facts.id, ...labels, facts.placeholder]) {
|
|
94
|
+
const kind = text ? secretWordKind(text) : null;
|
|
95
|
+
if (kind)
|
|
96
|
+
return kind;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/** The fixed stand-in for a secret field's value: the same whatever was typed, so not even its length is kept. */
|
|
101
|
+
export function maskedValue(kind) {
|
|
102
|
+
return `•••• (masked: ${kind} field)`;
|
|
103
|
+
}
|
|
104
|
+
/** A JSON-quoted string, as the type result writes a value. */
|
|
105
|
+
const QUOTED = String.raw `"(?:[^"\\]|\\.)*"`;
|
|
106
|
+
/** `"<label>" ← "<value>"`, the shape a type entry names its field and value in. */
|
|
107
|
+
const TYPED_INTO_RE = new RegExp(String.raw `"((?:[^"\\]|\\.)*)" ← (${QUOTED})`, "g");
|
|
108
|
+
const EXISTING_RE = new RegExp(String.raw `existing content ${QUOTED}`, "g");
|
|
109
|
+
/**
|
|
110
|
+
* Mask the typed value, and any previous content a note quotes, wherever text
|
|
111
|
+
* says a value went into a field whose label names a secret. The browser masks
|
|
112
|
+
* these as it types (browser.ts); this catches text written by any other route,
|
|
113
|
+
* such as an older log or an agent's own note. `hits` counts what was masked.
|
|
114
|
+
*/
|
|
115
|
+
export function maskSecretFieldValues(text) {
|
|
116
|
+
let hits = 0;
|
|
117
|
+
let out = "";
|
|
118
|
+
let last = 0;
|
|
119
|
+
for (const m of text.matchAll(TYPED_INTO_RE)) {
|
|
120
|
+
const kind = secretWordKind(m[1]);
|
|
121
|
+
if (!kind)
|
|
122
|
+
continue;
|
|
123
|
+
const start = m.index ?? 0;
|
|
124
|
+
out += text.slice(last, start) + `"${m[1]}" ← ${maskedValue(kind)}`;
|
|
125
|
+
last = start + m[0].length;
|
|
126
|
+
hits += 1;
|
|
127
|
+
// What the note says the field held before is the same secret's earlier value.
|
|
128
|
+
const next = text.indexOf(" ← ", last);
|
|
129
|
+
const rest = text.slice(last, next === -1 ? text.length : next);
|
|
130
|
+
const cleaned = rest.replace(EXISTING_RE, () => {
|
|
131
|
+
hits += 1;
|
|
132
|
+
return `existing content (masked: ${kind} field)`;
|
|
133
|
+
});
|
|
134
|
+
out += cleaned;
|
|
135
|
+
last += rest.length;
|
|
136
|
+
}
|
|
137
|
+
return { text: out + text.slice(last), hits };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* `name=value` in a query string, a fragment or a form body. A GET form puts
|
|
141
|
+
* what was typed into the address (`?password=…`), and the address is logged
|
|
142
|
+
* with every action. Group 1 is what precedes it, 2 the name, 3 the value.
|
|
143
|
+
*/
|
|
144
|
+
const PARAM_RE = /(^|[?&;#\s"'(])([A-Za-z][\w.%[\]-]*)=([^&#;\s"')]+)/g;
|
|
145
|
+
/** Mask the value of every parameter whose name names a secret (`pin=`, `otp=`, `password=`, `access_token=`), whatever its length. */
|
|
146
|
+
export function maskSecretParams(text) {
|
|
147
|
+
let hits = 0;
|
|
148
|
+
const out = text.replace(PARAM_RE, (m, before, name, value) => {
|
|
149
|
+
// A form's nested names (`user[password]`, `user%5Bpassword%5D`) name the field last; a value already masked stays.
|
|
150
|
+
if (value.startsWith("[") || !secretWordKind(name.replace(/%5[BD]/gi, " ")))
|
|
151
|
+
return m;
|
|
152
|
+
hits += 1;
|
|
153
|
+
return `${before}${name}=[redacted]`;
|
|
154
|
+
});
|
|
155
|
+
return { text: out, hits };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Playwright's call log names the value an action was given (`- fill("…")`),
|
|
159
|
+
* so the error from a failed fill of a secret field quotes the secret. It does
|
|
160
|
+
* not escape a quote inside the value, so the call is masked to the end of its
|
|
161
|
+
* line, and every value the fill was given is removed wherever else it appears.
|
|
162
|
+
*/
|
|
163
|
+
export function maskFillCalls(message, values = []) {
|
|
164
|
+
let out = message;
|
|
165
|
+
// Longest first, so a value inside another is not left behind as part of it.
|
|
166
|
+
for (const value of [...values].sort((a, b) => b.length - a.length))
|
|
167
|
+
if (value !== "")
|
|
168
|
+
out = out.split(value).join("(masked)");
|
|
169
|
+
return out.replace(/\b(fill|type|pressSequentially|insertText)\(.*$/gm, "$1((masked))");
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Personal data in text
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/** Domains reserved for documentation (RFC 2606 / 6761) name nobody, so an address on one stays readable. */
|
|
175
|
+
const RESERVED_DOMAIN_RE = /^(?:[\p{L}\p{N}-]+\.)*(?:example\.(?:com|org|net)|example|test|invalid|localhost)$/iu;
|
|
176
|
+
/** An image's pixel-density suffix (`@2x` before its extension), which reads like an address but is a file name. */
|
|
177
|
+
const DENSITY_DOMAIN_RE = /^\d+(?:\.\d+)?x\./i;
|
|
178
|
+
/** An address in any script, its @ written out or percent-encoded (`%40`), as a query string carries it. Group 1 is the domain. */
|
|
179
|
+
const EMAIL_RE = /[\p{L}\p{N}._%+-]+(?:@|%40)([\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.(?:\p{L}{2,}|xn--[a-z0-9-]+))/giu;
|
|
180
|
+
/** A run of digit groups split by single spaces or hyphens; a card number is some of its groups. */
|
|
181
|
+
const DIGIT_RUN_RE = /(?<![\d.])\d+(?:[ -]\d+)*/g;
|
|
182
|
+
/** A card number's first digit: the networks issue from 2 to 6 and 8, so a millisecond timestamp (a leading 1) is never one. */
|
|
183
|
+
const CARD_FIRST_DIGIT_RE = /^[2-68]/;
|
|
184
|
+
function luhnValid(digits) {
|
|
185
|
+
let sum = 0;
|
|
186
|
+
for (let i = 0; i < digits.length; i++) {
|
|
187
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
188
|
+
if (i % 2 === 1) {
|
|
189
|
+
d *= 2;
|
|
190
|
+
if (d > 9)
|
|
191
|
+
d -= 9;
|
|
192
|
+
}
|
|
193
|
+
sum += d;
|
|
194
|
+
}
|
|
195
|
+
return sum % 10 === 0;
|
|
196
|
+
}
|
|
197
|
+
function isCardNumber(digits) {
|
|
198
|
+
return digits.length >= 13 && digits.length <= 19 && CARD_FIRST_DIGIT_RE.test(digits) && luhnValid(digits);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether groups[i..j] are laid out as a card number is written: one run of
|
|
202
|
+
* digits, or groups of at least four (4-4-4-4, 4-6-5) with a shorter last one
|
|
203
|
+
* allowed. A list of small numbers (`320 375 414 768`) is not, whatever its
|
|
204
|
+
* digits add up to.
|
|
205
|
+
*/
|
|
206
|
+
function cardShaped(groups, i, j) {
|
|
207
|
+
for (let k = i; k < j; k++)
|
|
208
|
+
if (groups[k].length < 4)
|
|
209
|
+
return false;
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The card numbers among a run's digit groups, each the longest that starts at
|
|
214
|
+
* its group, so a date or a quantity written just before a card number does
|
|
215
|
+
* not hide it. A group is never split: digits inside a longer id stay.
|
|
216
|
+
*/
|
|
217
|
+
function redactCardsInRun(run) {
|
|
218
|
+
const parts = run.split(/([ -])/);
|
|
219
|
+
const groups = parts.filter((_, i) => i % 2 === 0);
|
|
220
|
+
let out = "";
|
|
221
|
+
let i = 0;
|
|
222
|
+
while (i < groups.length) {
|
|
223
|
+
let end = -1;
|
|
224
|
+
let digits = "";
|
|
225
|
+
for (let j = i; j < groups.length && digits.length + groups[j].length <= 19; j++) {
|
|
226
|
+
digits += groups[j];
|
|
227
|
+
if (cardShaped(groups, i, j) && isCardNumber(digits))
|
|
228
|
+
end = j;
|
|
229
|
+
}
|
|
230
|
+
if (i > 0)
|
|
231
|
+
out += parts[2 * i - 1];
|
|
232
|
+
if (end === -1) {
|
|
233
|
+
out += groups[i];
|
|
234
|
+
i += 1;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
out += "[card number redacted]";
|
|
238
|
+
i = end + 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Personal data a page, a flow or a typed value can put in text the tool
|
|
245
|
+
* keeps: an email address, its @ written out or percent-encoded (a reserved
|
|
246
|
+
* example address and an image's `@2x` name excepted), and a card number
|
|
247
|
+
* (digits that start as a card network's do and pass the Luhn check, so an
|
|
248
|
+
* order number or a timestamp stays). Each replacement names what it removed.
|
|
249
|
+
*/
|
|
250
|
+
export function redactPersonal(text) {
|
|
251
|
+
return text
|
|
252
|
+
.replace(EMAIL_RE, (m, domain) => (RESERVED_DOMAIN_RE.test(domain) || DENSITY_DOMAIN_RE.test(domain) ? m : "[email redacted]"))
|
|
253
|
+
.replace(DIGIT_RUN_RE, redactCardsInRun);
|
|
254
|
+
}
|
package/dist/engine/signed-in.js
CHANGED
|
@@ -137,7 +137,7 @@ export function looksLikeCredential(h) {
|
|
|
137
137
|
return OPAQUE.test(h.value);
|
|
138
138
|
}
|
|
139
139
|
/** The first value held now that was not held when the window opened, or held a different value, and looks like a session. */
|
|
140
|
-
|
|
140
|
+
function newCredential(watch, held) {
|
|
141
141
|
for (const h of held) {
|
|
142
142
|
if (watch.baseline.get(h.key) === h.value)
|
|
143
143
|
continue;
|
|
@@ -153,7 +153,7 @@ export function newCredential(watch, held) {
|
|
|
153
153
|
*/
|
|
154
154
|
const SIGN_IN_STEP_RE = /\/(log-in|mfa|2fa|otp|verify|challenge|select-account|choose-account|account-picker)(\/|$)/;
|
|
155
155
|
/** The path is a sign-in route (/login, /signin, /auth/..., /mfa, /verify, an account picker). */
|
|
156
|
-
|
|
156
|
+
function signInRoute(url) {
|
|
157
157
|
try {
|
|
158
158
|
const path = new URL(url).pathname.toLowerCase();
|
|
159
159
|
return LOGIN_ROUTE_RE.test(path) || SIGN_IN_STEP_RE.test(path);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* its parent window. Like live-page.ts, the client script avoids template
|
|
17
17
|
* literals because the page is one; status-pane tests check the script parses.
|
|
18
18
|
*/
|
|
19
|
+
import { scriptLiteral } from "./script-literal.js";
|
|
19
20
|
import { MCP_APPS_PROTOCOL, STATUS_POLL_MS, STATUS_POLL_TOOL } from "./status-pane.js";
|
|
20
21
|
export function statusPanePage(version) {
|
|
21
22
|
return `<!doctype html>
|
|
@@ -140,10 +141,10 @@ export function statusPanePage(version) {
|
|
|
140
141
|
<script>
|
|
141
142
|
(function () {
|
|
142
143
|
"use strict";
|
|
143
|
-
var POLL_TOOL = ${
|
|
144
|
+
var POLL_TOOL = ${scriptLiteral(STATUS_POLL_TOOL)};
|
|
144
145
|
var POLL_MS = ${STATUS_POLL_MS};
|
|
145
|
-
var PROTOCOL = ${
|
|
146
|
-
var VERSION = ${
|
|
146
|
+
var PROTOCOL = ${scriptLiteral(MCP_APPS_PROTOCOL)};
|
|
147
|
+
var VERSION = ${scriptLiteral(version)};
|
|
147
148
|
var REQUEST_MS = 10000;
|
|
148
149
|
|
|
149
150
|
var nextId = 1;
|
package/dist/engine/task.js
CHANGED
|
@@ -23,7 +23,7 @@ export const TASK_MAX = 120;
|
|
|
23
23
|
* The tools that act on the app under test. Each changes what the person
|
|
24
24
|
* watching is looking at, so each has to be able to say why.
|
|
25
25
|
*/
|
|
26
|
-
|
|
26
|
+
const NEEDS_TASK = new Set([
|
|
27
27
|
"scout_navigate",
|
|
28
28
|
"scout_back",
|
|
29
29
|
"scout_click",
|
package/dist/engine/teardown.js
CHANGED
|
@@ -25,6 +25,7 @@ export async function boundedTeardown(context, browser, capMs) {
|
|
|
25
25
|
await context?.close().catch(() => { });
|
|
26
26
|
await browser?.close().catch(() => { });
|
|
27
27
|
})()
|
|
28
|
+
// Each close above already ignores its own failure; this keeps anything unexpected from becoming an unhandled rejection, since teardown never throws.
|
|
28
29
|
.catch(() => { })
|
|
29
30
|
.finally(() => {
|
|
30
31
|
finished = true;
|
|
@@ -36,6 +37,7 @@ export async function boundedTeardown(context, browser, capMs) {
|
|
|
36
37
|
timer = setTimeout(resolve, capMs);
|
|
37
38
|
}),
|
|
38
39
|
]);
|
|
40
|
+
// Past the cap: one more try at the browser, whose failure changes nothing for a caller already released.
|
|
39
41
|
if (!finished)
|
|
40
42
|
void browser?.close().catch(() => { });
|
|
41
43
|
}
|
package/dist/engine/tickets.js
CHANGED
|
@@ -6,17 +6,17 @@ export const MAX_TICKET_TEXT = 200_000;
|
|
|
6
6
|
export const MAX_TICKETS = 50;
|
|
7
7
|
/** Most tickets the project keeps across runs: the newest readings. */
|
|
8
8
|
export const MAX_TICKETS_KEPT = 100;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const MAX_CRITERIA_PER_TICKET = 50;
|
|
10
|
+
const MAX_CRITERION_TEXT = 500;
|
|
11
11
|
export const MAX_REASON = 300;
|
|
12
12
|
export const MAX_CRITERION_FINDINGS = 20;
|
|
13
|
-
|
|
13
|
+
const MAX_CRITERION_VERDICTS = 1000;
|
|
14
14
|
/** A ticket file is read only up to this size. */
|
|
15
15
|
export const MAX_TICKET_FILE_BYTES = 1_000_000;
|
|
16
16
|
/** The file types a ticket is read from: text, never a binary or a document format. */
|
|
17
17
|
export const TICKET_FILE_EXTENSIONS = [".md", ".markdown", ".txt", ".text", ".feature"];
|
|
18
18
|
/** Below this confidence, the plain report says the verdict is unsure. */
|
|
19
|
-
|
|
19
|
+
const UNSURE_BELOW = 0.6;
|
|
20
20
|
/** Whether a file name has one of the extensions a ticket is read from. */
|
|
21
21
|
export function isTicketFileName(name) {
|
|
22
22
|
const lower = name.toLowerCase();
|
|
@@ -287,7 +287,7 @@ function numberCriteria(drafts) {
|
|
|
287
287
|
});
|
|
288
288
|
}
|
|
289
289
|
/** The key a ticket's heading carries, if any: "PROJ-12" or "#12". */
|
|
290
|
-
|
|
290
|
+
function ticketKey(heading) {
|
|
291
291
|
const key = TICKET_KEY.exec(heading)?.[1];
|
|
292
292
|
if (key)
|
|
293
293
|
return key;
|
|
@@ -520,7 +520,7 @@ export function answerTicket(ticket, verdicts) {
|
|
|
520
520
|
}
|
|
521
521
|
// ── Reporting ─────────────────────────────────────────────────────────────────
|
|
522
522
|
/** Why a criterion was not tested, in plain words. */
|
|
523
|
-
|
|
523
|
+
const NOT_TESTED_WORDS = {
|
|
524
524
|
"no-access": "the account the run used could not reach this part of the site",
|
|
525
525
|
"observe-blocked": "the run was only allowed to look, not to send changes, so this could not be tried",
|
|
526
526
|
"out-of-scope": "it is outside what this run could check",
|