scenescout 3.15.0 → 3.17.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 +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When an interactive sign-in has finished, so `scenescout login` and
|
|
3
|
+
* scout_login can save the profile and close the window without anyone
|
|
4
|
+
* pressing Enter in a terminal.
|
|
5
|
+
*
|
|
6
|
+
* The window is looked at every half second or so (login-run.ts). Each look
|
|
7
|
+
* is reduced to a few facts: where the tab is, whether it shows a password or
|
|
8
|
+
* one-time-code field, and which cookies and storage entries the app's page
|
|
9
|
+
* holds. This file decides from a run of those looks whether the person is
|
|
10
|
+
* signed in. It never sees the browser, so every case is a table test.
|
|
11
|
+
*
|
|
12
|
+
* Signed in means all of these, on two looks in a row at the same address:
|
|
13
|
+
* - the tab is back on the app (the origin the sign-in started at, or the
|
|
14
|
+
* same host's other scheme or www. its first page landed on), not on
|
|
15
|
+
* an identity provider, and no other tab is still on one;
|
|
16
|
+
* - the page shows no password or one-time-code field and its path is not
|
|
17
|
+
* a sign-in route or step (a second factor, an account picker);
|
|
18
|
+
* - it is not an OAuth, OpenID Connect or SAML return the app has yet to
|
|
19
|
+
* exchange (a `code` with a `state`, a token in the fragment, a SAML
|
|
20
|
+
* response);
|
|
21
|
+
* - a credential appeared that was not there when the window opened, or
|
|
22
|
+
* one there then changed: a cookie sent to this page or a storage entry
|
|
23
|
+
* of its origin whose name or value looks like a session;
|
|
24
|
+
* - the person went through a sign-in screen first (a sign-in field, a
|
|
25
|
+
* sign-in route, or another origin), so a landing page that sets a cookie
|
|
26
|
+
* when a banner is dismissed is never taken for a sign-in.
|
|
27
|
+
*
|
|
28
|
+
* A success URL, when one is given, replaces the credential and the sign-in
|
|
29
|
+
* route: once a sign-in screen has been seen, the page at that URL with no
|
|
30
|
+
* sign-in field on it is signed in.
|
|
31
|
+
*/
|
|
32
|
+
import { LOGIN_ROUTE_RE } from "./authloss.js";
|
|
33
|
+
import { urlMatches } from "./scripted-login.js";
|
|
34
|
+
/** Looks in a row that must agree before the window is closed: a redirect chain passes through pages that look done for a moment. */
|
|
35
|
+
export const STABLE_LOOKS = 2;
|
|
36
|
+
/** How often the window is looked at, in ms. */
|
|
37
|
+
export const LOOK_EVERY_MS = 500;
|
|
38
|
+
export const WAIT_SAYS = {
|
|
39
|
+
away: "on another site (the identity provider)",
|
|
40
|
+
popup: "a sign-in window on another site is still open",
|
|
41
|
+
"sign-in-screen": "on the sign-in screen",
|
|
42
|
+
returning: "back on the app, which is still finishing the sign-in",
|
|
43
|
+
"no-session": "on the app, but it holds no new session yet",
|
|
44
|
+
"not-started": "no sign-in screen has been seen yet",
|
|
45
|
+
settling: "signed in, making sure the page has settled",
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Start watching. The app is the origin of the URL the window opened at, and
|
|
49
|
+
* also the origin its first page landed on when that is the same host but
|
|
50
|
+
* for a leading `www.` or the scheme (http to https, the apex to www): an app
|
|
51
|
+
* that redirects there before anyone signs in. A redirect to any other host
|
|
52
|
+
* is the identity provider. The baseline is what the window held then.
|
|
53
|
+
*/
|
|
54
|
+
export function startWatch(startUrl, held, successUrl, landedUrl) {
|
|
55
|
+
const start = originOf(startUrl);
|
|
56
|
+
const landed = landedUrl === undefined ? null : originOf(landedUrl);
|
|
57
|
+
const origins = [start ?? startUrl];
|
|
58
|
+
if (start && landed && landed !== start && bareHost(start) === bareHost(landed))
|
|
59
|
+
origins.push(landed);
|
|
60
|
+
return {
|
|
61
|
+
appOrigins: origins,
|
|
62
|
+
baseline: new Map(held.map((h) => [h.key, h.value])),
|
|
63
|
+
sawSignIn: false,
|
|
64
|
+
candidate: null,
|
|
65
|
+
...(successUrl ? { successUrl } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** An origin's host without a leading `www.`. The port is left out: a scheme change moves the default port. */
|
|
69
|
+
function bareHost(origin) {
|
|
70
|
+
return new URL(origin).hostname.replace(/^www\./i, "");
|
|
71
|
+
}
|
|
72
|
+
function originOf(url) {
|
|
73
|
+
try {
|
|
74
|
+
const u = new URL(url);
|
|
75
|
+
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The address is one of the app's. */
|
|
82
|
+
export function onApp(watch, url) {
|
|
83
|
+
const origin = originOf(url);
|
|
84
|
+
return origin !== null && watch.appOrigins.includes(origin);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* An address an identity provider sends the browser back to before the app
|
|
88
|
+
* has exchanged what it carries: an authorization code with its state, a
|
|
89
|
+
* token or an error in the fragment, or a SAML response. The page that
|
|
90
|
+
* follows it is the one to judge.
|
|
91
|
+
*/
|
|
92
|
+
export function isAuthReturn(url) {
|
|
93
|
+
let u;
|
|
94
|
+
try {
|
|
95
|
+
u = new URL(url);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const q = u.searchParams;
|
|
101
|
+
if (q.has("code") && q.has("state"))
|
|
102
|
+
return true;
|
|
103
|
+
if (q.has("SAMLResponse") || q.has("SAMLart"))
|
|
104
|
+
return true;
|
|
105
|
+
const fragment = new URLSearchParams(u.hash.replace(/^#/, ""));
|
|
106
|
+
return fragment.has("access_token") || fragment.has("id_token") || (fragment.has("code") && fragment.has("state"));
|
|
107
|
+
}
|
|
108
|
+
/** Names a session credential goes by. */
|
|
109
|
+
const CREDENTIAL_NAME = /sess|auth|token|jwt|(^|[^a-z])sid([^a-z]|$)|login|ident|user|account|remember|bearer|oidc|saml|msal|cognito|aspnetcore/i;
|
|
110
|
+
/** Names of values a sign-in sets on the way that are not the session: anti-forgery, the redirect's own state, where to go after. */
|
|
111
|
+
const NOT_CREDENTIAL_NAME = /csrf|xsrf|nonce|pkce|verifier|consent|redirect|return_?(to|url)|state$/i;
|
|
112
|
+
/** Analytics and marketing cookies, whose long values say nothing about a session. */
|
|
113
|
+
const ANALYTICS_NAME = /^(_ga|_gid|_gcl|_fbp|_fbc|_hj|_pk_|_uet|_clck|_clsk|ajs_|amp_|mp_|optimizely|intercom|hubspot|__hs)/i;
|
|
114
|
+
const JWT = /^(Bearer\s+)?eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
115
|
+
/** A token inside a JSON value: what a sign-in library stores beside the user it signed in. */
|
|
116
|
+
const JSON_TOKEN = /"(access_?token|id_?token|refresh_?token|token|jwt)"\s*:\s*"[^"]{8,}"/i;
|
|
117
|
+
/** An opaque random-looking value: long, one token, from the alphabets session ids are written in. */
|
|
118
|
+
const OPAQUE = /^[A-Za-z0-9+/=_.%:-]{20,}$/;
|
|
119
|
+
/**
|
|
120
|
+
* This value looks like a session credential. A JWT always does and a JSON
|
|
121
|
+
* value does when it carries a token, whatever their names; otherwise the
|
|
122
|
+
* name decides, and a long opaque value under a name that says nothing.
|
|
123
|
+
*/
|
|
124
|
+
export function looksLikeCredential(h) {
|
|
125
|
+
if (h.value === "")
|
|
126
|
+
return false;
|
|
127
|
+
if (ANALYTICS_NAME.test(h.name))
|
|
128
|
+
return false;
|
|
129
|
+
if (JWT.test(h.value))
|
|
130
|
+
return true;
|
|
131
|
+
if (/^\s*[{[]/.test(h.value))
|
|
132
|
+
return JSON_TOKEN.test(h.value);
|
|
133
|
+
if (NOT_CREDENTIAL_NAME.test(h.name))
|
|
134
|
+
return false;
|
|
135
|
+
if (CREDENTIAL_NAME.test(h.name))
|
|
136
|
+
return true;
|
|
137
|
+
return OPAQUE.test(h.value);
|
|
138
|
+
}
|
|
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
|
+
export function newCredential(watch, held) {
|
|
141
|
+
for (const h of held) {
|
|
142
|
+
if (watch.baseline.get(h.key) === h.value)
|
|
143
|
+
continue;
|
|
144
|
+
if (looksLikeCredential(h))
|
|
145
|
+
return h;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Steps of a sign-in that can show no field: approving a push on a phone, a
|
|
151
|
+
* second factor's challenge, choosing an account or a tenant. An app often
|
|
152
|
+
* holds a partial session by then, so the path is what says it is not done.
|
|
153
|
+
*/
|
|
154
|
+
const SIGN_IN_STEP_RE = /\/(log-in|mfa|2fa|otp|verify|challenge|select-account|choose-account|account-picker)(\/|$)/;
|
|
155
|
+
/** The path is a sign-in route (/login, /signin, /auth/..., /mfa, /verify, an account picker). */
|
|
156
|
+
export function signInRoute(url) {
|
|
157
|
+
try {
|
|
158
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
159
|
+
return LOGIN_ROUTE_RE.test(path) || SIGN_IN_STEP_RE.test(path);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Judge one look. Returns the watch to carry to the next look and the
|
|
167
|
+
* verdict; signed-in only once STABLE_LOOKS looks in a row at one address
|
|
168
|
+
* read that way.
|
|
169
|
+
*/
|
|
170
|
+
export function judgeSignIn(watch, look) {
|
|
171
|
+
const waiting = (reason, sawSignIn = watch.sawSignIn) => ({
|
|
172
|
+
watch: { ...watch, sawSignIn, candidate: null },
|
|
173
|
+
verdict: { kind: "waiting", reason },
|
|
174
|
+
});
|
|
175
|
+
const app = onApp(watch, look.url);
|
|
176
|
+
// An absolute success URL may be anywhere; a path is only looked for on the app.
|
|
177
|
+
const successAbsolute = watch.successUrl !== undefined && /^https?:\/\//i.test(watch.successUrl);
|
|
178
|
+
if (!app && !(successAbsolute && urlMatches(look.url, watch.successUrl))) {
|
|
179
|
+
// about:blank before the first page loads is nowhere, not another site.
|
|
180
|
+
return originOf(look.url) === null ? waiting("not-started") : waiting("away", true);
|
|
181
|
+
}
|
|
182
|
+
if (look.popupAway)
|
|
183
|
+
return waiting("popup", true);
|
|
184
|
+
if (look.signInField)
|
|
185
|
+
return waiting("sign-in-screen", true);
|
|
186
|
+
if (isAuthReturn(look.url))
|
|
187
|
+
return waiting("returning", true);
|
|
188
|
+
if (signInRoute(look.url))
|
|
189
|
+
return waiting("sign-in-screen", true);
|
|
190
|
+
let via;
|
|
191
|
+
if (watch.successUrl !== undefined) {
|
|
192
|
+
if (!urlMatches(look.url, watch.successUrl))
|
|
193
|
+
return waiting("no-session");
|
|
194
|
+
// A success URL the first page already matches (a path such as "/") is not a sign-in until one has been seen.
|
|
195
|
+
if (!watch.sawSignIn)
|
|
196
|
+
return waiting("not-started");
|
|
197
|
+
via = { kind: "signed-in", via: "success-url" };
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
const credential = newCredential(watch, look.held);
|
|
201
|
+
if (!credential)
|
|
202
|
+
return waiting(watch.sawSignIn ? "no-session" : "not-started");
|
|
203
|
+
if (!watch.sawSignIn)
|
|
204
|
+
return waiting("not-started");
|
|
205
|
+
via = {
|
|
206
|
+
kind: "signed-in",
|
|
207
|
+
via: "credential",
|
|
208
|
+
credential: `${credential.kind === "cookie" ? "cookie" : credential.kind === "local" ? "localStorage" : "sessionStorage"} "${credential.name}"`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const looks = watch.candidate?.url === look.url ? watch.candidate.looks + 1 : 1;
|
|
212
|
+
const next = { ...watch, candidate: { url: look.url, looks } };
|
|
213
|
+
if (looks < STABLE_LOOKS)
|
|
214
|
+
return { watch: next, verdict: { kind: "waiting", reason: "settling" } };
|
|
215
|
+
return { watch: next, verdict: via };
|
|
216
|
+
}
|
|
217
|
+
/** What an interactive login saves on: `auto` when sign-in is detected or Enter is pressed, `enter` on Enter alone. */
|
|
218
|
+
export const SAVE_MODES = ["auto", "enter"];
|
|
219
|
+
export const DEFAULT_SAVE_MODE = "auto";
|
|
220
|
+
/**
|
|
221
|
+
* The sign-in windows scout_login has open, one per project and role, so a
|
|
222
|
+
* second call waits on the window the first one opened. A window that has
|
|
223
|
+
* finished is kept, with its outcome, until a call has reported it: a sign-in
|
|
224
|
+
* that ends between two calls is reported by the next one rather than lost
|
|
225
|
+
* behind a fresh window. An outcome nobody asks for within `keepMs` is
|
|
226
|
+
* dropped, so a call long after starts over.
|
|
227
|
+
*/
|
|
228
|
+
export class LoginWindows {
|
|
229
|
+
keepMs;
|
|
230
|
+
now;
|
|
231
|
+
open = new Map();
|
|
232
|
+
constructor(keepMs, now = () => Date.now()) {
|
|
233
|
+
this.keepMs = keepMs;
|
|
234
|
+
this.now = now;
|
|
235
|
+
}
|
|
236
|
+
/** The window for `key`: the open one, a finished one not yet reported, or a new one from `start`. */
|
|
237
|
+
async get(key, start) {
|
|
238
|
+
const held = this.open.get(key);
|
|
239
|
+
if (held && (held.settledAt === undefined || this.now() - held.settledAt <= this.keepMs))
|
|
240
|
+
return { window: held.window, resumed: true };
|
|
241
|
+
const window = await start();
|
|
242
|
+
const entry = { window };
|
|
243
|
+
this.open.set(key, entry);
|
|
244
|
+
void window.done.then(() => (entry.settledAt = this.now()), () => (entry.settledAt = this.now()));
|
|
245
|
+
return { window, resumed: false };
|
|
246
|
+
}
|
|
247
|
+
/** A call has reported this window's outcome: the next call for `key` opens a new one. */
|
|
248
|
+
reported(key, window) {
|
|
249
|
+
if (this.open.get(key)?.window === window)
|
|
250
|
+
this.open.delete(key);
|
|
251
|
+
}
|
|
252
|
+
/** Every window still held, for shutdown. */
|
|
253
|
+
all() {
|
|
254
|
+
return [...this.open.values()].map((e) => e.window);
|
|
255
|
+
}
|
|
256
|
+
}
|