@qulib/core 0.5.1 → 0.5.3
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 +9 -9
- package/dist/cli/index.js +3 -3
- package/dist/llm/context-builder.js +2 -2
- package/dist/tools/auth/detect.d.ts +8 -0
- package/dist/tools/auth/detect.d.ts.map +1 -1
- package/dist/tools/auth/detect.js +46 -9
- package/package.json +2 -2
- package/dist/tools/apply-auth.d.ts +0 -4
- package/dist/tools/apply-auth.d.ts.map +0 -1
- package/dist/tools/apply-auth.js +0 -35
- package/dist/tools/auth/block-gap.d.ts +0 -9
- package/dist/tools/auth/block-gap.d.ts.map +0 -1
- package/dist/tools/auth/block-gap.js +0 -52
- package/dist/tools/auth/detector.d.ts +0 -23
- package/dist/tools/auth/detector.d.ts.map +0 -1
- package/dist/tools/auth/detector.js +0 -526
- package/dist/tools/auth/explorer.d.ts +0 -4
- package/dist/tools/auth/explorer.d.ts.map +0 -1
- package/dist/tools/auth/explorer.js +0 -346
- package/dist/tools/auth/oauth-providers.d.ts +0 -7
- package/dist/tools/auth/oauth-providers.d.ts.map +0 -1
- package/dist/tools/auth/oauth-providers.js +0 -21
- package/dist/tools/auth/surface-analyzer.d.ts +0 -4
- package/dist/tools/auth/surface-analyzer.d.ts.map +0 -1
- package/dist/tools/auth/surface-analyzer.js +0 -170
- package/dist/tools/auth/user-providers.d.ts +0 -15
- package/dist/tools/auth/user-providers.d.ts.map +0 -1
- package/dist/tools/auth/user-providers.js +0 -62
- package/dist/tools/auth-block-gap.d.ts +0 -9
- package/dist/tools/auth-block-gap.d.ts.map +0 -1
- package/dist/tools/auth-block-gap.js +0 -52
- package/dist/tools/auth-detector.d.ts +0 -23
- package/dist/tools/auth-detector.d.ts.map +0 -1
- package/dist/tools/auth-detector.js +0 -526
- package/dist/tools/auth-explorer.d.ts +0 -4
- package/dist/tools/auth-explorer.d.ts.map +0 -1
- package/dist/tools/auth-explorer.js +0 -346
- package/dist/tools/auth-surface-analyzer.d.ts +0 -4
- package/dist/tools/auth-surface-analyzer.d.ts.map +0 -1
- package/dist/tools/auth-surface-analyzer.js +0 -170
- package/dist/tools/auth.d.ts +0 -4
- package/dist/tools/auth.d.ts.map +0 -1
- package/dist/tools/auth.js +0 -35
- package/dist/tools/automation-maturity.d.ts +0 -4
- package/dist/tools/automation-maturity.d.ts.map +0 -1
- package/dist/tools/automation-maturity.js +0 -219
- package/dist/tools/browser.d.ts +0 -3
- package/dist/tools/browser.d.ts.map +0 -1
- package/dist/tools/browser.js +0 -13
- package/dist/tools/cypress-explorer.d.ts +0 -8
- package/dist/tools/cypress-explorer.d.ts.map +0 -1
- package/dist/tools/cypress-explorer.js +0 -5
- package/dist/tools/explorer-factory.d.ts +0 -4
- package/dist/tools/explorer-factory.d.ts.map +0 -1
- package/dist/tools/explorer-factory.js +0 -12
- package/dist/tools/explorer.interface.d.ts +0 -7
- package/dist/tools/explorer.interface.d.ts.map +0 -1
- package/dist/tools/explorer.interface.js +0 -1
- package/dist/tools/explorers/cypress-explorer.d.ts +0 -8
- package/dist/tools/explorers/cypress-explorer.d.ts.map +0 -1
- package/dist/tools/explorers/cypress-explorer.js +0 -5
- package/dist/tools/explorers/explorer.interface.d.ts +0 -7
- package/dist/tools/explorers/explorer.interface.d.ts.map +0 -1
- package/dist/tools/explorers/explorer.interface.js +0 -1
- package/dist/tools/explorers/playwright-explorer.d.ts +0 -8
- package/dist/tools/explorers/playwright-explorer.d.ts.map +0 -1
- package/dist/tools/explorers/playwright-explorer.js +0 -172
- package/dist/tools/framework-detector.d.ts +0 -15
- package/dist/tools/framework-detector.d.ts.map +0 -1
- package/dist/tools/framework-detector.js +0 -153
- package/dist/tools/gap-engine.d.ts +0 -8
- package/dist/tools/gap-engine.d.ts.map +0 -1
- package/dist/tools/gap-engine.js +0 -138
- package/dist/tools/oauth-providers.d.ts +0 -7
- package/dist/tools/oauth-providers.d.ts.map +0 -1
- package/dist/tools/oauth-providers.js +0 -21
- package/dist/tools/playwright-explorer.d.ts +0 -8
- package/dist/tools/playwright-explorer.d.ts.map +0 -1
- package/dist/tools/playwright-explorer.js +0 -172
- package/dist/tools/public-surface.d.ts +0 -5
- package/dist/tools/public-surface.d.ts.map +0 -1
- package/dist/tools/public-surface.js +0 -13
- package/dist/tools/repo/framework-detector.d.ts +0 -15
- package/dist/tools/repo/framework-detector.d.ts.map +0 -1
- package/dist/tools/repo/framework-detector.js +0 -153
- package/dist/tools/repo/scanner.d.ts +0 -19
- package/dist/tools/repo/scanner.d.ts.map +0 -1
- package/dist/tools/repo/scanner.js +0 -181
- package/dist/tools/repo-scanner.d.ts +0 -19
- package/dist/tools/repo-scanner.d.ts.map +0 -1
- package/dist/tools/repo-scanner.js +0 -181
- package/dist/tools/scoring/gap-engine.d.ts +0 -8
- package/dist/tools/scoring/gap-engine.d.ts.map +0 -1
- package/dist/tools/scoring/gap-engine.js +0 -138
- package/dist/tools/user-providers.d.ts +0 -15
- package/dist/tools/user-providers.d.ts.map +0 -1
- package/dist/tools/user-providers.js +0 -62
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
import { AuthExplorationSchema, } from '../schemas/config.schema.js';
|
|
2
|
-
import { launchBrowser } from './browser.js';
|
|
3
|
-
import { BUILT_IN_OAUTH_PROVIDERS } from './oauth-providers.js';
|
|
4
|
-
import { loadUserProviders } from './user-providers.js';
|
|
5
|
-
const MAGIC_LINK_PATTERNS = [
|
|
6
|
-
/email me a (sign[- ]?in )?link/i,
|
|
7
|
-
/sign in with email/i,
|
|
8
|
-
/passwordless/i,
|
|
9
|
-
/we'll send you a link/i,
|
|
10
|
-
];
|
|
11
|
-
async function waitNetworkIdleBestEffort(page) {
|
|
12
|
-
try {
|
|
13
|
-
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
|
14
|
-
}
|
|
15
|
-
catch {
|
|
16
|
-
// best-effort
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
function textLooksLikeOAuthIdpButton(text) {
|
|
20
|
-
const t = text.trim();
|
|
21
|
-
if (t.length === 0 || t.length > 120) {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
return (/\b(sign in with|log in with|continue with|sign up with)\b/i.test(t) ||
|
|
25
|
-
/^(github|google|microsoft|apple)$/i.test(t));
|
|
26
|
-
}
|
|
27
|
-
function slugifyLabel(text) {
|
|
28
|
-
const s = text
|
|
29
|
-
.trim()
|
|
30
|
-
.toLowerCase()
|
|
31
|
-
.replace(/\s+/g, '-')
|
|
32
|
-
.replace(/[^a-z0-9-]/g, '')
|
|
33
|
-
.slice(0, 48);
|
|
34
|
-
return s.length > 0 ? s : 'unknown';
|
|
35
|
-
}
|
|
36
|
-
function onLoginishPage(url) {
|
|
37
|
-
return /login|sign[- ]?in|auth|sso|oauth/i.test(new URL(url).pathname + new URL(url).hostname);
|
|
38
|
-
}
|
|
39
|
-
function debugExplore() {
|
|
40
|
-
return process.env.QULIB_DEBUG === '1';
|
|
41
|
-
}
|
|
42
|
-
function isHeuristicUnknownSso(text, loginish) {
|
|
43
|
-
const t = text.trim();
|
|
44
|
-
if (!loginish || t.length < 3 || t.length > 80) {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
if (/^(submit|cancel|back|close|next|skip|help|faq)$/i.test(t)) {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
if (/\b(sign in with|log in with|continue with)\b/i.test(t)) {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
if (/\b(sync|sso|portal|workspace|federation)\b/i.test(t)) {
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
function storageRequirement() {
|
|
59
|
-
return {
|
|
60
|
-
method: 'storage-state',
|
|
61
|
-
instruction: 'OAuth and most SSO flows cannot be scripted. Run `qulib auth init --base-url <app-url>` on this machine, then pass the saved storage state JSON to `analyze` or MCP `analyze_app` as `auth: { type: "storage-state", path: "..." }`.',
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
async function collectVisibleControlTexts(page) {
|
|
65
|
-
return page.evaluate(() => {
|
|
66
|
-
const seen = new Set();
|
|
67
|
-
const out = [];
|
|
68
|
-
const nodes = document.querySelectorAll('button, a[href], [role="button"]');
|
|
69
|
-
for (const el of nodes) {
|
|
70
|
-
const t = (el.textContent ?? '').trim().replace(/\s+/g, ' ');
|
|
71
|
-
if (!t || t.length > 120) {
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
const style = window.getComputedStyle(el);
|
|
75
|
-
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (!seen.has(t)) {
|
|
79
|
-
seen.add(t);
|
|
80
|
-
out.push(t);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return out;
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
function buildAllProviders() {
|
|
87
|
-
const builtIn = BUILT_IN_OAUTH_PROVIDERS.map((p) => ({ ...p, source: 'built-in' }));
|
|
88
|
-
const user = loadUserProviders().map((p) => ({ ...p, source: 'user-local' }));
|
|
89
|
-
return [...builtIn, ...user];
|
|
90
|
-
}
|
|
91
|
-
function matchProvider(text, p) {
|
|
92
|
-
return p.patterns.some((re) => re.test(text));
|
|
93
|
-
}
|
|
94
|
-
function oauthConfidence(source, loginish) {
|
|
95
|
-
if (source === 'user-local') {
|
|
96
|
-
return 'high';
|
|
97
|
-
}
|
|
98
|
-
if (source === 'built-in' && loginish) {
|
|
99
|
-
return 'high';
|
|
100
|
-
}
|
|
101
|
-
if (source === 'built-in') {
|
|
102
|
-
return 'medium';
|
|
103
|
-
}
|
|
104
|
-
return 'low';
|
|
105
|
-
}
|
|
106
|
-
async function buildFormPaths(page) {
|
|
107
|
-
const passwordCount = await page.locator('input[type="password"]').count();
|
|
108
|
-
if (passwordCount === 0) {
|
|
109
|
-
return [];
|
|
110
|
-
}
|
|
111
|
-
const formType = passwordCount > 1 ? 'form-multi' : 'form-login';
|
|
112
|
-
const fields = await page.evaluate(() => {
|
|
113
|
-
const pwd = document.querySelector('input[type="password"]');
|
|
114
|
-
if (!pwd) {
|
|
115
|
-
return [];
|
|
116
|
-
}
|
|
117
|
-
const form = pwd.closest('form') ?? document.body;
|
|
118
|
-
const out = [];
|
|
119
|
-
const inputs = form.querySelectorAll('input, select, textarea');
|
|
120
|
-
for (const el of inputs) {
|
|
121
|
-
if (!(el instanceof HTMLElement)) {
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
const tag = el.tagName.toLowerCase();
|
|
125
|
-
if (tag === 'input') {
|
|
126
|
-
const inp = el;
|
|
127
|
-
const t = (inp.type || 'text').toLowerCase();
|
|
128
|
-
if (['hidden', 'submit', 'button', 'image', 'reset'].includes(t)) {
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
let fieldType = 'text';
|
|
132
|
-
if (t === 'password') {
|
|
133
|
-
fieldType = 'password';
|
|
134
|
-
}
|
|
135
|
-
else if (t === 'email') {
|
|
136
|
-
fieldType = 'email';
|
|
137
|
-
}
|
|
138
|
-
else if (t === 'checkbox') {
|
|
139
|
-
fieldType = 'checkbox';
|
|
140
|
-
}
|
|
141
|
-
const id = inp.id;
|
|
142
|
-
let label = inp.getAttribute('aria-label') ?? inp.placeholder ?? inp.name ?? fieldType;
|
|
143
|
-
if (id) {
|
|
144
|
-
const lab = document.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
145
|
-
if (lab?.textContent) {
|
|
146
|
-
label = lab.textContent.trim();
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
out.push({
|
|
150
|
-
name: inp.name || inp.id || fieldType,
|
|
151
|
-
label: label.slice(0, 120),
|
|
152
|
-
type: fieldType,
|
|
153
|
-
observedOptions: [],
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
else if (tag === 'select') {
|
|
157
|
-
const sel = el;
|
|
158
|
-
const opts = Array.from(sel.options).map((o) => o.text.trim()).filter(Boolean);
|
|
159
|
-
out.push({
|
|
160
|
-
name: sel.name || sel.id || 'select',
|
|
161
|
-
label: (sel.getAttribute('aria-label') ?? sel.name ?? 'select').slice(0, 120),
|
|
162
|
-
type: 'select',
|
|
163
|
-
observedOptions: opts.slice(0, 50),
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
});
|
|
169
|
-
const requirements = fields.length > 0
|
|
170
|
-
? { method: 'credentials', fields }
|
|
171
|
-
: {
|
|
172
|
-
method: 'unknown',
|
|
173
|
-
instruction: 'A password field exists but field metadata could not be read. Inspect the page in devtools and configure form-login selectors manually, or use `qulib auth init`.',
|
|
174
|
-
};
|
|
175
|
-
return [
|
|
176
|
-
{
|
|
177
|
-
id: formType === 'form-multi' ? 'form-multi' : 'form-login',
|
|
178
|
-
label: formType === 'form-multi' ? 'Multi-field sign-in form' : 'Username / password form',
|
|
179
|
-
type: formType,
|
|
180
|
-
provider: null,
|
|
181
|
-
source: 'heuristic',
|
|
182
|
-
automatable: requirements.method === 'credentials',
|
|
183
|
-
confidence: requirements.method === 'credentials' ? 'medium' : 'low',
|
|
184
|
-
requirements,
|
|
185
|
-
},
|
|
186
|
-
];
|
|
187
|
-
}
|
|
188
|
-
export async function exploreAuth(url, timeoutMs = 20000, progress) {
|
|
189
|
-
const browser = await launchBrowser();
|
|
190
|
-
try {
|
|
191
|
-
const context = await browser.newContext();
|
|
192
|
-
const page = await context.newPage();
|
|
193
|
-
progress?.info(`explore_auth URL=${url}`);
|
|
194
|
-
await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
195
|
-
await waitNetworkIdleBestEffort(page);
|
|
196
|
-
if (debugExplore()) {
|
|
197
|
-
const html = await page.content();
|
|
198
|
-
progress?.debug(`explore_auth HTML byteLength=${Buffer.byteLength(html, 'utf8')}`);
|
|
199
|
-
}
|
|
200
|
-
const loginishAfterFirst = /login|sign[- ]?in|auth/i.test(page.url()) || (await page.locator('input[type="password"]').count()) > 0;
|
|
201
|
-
if (!loginishAfterFirst) {
|
|
202
|
-
const loginLink = page.locator('a').filter({ hasText: /^(log ?in|sign ?in|sign in)$/i }).first();
|
|
203
|
-
const cnt = await loginLink.count();
|
|
204
|
-
progress?.debug(`explore_auth selector loginLink count=${cnt}`);
|
|
205
|
-
if (cnt > 0) {
|
|
206
|
-
const href = await loginLink.getAttribute('href');
|
|
207
|
-
progress?.debug(`explore_auth selector loginLink href matched=${Boolean(href)}`);
|
|
208
|
-
if (href) {
|
|
209
|
-
const next = new URL(href, url).toString();
|
|
210
|
-
await page.goto(next, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
211
|
-
await waitNetworkIdleBestEffort(page);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
const finalUrl = page.url();
|
|
216
|
-
const loginish = onLoginishPage(finalUrl) || (await page.locator('input[type="password"]').count()) > 0;
|
|
217
|
-
const allProviders = buildAllProviders();
|
|
218
|
-
const texts = await collectVisibleControlTexts(page);
|
|
219
|
-
const consumed = new Set();
|
|
220
|
-
const authPaths = [];
|
|
221
|
-
const unrecognizedButtons = [];
|
|
222
|
-
for (const rawText of texts) {
|
|
223
|
-
const text = rawText.trim();
|
|
224
|
-
if (!text) {
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
let providerMatch = null;
|
|
228
|
-
for (const p of allProviders) {
|
|
229
|
-
const hit = matchProvider(text, p);
|
|
230
|
-
if (debugExplore()) {
|
|
231
|
-
progress?.debug(`explore_auth provider try id=${p.id} matched=${hit}`);
|
|
232
|
-
}
|
|
233
|
-
if (!hit) {
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
if (p.source === 'built-in' && !(textLooksLikeOAuthIdpButton(text) || loginish)) {
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
providerMatch = { p, gate: textLooksLikeOAuthIdpButton(text) || loginish };
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
if (providerMatch) {
|
|
243
|
-
const { p, gate } = providerMatch;
|
|
244
|
-
const id = `oauth:${p.id}`;
|
|
245
|
-
if (consumed.has(id)) {
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
consumed.add(id);
|
|
249
|
-
authPaths.push({
|
|
250
|
-
id,
|
|
251
|
-
label: p.label,
|
|
252
|
-
type: 'oauth',
|
|
253
|
-
provider: p.id,
|
|
254
|
-
source: p.source,
|
|
255
|
-
automatable: false,
|
|
256
|
-
confidence: oauthConfidence(p.source, loginish || gate),
|
|
257
|
-
requirements: storageRequirement(),
|
|
258
|
-
});
|
|
259
|
-
progress?.info(`explore_auth path id=${id} type=oauth provider=${p.id} automatable=false`);
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
if (isHeuristicUnknownSso(text, loginish)) {
|
|
263
|
-
const slug = slugifyLabel(text);
|
|
264
|
-
const id = `oauth-unknown:${slug}`;
|
|
265
|
-
if (consumed.has(id)) {
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
consumed.add(id);
|
|
269
|
-
authPaths.push({
|
|
270
|
-
id,
|
|
271
|
-
label: text.slice(0, 100),
|
|
272
|
-
type: 'oauth-unknown',
|
|
273
|
-
provider: null,
|
|
274
|
-
source: 'heuristic',
|
|
275
|
-
automatable: false,
|
|
276
|
-
confidence: 'low',
|
|
277
|
-
requirements: storageRequirement(),
|
|
278
|
-
});
|
|
279
|
-
progress?.info(`explore_auth path id=${id} type=oauth-unknown automatable=false`);
|
|
280
|
-
const safePattern = text.slice(0, 48).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
281
|
-
unrecognizedButtons.push({
|
|
282
|
-
label: text.slice(0, 100),
|
|
283
|
-
hint: `If this is your org SSO, register it: qulib auth providers add --id "${slug}" --label "${text.replace(/"/g, '\\"').slice(0, 80)}" --pattern "${safePattern}"`,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
const pageText = await page.locator('body').innerText().catch(() => '');
|
|
288
|
-
if (MAGIC_LINK_PATTERNS.some((re) => re.test(pageText))) {
|
|
289
|
-
authPaths.push({
|
|
290
|
-
id: 'magic-link',
|
|
291
|
-
label: 'Magic link / passwordless',
|
|
292
|
-
type: 'magic-link',
|
|
293
|
-
provider: null,
|
|
294
|
-
source: 'heuristic',
|
|
295
|
-
automatable: false,
|
|
296
|
-
confidence: 'medium',
|
|
297
|
-
requirements: {
|
|
298
|
-
method: 'storage-state',
|
|
299
|
-
instruction: 'Magic-link flows need a human in the loop. Use `qulib auth init --base-url <app-url>` and complete email or provider steps in the opened browser, then reuse the saved storage state for scans.',
|
|
300
|
-
},
|
|
301
|
-
});
|
|
302
|
-
progress?.info('explore_auth path id=magic-link type=magic-link automatable=false');
|
|
303
|
-
}
|
|
304
|
-
const formPaths = await buildFormPaths(page);
|
|
305
|
-
for (const fp of formPaths) {
|
|
306
|
-
authPaths.push(fp);
|
|
307
|
-
progress?.info(`explore_auth path id=${fp.id} type=${fp.type} automatable=${fp.automatable}`);
|
|
308
|
-
}
|
|
309
|
-
const authRequired = authPaths.length > 0;
|
|
310
|
-
let authScope = 'none';
|
|
311
|
-
if (authRequired) {
|
|
312
|
-
if (loginish) {
|
|
313
|
-
authScope = 'site-wide';
|
|
314
|
-
}
|
|
315
|
-
else {
|
|
316
|
-
authScope = /login|signin|auth/i.test(new URL(finalUrl).pathname) ? 'site-wide' : 'optional';
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const suggestedParts = [];
|
|
320
|
-
if (authPaths.some((p) => p.type === 'oauth' || p.type === 'oauth-unknown')) {
|
|
321
|
-
suggestedParts.push('For OAuth or unrecognized SSO buttons, collect a Playwright storage state with `qulib auth init` before calling `analyze_app`.');
|
|
322
|
-
}
|
|
323
|
-
if (authPaths.some((p) => p.type === 'form-login' || p.type === 'form-multi')) {
|
|
324
|
-
suggestedParts.push('For password forms, gather username/password and stable selectors (or use storage state if MFA applies).');
|
|
325
|
-
}
|
|
326
|
-
if (authPaths.some((p) => p.type === 'magic-link')) {
|
|
327
|
-
suggestedParts.push('For magic-link, use `qulib auth init` after the user completes email delivery.');
|
|
328
|
-
}
|
|
329
|
-
if (!authRequired) {
|
|
330
|
-
suggestedParts.push('No sign-in surface detected at this URL; you can run `analyze_app` without auth unless gated deeper in the app.');
|
|
331
|
-
}
|
|
332
|
-
const exploration = {
|
|
333
|
-
url: finalUrl,
|
|
334
|
-
authRequired,
|
|
335
|
-
authScope,
|
|
336
|
-
authPaths,
|
|
337
|
-
observedAt: new Date().toISOString(),
|
|
338
|
-
suggestedAgentBehavior: suggestedParts.join(' '),
|
|
339
|
-
unrecognizedButtons,
|
|
340
|
-
};
|
|
341
|
-
return AuthExplorationSchema.parse(exploration);
|
|
342
|
-
}
|
|
343
|
-
finally {
|
|
344
|
-
await browser.close();
|
|
345
|
-
}
|
|
346
|
-
}
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import type { DetectedAuth } from '../schemas/config.schema.js';
|
|
2
|
-
import type { Gap } from '../schemas/gap-analysis.schema.js';
|
|
3
|
-
export declare function analyzeAuthSurfaceGaps(url: string, detection: DetectedAuth, timeoutMs: number): Promise<Gap[]>;
|
|
4
|
-
//# sourceMappingURL=auth-surface-analyzer.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth-surface-analyzer.d.ts","sourceRoot":"","sources":["../../src/tools/auth-surface-analyzer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAW7D,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,YAAY,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,EAAE,CAAC,CAwKhB"}
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { launchBrowser } from './browser.js';
|
|
3
|
-
async function waitNetworkIdleBestEffort(page) {
|
|
4
|
-
try {
|
|
5
|
-
await page.waitForLoadState('networkidle', { timeout: 5000 });
|
|
6
|
-
}
|
|
7
|
-
catch {
|
|
8
|
-
/* best-effort */
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
export async function analyzeAuthSurfaceGaps(url, detection, timeoutMs) {
|
|
12
|
-
if (!detection.hasAuth) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
|
-
const gaps = [];
|
|
16
|
-
const browser = await launchBrowser();
|
|
17
|
-
try {
|
|
18
|
-
const context = await browser.newContext();
|
|
19
|
-
const page = await context.newPage();
|
|
20
|
-
const resp = await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' }).catch(() => null);
|
|
21
|
-
if (!resp || !resp.ok()) {
|
|
22
|
-
gaps.push({
|
|
23
|
-
id: randomUUID(),
|
|
24
|
-
path: new URL(url).pathname || '/',
|
|
25
|
-
severity: 'critical',
|
|
26
|
-
category: 'auth-surface',
|
|
27
|
-
reason: 'Sign-in surface did not load successfully for evaluation.',
|
|
28
|
-
description: 'The auth entry URL failed to load or returned a non-OK status before DOM checks could run.',
|
|
29
|
-
recommendation: 'Verify DNS, TLS, and that the URL is reachable from the scan environment.',
|
|
30
|
-
});
|
|
31
|
-
return gaps;
|
|
32
|
-
}
|
|
33
|
-
await waitNetworkIdleBestEffort(page);
|
|
34
|
-
const title = await page.title().catch(() => '');
|
|
35
|
-
if (!title || title.trim().length < 3) {
|
|
36
|
-
gaps.push({
|
|
37
|
-
id: randomUUID(),
|
|
38
|
-
path: '/',
|
|
39
|
-
severity: 'medium',
|
|
40
|
-
category: 'auth-surface',
|
|
41
|
-
reason: 'Missing or trivial document title on the sign-in surface.',
|
|
42
|
-
description: 'Users and assistive tech rely on a meaningful window title.',
|
|
43
|
-
recommendation: 'Set a concise, unique <title> for the login experience.',
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
const metaDesc = await page.locator('meta[name="description"]').getAttribute('content').catch(() => null);
|
|
47
|
-
if (!metaDesc || metaDesc.trim().length < 8) {
|
|
48
|
-
gaps.push({
|
|
49
|
-
id: randomUUID(),
|
|
50
|
-
path: '/',
|
|
51
|
-
severity: 'low',
|
|
52
|
-
category: 'auth-surface',
|
|
53
|
-
reason: 'No meta description on the sign-in surface.',
|
|
54
|
-
description: 'Search and sharing previews benefit from meta description on public entry pages.',
|
|
55
|
-
recommendation: 'Add <meta name="description" content="..."> with a short summary of the product.',
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
const h1Count = await page.locator('h1').count();
|
|
59
|
-
if (h1Count === 0) {
|
|
60
|
-
gaps.push({
|
|
61
|
-
id: randomUUID(),
|
|
62
|
-
path: '/',
|
|
63
|
-
severity: 'medium',
|
|
64
|
-
category: 'auth-surface',
|
|
65
|
-
reason: 'No visible primary heading (h1) on the sign-in surface.',
|
|
66
|
-
description: 'A primary heading helps users orient on the login page.',
|
|
67
|
-
recommendation: 'Add a single descriptive <h1> for the sign-in view.',
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
const oauthButtons = page.locator('button, a[href], [role="button"]');
|
|
71
|
-
const n = await oauthButtons.count();
|
|
72
|
-
for (let i = 0; i < Math.min(n, 25); i++) {
|
|
73
|
-
const el = oauthButtons.nth(i);
|
|
74
|
-
const text = ((await el.textContent()) ?? '').trim();
|
|
75
|
-
if (!text || text.length > 120)
|
|
76
|
-
continue;
|
|
77
|
-
const isOAuthish = /google|microsoft|github|apple|sso|sign in with|log in with|continue with|oauth/i.test(text);
|
|
78
|
-
if (!isOAuthish)
|
|
79
|
-
continue;
|
|
80
|
-
const role = await el.getAttribute('role');
|
|
81
|
-
const tag = await el.evaluate((node) => node.tagName.toLowerCase());
|
|
82
|
-
const tabIndex = await el.getAttribute('tabindex');
|
|
83
|
-
const aria = await el.getAttribute('aria-label');
|
|
84
|
-
const keyboardable = tag === 'button' || tag === 'a' || role === 'button';
|
|
85
|
-
const labeled = Boolean(aria && aria.trim().length > 0) || text.length > 0;
|
|
86
|
-
if (!keyboardable || tabIndex === '-1') {
|
|
87
|
-
gaps.push({
|
|
88
|
-
id: randomUUID(),
|
|
89
|
-
path: '/',
|
|
90
|
-
severity: 'high',
|
|
91
|
-
category: 'auth-surface',
|
|
92
|
-
reason: `OAuth control "${text.slice(0, 60)}" may not be keyboard-accessible.`,
|
|
93
|
-
description: 'SSO entry points should be real buttons or links with focus support.',
|
|
94
|
-
recommendation: 'Use <button> or <a href> with visible label; avoid tabindex=-1 on the only sign-in path.',
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
else if (!labeled) {
|
|
98
|
-
gaps.push({
|
|
99
|
-
id: randomUUID(),
|
|
100
|
-
path: '/',
|
|
101
|
-
severity: 'medium',
|
|
102
|
-
category: 'auth-surface',
|
|
103
|
-
reason: `OAuth control "${text.slice(0, 60)}" lacks aria-label and has weak visible text.`,
|
|
104
|
-
description: 'Assistive technologies need a clear accessible name for IdP buttons.',
|
|
105
|
-
recommendation: 'Add aria-label or visible text that names the provider and action.',
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const hasPassword = (await page.locator('input[type="password"]').count()) > 0;
|
|
110
|
-
const hasEmailLink = await page.getByText(/magic link|email.*link|passwordless/i).count();
|
|
111
|
-
const hasOAuthUi = detection.oauthButtons.length > 0 ||
|
|
112
|
-
(await page.getByText(/sign in with|continue with google|microsoft|github/i).count()) > 0;
|
|
113
|
-
const formLoginFallbacks = (detection.authOptions ?? []).filter((o) => o.type === 'form-login');
|
|
114
|
-
const hasFormLoginFallback = formLoginFallbacks.length > 0;
|
|
115
|
-
if (detection.type === 'oauth' && hasOAuthUi && !hasPassword && !hasEmailLink) {
|
|
116
|
-
if (hasFormLoginFallback) {
|
|
117
|
-
const labels = formLoginFallbacks.map((o) => o.label).join(', ');
|
|
118
|
-
gaps.push({
|
|
119
|
-
id: randomUUID(),
|
|
120
|
-
path: '/',
|
|
121
|
-
severity: 'low',
|
|
122
|
-
category: 'auth-surface',
|
|
123
|
-
reason: `OAuth-primary login with form-login fallback detected via: ${labels}`,
|
|
124
|
-
description: 'A form-based login path exists alongside OAuth. Automate via type="form-login" using the selectors in authOptions.',
|
|
125
|
-
recommendation: `Automatable form option(s): ${labels}. Configure type="form-login" with credentials and selectors from detectedAuth.authOptions.`,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
gaps.push({
|
|
130
|
-
id: randomUUID(),
|
|
131
|
-
path: '/',
|
|
132
|
-
severity: 'medium',
|
|
133
|
-
category: 'auth-surface',
|
|
134
|
-
reason: 'OAuth-only entry with no visible password or magic-link fallback.',
|
|
135
|
-
description: 'Users who cannot use a social IdP need another path (email/password, help, or support).',
|
|
136
|
-
recommendation: 'Add a documented fallback (email/password, help desk link, or alternate IdP).',
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const errorSelectors = '[role="alert"], [data-testid*="error" i], .error, .alert-danger, [class*="error" i]';
|
|
141
|
-
const errCount = await page.locator(errorSelectors).count();
|
|
142
|
-
if (errCount === 0 && hasOAuthUi) {
|
|
143
|
-
gaps.push({
|
|
144
|
-
id: randomUUID(),
|
|
145
|
-
path: '/',
|
|
146
|
-
severity: 'low',
|
|
147
|
-
category: 'auth-surface',
|
|
148
|
-
reason: 'No obvious in-DOM error container found for OAuth sign-in failures.',
|
|
149
|
-
description: 'IdP failures should surface recoverable feedback in the page.',
|
|
150
|
-
recommendation: 'Reserve a live region or inline alert for OAuth errors returned from the provider.',
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
const help = await page.getByText(/forgot password|need help|contact support|get help/i).count();
|
|
154
|
-
if (help === 0 && hasOAuthUi) {
|
|
155
|
-
gaps.push({
|
|
156
|
-
id: randomUUID(),
|
|
157
|
-
path: '/',
|
|
158
|
-
severity: 'low',
|
|
159
|
-
category: 'auth-surface',
|
|
160
|
-
reason: 'No visible “forgot password” or help path detected near OAuth controls.',
|
|
161
|
-
description: 'Users locked out of an IdP need a support or recovery affordance.',
|
|
162
|
-
recommendation: 'Link to account recovery, IT help, or a support URL near the sign-in actions.',
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
finally {
|
|
167
|
-
await browser.close();
|
|
168
|
-
}
|
|
169
|
-
return gaps;
|
|
170
|
-
}
|
package/dist/tools/auth.d.ts
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import type { Browser, BrowserContext } from '@playwright/test';
|
|
2
|
-
import type { AuthConfig } from '../schemas/config.schema.js';
|
|
3
|
-
export declare function createAuthenticatedContext(browser: Browser, auth: AuthConfig | undefined, timeoutMs: number): Promise<BrowserContext>;
|
|
4
|
-
//# sourceMappingURL=auth.d.ts.map
|
package/dist/tools/auth.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/tools/auth.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAEhE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,UAAU,GAAG,SAAS,EAC5B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,cAAc,CAAC,CAsCzB"}
|
package/dist/tools/auth.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { resolve } from 'node:path';
|
|
2
|
-
export async function createAuthenticatedContext(browser, auth, timeoutMs) {
|
|
3
|
-
if (!auth) {
|
|
4
|
-
return browser.newContext();
|
|
5
|
-
}
|
|
6
|
-
if (auth.type === 'storage-state') {
|
|
7
|
-
const storagePath = resolve(process.cwd(), auth.path);
|
|
8
|
-
return browser.newContext({ storageState: storagePath });
|
|
9
|
-
}
|
|
10
|
-
const context = await browser.newContext();
|
|
11
|
-
const page = await context.newPage();
|
|
12
|
-
try {
|
|
13
|
-
await page.goto(auth.loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
|
|
14
|
-
await page.fill(auth.selectors.username, auth.credentials.username);
|
|
15
|
-
await page.fill(auth.selectors.password, auth.credentials.password);
|
|
16
|
-
await page.click(auth.selectors.submit);
|
|
17
|
-
const urlFragment = auth.successIndicator.urlContains;
|
|
18
|
-
if (urlFragment) {
|
|
19
|
-
await page.waitForURL((url) => url.toString().includes(urlFragment), {
|
|
20
|
-
timeout: timeoutMs,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
const visibleSelector = auth.successIndicator.selectorVisible;
|
|
24
|
-
if (visibleSelector) {
|
|
25
|
-
await page.waitForSelector(visibleSelector, {
|
|
26
|
-
timeout: timeoutMs,
|
|
27
|
-
state: 'visible',
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
finally {
|
|
32
|
-
await page.close();
|
|
33
|
-
}
|
|
34
|
-
return context;
|
|
35
|
-
}
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import type { RepoAnalysis } from '../schemas/repo-analysis.schema.js';
|
|
2
|
-
import type { AutomationMaturity } from '../schemas/automation-maturity.schema.js';
|
|
3
|
-
export declare function computeAutomationMaturity(repo: RepoAnalysis): AutomationMaturity;
|
|
4
|
-
//# sourceMappingURL=automation-maturity.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"automation-maturity.d.ts","sourceRoot":"","sources":["../../src/tools/automation-maturity.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AACvE,OAAO,KAAK,EACV,kBAAkB,EAGnB,MAAM,0CAA0C,CAAC;AAiDlD,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,YAAY,GAAG,kBAAkB,CAuLhF"}
|