scenescout 3.8.0 → 3.10.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.
- package/CHANGELOG.md +23 -0
- package/README.md +52 -3
- package/dist/browsers.js +12 -0
- package/dist/check-run.js +76 -0
- package/dist/cli.js +71 -6
- package/dist/engine/browser.js +514 -45
- package/dist/engine/check.js +487 -0
- package/dist/engine/collector.js +194 -25
- package/dist/engine/design.js +24 -8
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +70 -3
- package/dist/engine/oracles.js +101 -4
- package/dist/engine/pace.js +14 -1
- package/dist/engine/policy.js +118 -0
- package/dist/engine/report.js +54 -5
- package/dist/mcp-server.js +49 -9
- package/package.json +6 -4
- package/skills/scenescout/SKILL.md +3 -3
package/dist/engine/lane.js
CHANGED
|
@@ -135,7 +135,12 @@ export function parseLaneReport(text, expectedLane) {
|
|
|
135
135
|
const trailing = /after JSON|Unexpected non-whitespace/i.test(message);
|
|
136
136
|
return { ok: false, reason: trailing ? "the reply must be one JSON object, with no text after it" : `not valid JSON: ${message}` };
|
|
137
137
|
}
|
|
138
|
-
|
|
138
|
+
// Decoded before the schema, so every cap measures the text the lane wrote.
|
|
139
|
+
// Only a reply that looks relay-escaped is decoded: one holding a literal
|
|
140
|
+
// `<` or `>` was not escaped on the way, and its `&` is what the lane
|
|
141
|
+
// meant (a page that double-escapes its text is a defect a lane can report).
|
|
142
|
+
const decoded = /[<>]/.test(text) ? { value: raw, count: 0 } : decodeStringValues(raw);
|
|
143
|
+
const result = LaneReport.safeParse(decoded.value);
|
|
139
144
|
if (!result.success) {
|
|
140
145
|
const issue = result.error.issues[0];
|
|
141
146
|
const at = issue.path.length ? ` at ${issue.path.join(".")}` : "";
|
|
@@ -144,7 +149,77 @@ export function parseLaneReport(text, expectedLane) {
|
|
|
144
149
|
if (expectedLane !== undefined && result.data.lane !== expectedLane) {
|
|
145
150
|
return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
|
|
146
151
|
}
|
|
147
|
-
return { ok: true, report: result.data, aroundIgnored };
|
|
152
|
+
return { ok: true, report: result.data, aroundIgnored, entitiesDecoded: decoded.count };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The HTML character references a relay adds when it escapes text: the five
|
|
156
|
+
* named ones an escaper emits, and decimal or hex numeric ones. Anything else
|
|
157
|
+
* that looks like a reference (` `, `�`, a bare `&`) is left as it is.
|
|
158
|
+
*/
|
|
159
|
+
const CHAR_REFERENCE = /&(?:(lt|gt|amp|quot|apos)|#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6}));/g;
|
|
160
|
+
const NAMED_REFERENCES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
|
|
161
|
+
/**
|
|
162
|
+
* One pass over the text, so each reference is decoded exactly once:
|
|
163
|
+
* `&lt;` becomes `<`, not `<`, because the `&` a match produces is
|
|
164
|
+
* never scanned again.
|
|
165
|
+
*/
|
|
166
|
+
function decodeReferences(text) {
|
|
167
|
+
let count = 0;
|
|
168
|
+
const out = text.replace(CHAR_REFERENCE, (whole, name, dec, hex) => {
|
|
169
|
+
if (name) {
|
|
170
|
+
count += 1;
|
|
171
|
+
return NAMED_REFERENCES[name];
|
|
172
|
+
}
|
|
173
|
+
const code = hex !== undefined ? parseInt(hex, 16) : Number(dec);
|
|
174
|
+
// NUL, surrogates and anything past the last code point are not characters a relay escaped.
|
|
175
|
+
if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff))
|
|
176
|
+
return whole;
|
|
177
|
+
count += 1;
|
|
178
|
+
return String.fromCodePoint(code);
|
|
179
|
+
});
|
|
180
|
+
return { text: out, count };
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A planner relaying a lane's reply often HTML-escapes it, so `->` arrives as
|
|
184
|
+
* `->` and no longer matches the finding filed with the same evidence.
|
|
185
|
+
* Decoded in string VALUES only, after JSON.parse: JSON's own syntax contains
|
|
186
|
+
* no `<`, `>` or `&`, so an escaper can only have touched it by turning its
|
|
187
|
+
* quotes into `"`, and that reply does not parse at all; it is refused
|
|
188
|
+
* as invalid JSON rather than repaired. Keys are a fixed set of plain words
|
|
189
|
+
* and are left alone.
|
|
190
|
+
*/
|
|
191
|
+
function decodeStringValues(value) {
|
|
192
|
+
if (typeof value === "string") {
|
|
193
|
+
const d = decodeReferences(value);
|
|
194
|
+
return { value: d.text, count: d.count };
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(value)) {
|
|
197
|
+
let count = 0;
|
|
198
|
+
const out = value.map((v) => {
|
|
199
|
+
const d = decodeStringValues(v);
|
|
200
|
+
count += d.count;
|
|
201
|
+
return d.value;
|
|
202
|
+
});
|
|
203
|
+
return { value: out, count };
|
|
204
|
+
}
|
|
205
|
+
if (value !== null && typeof value === "object") {
|
|
206
|
+
let count = 0;
|
|
207
|
+
const out = {};
|
|
208
|
+
for (const [k, v] of Object.entries(value)) {
|
|
209
|
+
const d = decodeStringValues(v);
|
|
210
|
+
count += d.count;
|
|
211
|
+
out[k] = d.value;
|
|
212
|
+
}
|
|
213
|
+
return { value: out, count };
|
|
214
|
+
}
|
|
215
|
+
return { value, count: 0 };
|
|
216
|
+
}
|
|
217
|
+
/** The line the fold adds when the parse decoded references, so a reader knows the tool changed the text; empty when it did not. */
|
|
218
|
+
export function decodedEntitiesNote(count) {
|
|
219
|
+
if (count === 0)
|
|
220
|
+
return "";
|
|
221
|
+
const what = count === 1 ? "1 HTML character reference" : `${count} HTML character references`;
|
|
222
|
+
return `\n(${what} in the report ${count === 1 ? "was" : "were"} decoded once, as a relay that escapes text would have added ${count === 1 ? "it" : "them"}.)`;
|
|
148
223
|
}
|
|
149
224
|
/**
|
|
150
225
|
* The paragraph the planner puts in every lane's prompt. It is generated from
|
|
@@ -196,3 +271,105 @@ export function summarizeLaneReport(r) {
|
|
|
196
271
|
parts.push(`blocked by ${r.blocked_by}`);
|
|
197
272
|
return `${r.lane}: ${parts.join(", ")}`;
|
|
198
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Which sessions are lanes of a parallel run, and whether each one's report
|
|
276
|
+
* has been folded, so a lane's session is not closed before its decisions
|
|
277
|
+
* have somewhere to go.
|
|
278
|
+
*
|
|
279
|
+
* Folding a report writes its decisions to the lane's own session's project
|
|
280
|
+
* memory. Closing that session first leaves nothing to write to: a planner
|
|
281
|
+
* that closes a lane whose fold was just refused (a cap exceeded, say) and
|
|
282
|
+
* then re-sends the corrected object loses every decision in it.
|
|
283
|
+
*
|
|
284
|
+
* A session counts as a lane only when something in this run named it as
|
|
285
|
+
* one: scout_lane_brief listed it, scout_lane_report issued its instruction,
|
|
286
|
+
* or a reply was folded (accepted or refused) under its name while that
|
|
287
|
+
* session was attached. Nothing is inferred from other lanes' activity: the
|
|
288
|
+
* planner's own session is not a lane, and a single-session run never names
|
|
289
|
+
* one, so neither is ever refused a close by this. The ledger belongs to one
|
|
290
|
+
* run: the server clears it when the last session closes, because lane names
|
|
291
|
+
* are module names and a later run may well attach a role with one of them.
|
|
292
|
+
*/
|
|
293
|
+
export class LaneLedger {
|
|
294
|
+
named = new Set();
|
|
295
|
+
refused = new Set();
|
|
296
|
+
folded = new Set();
|
|
297
|
+
/** An instruction named this session as a lane. */
|
|
298
|
+
name(lane) {
|
|
299
|
+
this.named.add(lane);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* A brief's lanes. One whose name is a session already live is skipped:
|
|
303
|
+
* that session was attached before the split existed, so it is the
|
|
304
|
+
* planner's own (or another role's), not a lane waiting to report.
|
|
305
|
+
*/
|
|
306
|
+
nameBriefed(lanes, isLive) {
|
|
307
|
+
for (const lane of lanes)
|
|
308
|
+
if (!isLive(lane))
|
|
309
|
+
this.named.add(lane);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* A reply for this lane was refused: it is a lane, and nothing it decided is
|
|
313
|
+
* kept yet. Only while its session is attached: a reply for a session that
|
|
314
|
+
* is gone says nothing about a later session given the same name.
|
|
315
|
+
*/
|
|
316
|
+
refuse(lane, attached) {
|
|
317
|
+
if (!attached)
|
|
318
|
+
return;
|
|
319
|
+
this.named.add(lane);
|
|
320
|
+
if (!this.folded.has(lane))
|
|
321
|
+
this.refused.add(lane);
|
|
322
|
+
}
|
|
323
|
+
/** A reply for this lane was accepted. Only while its session is attached, for the same reason. */
|
|
324
|
+
fold(lane, attached) {
|
|
325
|
+
if (!attached)
|
|
326
|
+
return;
|
|
327
|
+
this.named.add(lane);
|
|
328
|
+
this.folded.add(lane);
|
|
329
|
+
this.refused.delete(lane);
|
|
330
|
+
}
|
|
331
|
+
/** The session closed: a later session with the same name is a fresh lane. */
|
|
332
|
+
forget(lane) {
|
|
333
|
+
this.named.delete(lane);
|
|
334
|
+
this.refused.delete(lane);
|
|
335
|
+
this.folded.delete(lane);
|
|
336
|
+
}
|
|
337
|
+
clear() {
|
|
338
|
+
this.named.clear();
|
|
339
|
+
this.refused.clear();
|
|
340
|
+
this.folded.clear();
|
|
341
|
+
}
|
|
342
|
+
state(lane) {
|
|
343
|
+
if (!this.named.has(lane))
|
|
344
|
+
return "not-a-lane";
|
|
345
|
+
if (this.folded.has(lane))
|
|
346
|
+
return "folded";
|
|
347
|
+
return this.refused.has(lane) ? "refused" : "unfolded";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Whether scout_close may close these sessions. Refused only when one of them
|
|
352
|
+
* is a lane whose report has not been accepted; `force` closes anyway. The
|
|
353
|
+
* message names every such lane and the two ways on.
|
|
354
|
+
*/
|
|
355
|
+
export function laneCloseGuard(sessions, ledger, force = false) {
|
|
356
|
+
if (force)
|
|
357
|
+
return { ok: true };
|
|
358
|
+
const pending = sessions.filter((s) => ledger.state(s) === "refused" || ledger.state(s) === "unfolded");
|
|
359
|
+
if (pending.length === 0)
|
|
360
|
+
return { ok: true };
|
|
361
|
+
const one = pending.length === 1;
|
|
362
|
+
const lines = pending.map((s) => ledger.state(s) === "refused"
|
|
363
|
+
? ` · ${JSON.stringify(s)}: its last report was REFUSED and no corrected one has been accepted`
|
|
364
|
+
: ` · ${JSON.stringify(s)}: no report from it has been accepted yet`);
|
|
365
|
+
const others = sessions.filter((s) => !pending.includes(s));
|
|
366
|
+
const message = `Not closed: ${one ? "this session is a lane" : `${pending.length} sessions are lanes`} of a parallel run whose report has not been folded.\n` +
|
|
367
|
+
lines.join("\n") +
|
|
368
|
+
`\nFolding keeps a lane's decisions in its own session's project memory and checks its defects were filed; closing the session first loses them. ` +
|
|
369
|
+
`Fold with scout_lane_report { lane, reply } (for a refused report, the lane's corrected object), then close. ` +
|
|
370
|
+
`To close anyway and lose ${one ? "that lane's" : "those lanes'"} decisions, pass force: true.` +
|
|
371
|
+
(others.length > 0
|
|
372
|
+
? `\nThe other session(s) can be closed one by one by name meanwhile: ${others.map((s) => `scout_close { session: ${JSON.stringify(s)} }`).join(", ")}.`
|
|
373
|
+
: "");
|
|
374
|
+
return { ok: false, unfolded: pending, message };
|
|
375
|
+
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { normalizePath, shortHash } from "./fingerprint.js";
|
|
4
|
+
import { isFormBookkeeping } from "./forms.js";
|
|
4
5
|
/**
|
|
5
6
|
* The kinds a finding can be. One list: scout_finding's input schema, the lane
|
|
6
7
|
* report a parallel agent hands back, and the skill text all read it from
|
|
@@ -282,6 +283,14 @@ function decisionKey(d) {
|
|
|
282
283
|
* project would otherwise accumulate every decision ever made and re-serialise
|
|
283
284
|
* them on each save, which is what made an old history slow to open.
|
|
284
285
|
*/
|
|
286
|
+
/**
|
|
287
|
+
* Whether a coverage key belongs to a control inside another site's frame:
|
|
288
|
+
* those keys carry the frame's origin (collector.ts frameElementKey), where
|
|
289
|
+
* the app's own frames carry a path. Not the app's to cover.
|
|
290
|
+
*/
|
|
291
|
+
export function isEmbedKey(key) {
|
|
292
|
+
return /^frame:https?:\/\//.test(key);
|
|
293
|
+
}
|
|
285
294
|
export const MAX_LANE_DECISIONS = 1000;
|
|
286
295
|
/**
|
|
287
296
|
* Most options a dropdown may have and still be tracked for unchosen options.
|
|
@@ -605,7 +614,7 @@ function liveOwnerPid(dir, isAlive) {
|
|
|
605
614
|
* Best-effort and idempotent — a read-only checkout must never break attach,
|
|
606
615
|
* and an existing file is left exactly as the user left it.
|
|
607
616
|
*/
|
|
608
|
-
function writeSelfIgnore(dir) {
|
|
617
|
+
export function writeSelfIgnore(dir) {
|
|
609
618
|
try {
|
|
610
619
|
const ignorePath = path.join(dir, ".gitignore");
|
|
611
620
|
if (fs.existsSync(ignorePath))
|
|
@@ -682,6 +691,7 @@ export class MemoryStore {
|
|
|
682
691
|
this.injectionsReported.clear();
|
|
683
692
|
this.auditsThisRun = 0;
|
|
684
693
|
this.selectChoices.clear();
|
|
694
|
+
this.emptySubmits.clear();
|
|
685
695
|
}
|
|
686
696
|
/**
|
|
687
697
|
* Each dropdown's options and the ones chosen in THIS run, by any session,
|
|
@@ -720,6 +730,55 @@ export class MemoryStore {
|
|
|
720
730
|
}
|
|
721
731
|
return out;
|
|
722
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Each form a session saw this run, by route and identity (forms.ts
|
|
735
|
+
* formIdentity: its own attributes, else its first submit control's
|
|
736
|
+
* coverage key), and whether any session has submitted it with every
|
|
737
|
+
* text field blank (forms.ts has the definition). Per run and shared across
|
|
738
|
+
* sessions, like the dropdown choices: an earlier run's empty submit says
|
|
739
|
+
* nothing about this build, and in a parallel run any lane may try it.
|
|
740
|
+
*/
|
|
741
|
+
emptySubmits = new Map();
|
|
742
|
+
/**
|
|
743
|
+
* A form seen on a visited state. Recording it again changes nothing, except
|
|
744
|
+
* that `guarded` (every submit control disabled while its fields were blank)
|
|
745
|
+
* takes it off the list for good: the page refuses the empty submit itself,
|
|
746
|
+
* and no one could clear an entry whose submit cannot be pressed.
|
|
747
|
+
*/
|
|
748
|
+
recordForm(fingerprint, key, guarded = false) {
|
|
749
|
+
const entry = this.formEntry(fingerprint, key);
|
|
750
|
+
if (entry && guarded)
|
|
751
|
+
entry.guarded = true;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* A submit of a form already seen; `empty` when every text field was blank
|
|
755
|
+
* at the moment it went. It only marks: a submit never puts a form on the
|
|
756
|
+
* list, since a key read at submit time is the one most likely to be stale.
|
|
757
|
+
* Returns whether the form was on the list, so a miss can be logged.
|
|
758
|
+
*/
|
|
759
|
+
recordFormSubmit(fingerprint, key, empty) {
|
|
760
|
+
const entry = this.formEntry(fingerprint, key, false);
|
|
761
|
+
if (entry && empty)
|
|
762
|
+
entry.triedEmpty = true;
|
|
763
|
+
return entry !== null;
|
|
764
|
+
}
|
|
765
|
+
formEntry(fingerprint, key, create = true) {
|
|
766
|
+
// Another site's frame is not the app's form to probe.
|
|
767
|
+
if (isEmbedKey(key))
|
|
768
|
+
return null;
|
|
769
|
+
const route = fingerprint.split("#")[0];
|
|
770
|
+
const id = `${route}\u0000${key}`;
|
|
771
|
+
let entry = this.emptySubmits.get(id) ?? null;
|
|
772
|
+
if (!entry && create) {
|
|
773
|
+
entry = { route, key, triedEmpty: false };
|
|
774
|
+
this.emptySubmits.set(id, entry);
|
|
775
|
+
}
|
|
776
|
+
return entry;
|
|
777
|
+
}
|
|
778
|
+
/** Forms seen this run that no session has submitted empty, in the order they were first seen. */
|
|
779
|
+
formsNeverSubmittedEmpty() {
|
|
780
|
+
return [...this.emptySubmits.values()].filter((f) => !f.triedEmpty && !f.guarded).map(({ route, key }) => ({ route, key }));
|
|
781
|
+
}
|
|
723
782
|
constructor(projectDir) {
|
|
724
783
|
this.dir = path.join(projectDir, MEMORY_DIRNAME);
|
|
725
784
|
this.legacyDirNote = adoptLegacyMemoryDir(projectDir);
|
|
@@ -1339,7 +1398,8 @@ export class MemoryStore {
|
|
|
1339
1398
|
// The planner's fold marker is not a step anyone took on a page. Left in,
|
|
1340
1399
|
// its empty URL read as a route change and cut the trace of a finding filed
|
|
1341
1400
|
// right after a fold — which is when the fold asks lanes to file.
|
|
1342
|
-
|
|
1401
|
+
// Nor is the forms bookkeeping (a read that failed, a submit it could not match).
|
|
1402
|
+
const log = this.actionLog.filter((a) => a.action !== "lane-report" && !isFormBookkeeping(a.action));
|
|
1343
1403
|
let start = Math.max(0, log.length - 12);
|
|
1344
1404
|
for (let i = log.length - 1; i >= 0 && i >= log.length - 12; i--) {
|
|
1345
1405
|
if (routeOf(log[i].url) !== routeOf(f.url)) {
|
|
@@ -1388,6 +1448,7 @@ export class MemoryStore {
|
|
|
1388
1448
|
let elementsTotal = 0;
|
|
1389
1449
|
let elementsExercised = 0;
|
|
1390
1450
|
const unexercised = [];
|
|
1451
|
+
const embeds = { total: 0, exercised: 0 };
|
|
1391
1452
|
for (const [route, elements] of byRoute) {
|
|
1392
1453
|
const own = [];
|
|
1393
1454
|
// The route's OWN element count — deduped across states and with shared
|
|
@@ -1398,6 +1459,12 @@ export class MemoryStore {
|
|
|
1398
1459
|
// untouched route silently drops out of the gap ledger.
|
|
1399
1460
|
let ownTotal = 0;
|
|
1400
1461
|
for (const [key, done] of elements) {
|
|
1462
|
+
if (isEmbedKey(key)) {
|
|
1463
|
+
embeds.total += 1;
|
|
1464
|
+
if (done)
|
|
1465
|
+
embeds.exercised += 1;
|
|
1466
|
+
continue;
|
|
1467
|
+
}
|
|
1401
1468
|
if (isChrome(key)) {
|
|
1402
1469
|
chrome.set(key, (chrome.get(key) ?? false) || done);
|
|
1403
1470
|
continue;
|
|
@@ -1424,7 +1491,7 @@ export class MemoryStore {
|
|
|
1424
1491
|
if (chromeLeft.length > 0) {
|
|
1425
1492
|
unexercised.push({ state: SHARED_CHROME_ROUTE, keys: chromeLeft, total: chrome.size });
|
|
1426
1493
|
}
|
|
1427
|
-
return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised };
|
|
1494
|
+
return { states: Object.keys(this.data.states).length, elementsTotal, elementsExercised, unexercised, embeds };
|
|
1428
1495
|
}
|
|
1429
1496
|
/**
|
|
1430
1497
|
* Remember which styled-element signatures the design audit saw on a route.
|
package/dist/engine/oracles.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { redactSecrets } from "./memory.js";
|
|
2
|
+
/** How an HTTP error violation states its request. One formatter, so a reader of the detail (the check) cannot drift from its writer. */
|
|
3
|
+
export function httpErrorDetail(method, url, status) {
|
|
4
|
+
return `${method} ${url.slice(0, 200)} → HTTP ${status}`;
|
|
5
|
+
}
|
|
6
|
+
/** The status an http_error detail names, or null when the detail is not one. */
|
|
7
|
+
export function httpStatusOf(detail) {
|
|
8
|
+
const m = /→ HTTP (\d{3})$/.exec(detail.replace(/ \[\d+ secrets? redacted\]$/, ""));
|
|
9
|
+
return m ? Number(m[1]) : null;
|
|
10
|
+
}
|
|
2
11
|
/** URLs whose failures are noise, not findings (favicons, source maps). */
|
|
3
12
|
const BENIGN_URL_RE = /favicon|\.map($|\?)/i;
|
|
4
13
|
/**
|
|
@@ -35,6 +44,68 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
|
|
|
35
44
|
return false;
|
|
36
45
|
return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* The request a console message is the browser's own echo of, or null when it
|
|
49
|
+
* is not one.
|
|
50
|
+
*
|
|
51
|
+
* Chromium and WebKit print "Failed to load resource: …" for a subresource
|
|
52
|
+
* that answered with an error status or got no answer, and give the
|
|
53
|
+
* resource's address as the message's location (Firefox prints nothing; see
|
|
54
|
+
* `echoesFailedLoads` in browsers.ts). The line carries no address in its
|
|
55
|
+
* text, so without this it could only be charged to the page. Returned in the
|
|
56
|
+
* form `requestKey` gives a request's URL, so the two can be matched.
|
|
57
|
+
*/
|
|
58
|
+
export function failedLoadEchoOf(text, locationUrl, pageUrl) {
|
|
59
|
+
if (!/^Failed to load resource: /.test(text) || !locationUrl)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return requestKey(new URL(locationUrl, pageUrl).href);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A request's address as the echo lookup keys it: absolute, and without its fragment, which is never sent. */
|
|
69
|
+
export function requestKey(url) {
|
|
70
|
+
try {
|
|
71
|
+
const u = new URL(url);
|
|
72
|
+
u.hash = "";
|
|
73
|
+
return u.href;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return url;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Which recent requests an embed sent, by address, so the browser's echo of
|
|
81
|
+
* one that failed is charged where the request was.
|
|
82
|
+
*
|
|
83
|
+
* Only addresses an embed requested are kept, and the latest request to an
|
|
84
|
+
* address decides: when the app then requests the same address, its echo is
|
|
85
|
+
* the app's again. Bounded, dropping the oldest, so a long run does not hold
|
|
86
|
+
* every address it saw.
|
|
87
|
+
*/
|
|
88
|
+
export class EmbedRequestLog {
|
|
89
|
+
cap;
|
|
90
|
+
byKey = new Map();
|
|
91
|
+
constructor(cap = 500) {
|
|
92
|
+
this.cap = cap;
|
|
93
|
+
}
|
|
94
|
+
note(url, embed) {
|
|
95
|
+
const key = requestKey(url);
|
|
96
|
+
this.byKey.delete(key);
|
|
97
|
+
if (!embed)
|
|
98
|
+
return;
|
|
99
|
+
this.byKey.set(key, embed);
|
|
100
|
+
if (this.byKey.size > this.cap)
|
|
101
|
+
this.byKey.delete(this.byKey.keys().next().value);
|
|
102
|
+
}
|
|
103
|
+
/** The embed whose request a console message echoes, or null when it echoes none of theirs. */
|
|
104
|
+
embedOfEcho(text, locationUrl, pageUrl) {
|
|
105
|
+
const key = failedLoadEchoOf(text, locationUrl, pageUrl);
|
|
106
|
+
return key === null ? null : (this.byKey.get(key) ?? null);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
38
109
|
/**
|
|
39
110
|
* Invariant oracles: passive listeners that record violations regardless of
|
|
40
111
|
* what the agent is doing. The engine drains the buffer after every action and
|
|
@@ -58,8 +129,12 @@ export class OracleMonitor {
|
|
|
58
129
|
severity: "high",
|
|
59
130
|
detail: text.slice(0, 500),
|
|
60
131
|
url: page.url(),
|
|
132
|
+
// The one console line that names its request: the browser's echo of an embed's failed load is the embed's too.
|
|
133
|
+
embed: this.embedRequests.embedOfEcho(text, msg.location().url, page.url()) ?? undefined,
|
|
61
134
|
});
|
|
62
135
|
});
|
|
136
|
+
// Noted as each request starts, which is always before the browser can echo its failure.
|
|
137
|
+
page.on("request", (req) => this.embedRequests.note(req.url(), this.embedOfRequest(req)));
|
|
63
138
|
page.on("pageerror", (err) => {
|
|
64
139
|
this.record({
|
|
65
140
|
kind: "page_error",
|
|
@@ -84,6 +159,7 @@ export class OracleMonitor {
|
|
|
84
159
|
severity: "medium",
|
|
85
160
|
detail: `${req.method()} ${req.url().slice(0, 200)} → ${failure}`,
|
|
86
161
|
url: page.url(),
|
|
162
|
+
embed: this.embedOfRequest(req) ?? undefined,
|
|
87
163
|
});
|
|
88
164
|
});
|
|
89
165
|
page.on("response", (res) => {
|
|
@@ -104,8 +180,9 @@ export class OracleMonitor {
|
|
|
104
180
|
this.record({
|
|
105
181
|
kind: "http_error",
|
|
106
182
|
severity: status >= 500 ? "high" : "medium",
|
|
107
|
-
detail:
|
|
183
|
+
detail: httpErrorDetail(res.request().method(), res.url(), status),
|
|
108
184
|
url: page.url(),
|
|
185
|
+
embed: this.embedOfRequest(res.request()) ?? undefined,
|
|
109
186
|
});
|
|
110
187
|
});
|
|
111
188
|
}
|
|
@@ -128,6 +205,19 @@ export class OracleMonitor {
|
|
|
128
205
|
* errors look the same.
|
|
129
206
|
*/
|
|
130
207
|
policyAttributed = 0;
|
|
208
|
+
embedOfRequest = () => null;
|
|
209
|
+
embedRequests = new EmbedRequestLog();
|
|
210
|
+
/**
|
|
211
|
+
* The engine knows which frame a request came from; a failing request is
|
|
212
|
+
* attributed to an embed through this. Console and page errors are not
|
|
213
|
+
* attributed: a console message says where its script was served from, not
|
|
214
|
+
* which frame ran it, so an SDK the app's page loads from the embed's own
|
|
215
|
+
* site would be taken for the embed. The exception is the browser's echo of
|
|
216
|
+
* a failed load, which names its request and goes where the request went.
|
|
217
|
+
*/
|
|
218
|
+
setEmbedAttribution(ofRequest) {
|
|
219
|
+
this.embedOfRequest = ofRequest;
|
|
220
|
+
}
|
|
131
221
|
/** The engine knows exactly which requests its policy stopped; failed requests and stand-in refusals are matched against that, not against wording. */
|
|
132
222
|
setPolicyRefusalCheck(check) {
|
|
133
223
|
this.refusedByPolicy = check;
|
|
@@ -158,7 +248,11 @@ export class OracleMonitor {
|
|
|
158
248
|
this.policyAttributed += 1;
|
|
159
249
|
return;
|
|
160
250
|
}
|
|
161
|
-
|
|
251
|
+
// Another site's frame: its behaviour, reported, but never as the app's high-severity defect.
|
|
252
|
+
const attributed = v.embed ? { ...v, severity: "medium" } : v;
|
|
253
|
+
if (!attributed.embed)
|
|
254
|
+
delete attributed.embed;
|
|
255
|
+
const violation = { ...redactViolation(attributed), at: new Date().toISOString() };
|
|
162
256
|
this.buffer.push(violation);
|
|
163
257
|
this.all.push(violation);
|
|
164
258
|
}
|
|
@@ -179,7 +273,8 @@ export class OracleMonitor {
|
|
|
179
273
|
for (const v of out) {
|
|
180
274
|
// Same normalization as the report rollup, so "the same violation"
|
|
181
275
|
// means the same thing in tool output and in the final report.
|
|
182
|
-
|
|
276
|
+
// An embed's violation is not the app's: its signature says whose it is.
|
|
277
|
+
const sig = `${v.embed ? `[${v.embed}] ` : ""}${v.kind}: ${v.detail
|
|
183
278
|
.replace(/\b\d+\b/g, ":n")
|
|
184
279
|
.replace(/[0-9a-f]{8,}/gi, ":h")
|
|
185
280
|
.slice(0, 140)}`;
|
|
@@ -205,7 +300,9 @@ export function formatViolations(violations) {
|
|
|
205
300
|
if (fresh.length === 0) {
|
|
206
301
|
return `\nORACLE: ${repeats} repeat violation(s) of previously reported signatures — nothing new.`;
|
|
207
302
|
}
|
|
208
|
-
const lines = fresh
|
|
303
|
+
const lines = fresh
|
|
304
|
+
.slice(0, 10)
|
|
305
|
+
.map((v) => ` ⚠ [${v.severity}] ${v.kind}${v.embed ? ` (in an embed of ${v.embed}: its behaviour, not the app's)` : ""}: ${v.detail}`);
|
|
209
306
|
const more = fresh.length > 10 ? `\n … and ${fresh.length - 10} more` : "";
|
|
210
307
|
return `\nORACLE VIOLATIONS since last action (${fresh.length} new):\n${lines.join("\n")}${more}${repeatLine}`;
|
|
211
308
|
}
|
package/dist/engine/pace.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import { FORMS_READ_FAILED, FORMS_SUBMIT_UNMATCHED } from "./forms.js";
|
|
1
2
|
/**
|
|
2
3
|
* Log entries that are not actions: a stated task, an attach, the note that a
|
|
3
4
|
* resource was created. They carry no browser work and take no time, so
|
|
4
5
|
* counting them inflates the action count and drags the median gap toward
|
|
5
6
|
* zero. One real run logged 325 entries of which 107 were stated tasks.
|
|
6
7
|
*/
|
|
7
|
-
const MARKER_ACTIONS = new Set([
|
|
8
|
+
const MARKER_ACTIONS = new Set([
|
|
9
|
+
"task",
|
|
10
|
+
"attach",
|
|
11
|
+
"close",
|
|
12
|
+
"lane-report",
|
|
13
|
+
"created-resource",
|
|
14
|
+
"journey:start",
|
|
15
|
+
"journey:end",
|
|
16
|
+
"record:full",
|
|
17
|
+
"record:failed",
|
|
18
|
+
FORMS_READ_FAILED,
|
|
19
|
+
FORMS_SUBMIT_UNMATCHED,
|
|
20
|
+
]);
|
|
8
21
|
/** Whether a log entry represents work the browser actually did. */
|
|
9
22
|
export function isActing(action) {
|
|
10
23
|
return !MARKER_ACTIONS.has(action);
|
package/dist/engine/policy.js
CHANGED
|
@@ -394,6 +394,124 @@ export function allowsForeignWriteOnSignIn(mode, topPageUrl, appUrl) {
|
|
|
394
394
|
const last = (segments[segments.length - 1] ?? "").replace(/\.[a-z0-9]+$/i, "").replace(/_/g, "-");
|
|
395
395
|
return SIGN_IN_SEGMENT_RE.test(last);
|
|
396
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* The embed a failing request is attributed to: the other site whose frame
|
|
399
|
+
* sent it (`frameSite`, judged on the frame chain), unless the request went to
|
|
400
|
+
* the app itself — a 500 from the app is the app's to answer, whoever called.
|
|
401
|
+
*/
|
|
402
|
+
export function embedOfRequest(appUrl, requestUrl, frameSite) {
|
|
403
|
+
if (!frameSite)
|
|
404
|
+
return null;
|
|
405
|
+
return foreignFrameOrigin(appUrl, [requestUrl]) === null ? null : frameSite;
|
|
406
|
+
}
|
|
407
|
+
/** The most origins a session may trust with its embeds' writes. */
|
|
408
|
+
export const MAX_TRUSTED_EMBEDS = 10;
|
|
409
|
+
/**
|
|
410
|
+
* The origins a session was told to trust, normalised, and what was given
|
|
411
|
+
* that is not one. An entry must be a plain http(s) origin — scheme, host and
|
|
412
|
+
* port, nothing after — so a path or a wildcard cannot widen it by accident.
|
|
413
|
+
*/
|
|
414
|
+
export function trustedEmbedOrigins(list) {
|
|
415
|
+
const origins = [];
|
|
416
|
+
const rejected = [];
|
|
417
|
+
const overflow = [];
|
|
418
|
+
for (const raw of list ?? []) {
|
|
419
|
+
let u;
|
|
420
|
+
try {
|
|
421
|
+
u = new URL(raw.trim());
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
rejected.push(raw);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const bare = u.pathname === "/" && !u.search && !u.hash && !u.username && !u.password;
|
|
428
|
+
if ((u.protocol !== "http:" && u.protocol !== "https:") || !bare || raw.includes("*")) {
|
|
429
|
+
rejected.push(raw);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// "pay.example.com." is "pay.example.com": one origin, one slot.
|
|
433
|
+
const origin = u.origin.replace(/\.(?=(:\d+)?$)/, "");
|
|
434
|
+
if (origins.includes(origin))
|
|
435
|
+
continue;
|
|
436
|
+
if (origins.length < MAX_TRUSTED_EMBEDS)
|
|
437
|
+
origins.push(origin);
|
|
438
|
+
else
|
|
439
|
+
overflow.push(raw);
|
|
440
|
+
}
|
|
441
|
+
return { origins, rejected, overflow };
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Whether the writes a frame of `origin` sends outside the app may go out
|
|
445
|
+
* after all: only for an origin the user named as trusted (a provider in test
|
|
446
|
+
* mode, say), and only in safe-write — read-only and observe keep their
|
|
447
|
+
* promise, and destructive allows everything already.
|
|
448
|
+
*/
|
|
449
|
+
export function trustsEmbedWrite(mode, trusted, origin) {
|
|
450
|
+
return mode === "safe-write" && trusted.has(origin);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Whether a foreign write may go out because of trust: every other site
|
|
454
|
+
* involved — each http(s) frame from the sender up to the page, and the
|
|
455
|
+
* Origin header when it names one — must be trusted, so an untrusted embed
|
|
456
|
+
* cannot borrow a trusted one it wraps. A page the session never adopted (a
|
|
457
|
+
* popup) is not a frame, and trust does not reach it.
|
|
458
|
+
*/
|
|
459
|
+
export function trustsForeignWrite(mode, trusted, appUrl, req) {
|
|
460
|
+
if (mode !== "safe-write" || trusted.size === 0 || req.unadoptedPageUrl)
|
|
461
|
+
return false;
|
|
462
|
+
const originOf = (url) => {
|
|
463
|
+
if (!url)
|
|
464
|
+
return null;
|
|
465
|
+
try {
|
|
466
|
+
const u = new URL(url);
|
|
467
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
const app = originOf(appUrl);
|
|
474
|
+
const involved = new Set();
|
|
475
|
+
for (const url of req.frameChain) {
|
|
476
|
+
// A blank or srcdoc frame is its parent's, and the parent is judged next.
|
|
477
|
+
// Any other frame with no web address (blob:, data:) could be an untrusted
|
|
478
|
+
// site that moved itself there to wrap a trusted one: no trust through it.
|
|
479
|
+
if (/^about:(blank|srcdoc)/i.test(url))
|
|
480
|
+
continue;
|
|
481
|
+
const o = originOf(url);
|
|
482
|
+
if (!o)
|
|
483
|
+
return false;
|
|
484
|
+
if (o !== app)
|
|
485
|
+
involved.add(o);
|
|
486
|
+
}
|
|
487
|
+
const header = originOf(req.originHeader);
|
|
488
|
+
if (header && header !== app)
|
|
489
|
+
involved.add(header);
|
|
490
|
+
return involved.size > 0 && [...involved].every((o) => trustsEmbedWrite(mode, trusted, o));
|
|
491
|
+
}
|
|
492
|
+
/** The longest text typed into another site's frame; past it, a value is a fuzzing probe, not a user's input. */
|
|
493
|
+
export const MAX_EMBED_TEXT = 200;
|
|
494
|
+
/**
|
|
495
|
+
* Why a value must not be typed into another site's frame, or null when it
|
|
496
|
+
* may be. The tester is authorised to test the app, not the embeds of
|
|
497
|
+
* others: markup, fuzzing lengths and control characters typed there would
|
|
498
|
+
* be probing a third party's system without its leave.
|
|
499
|
+
*/
|
|
500
|
+
export function hostileForEmbed(text) {
|
|
501
|
+
if (/<\s*[a-z!/?]/i.test(text) || /javascript:/i.test(text))
|
|
502
|
+
return "it is markup";
|
|
503
|
+
if (text.length > MAX_EMBED_TEXT)
|
|
504
|
+
return `it is longer than ${MAX_EMBED_TEXT} characters`;
|
|
505
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text))
|
|
506
|
+
return "it holds control characters";
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
/** The refusal for a probe aimed at another site's frame. */
|
|
510
|
+
export function embedProbeRefusal(what, origin) {
|
|
511
|
+
return (`REFUSED: ${what} inside a frame of ${origin}, another site embedded in the page. The tester is authorised to test the app, ` +
|
|
512
|
+
`not the embeds of others, so hostile input, repeated-click probes and file uploads are never sent into one, in any mode. ` +
|
|
513
|
+
`Ordinary clicks and typing there are allowed; the embed's writes out of the app are refused by the write policy.`);
|
|
514
|
+
}
|
|
397
515
|
/**
|
|
398
516
|
* The sandbox given to every document a frame of another origin loads, outside
|
|
399
517
|
* destructive mode: scripts, forms and its own origin keep working, and no
|