@qulib/core 0.4.1 → 0.4.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.
Files changed (135) hide show
  1. package/README.md +56 -8
  2. package/dist/analyze.d.ts.map +1 -1
  3. package/dist/analyze.js +86 -7
  4. package/dist/cli/auth-login-resolve.d.ts +14 -0
  5. package/dist/cli/auth-login-resolve.d.ts.map +1 -0
  6. package/dist/cli/auth-login-resolve.js +68 -0
  7. package/dist/cli/auth-login-run.d.ts +13 -0
  8. package/dist/cli/auth-login-run.d.ts.map +1 -0
  9. package/dist/cli/auth-login-run.js +152 -0
  10. package/dist/cli/index.js +60 -7
  11. package/dist/harness/state-manager.d.ts +10 -0
  12. package/dist/harness/state-manager.d.ts.map +1 -1
  13. package/dist/harness/state-manager.js +15 -0
  14. package/dist/index.d.ts +8 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7 -6
  17. package/dist/phases/act.js +3 -3
  18. package/dist/phases/observe.js +5 -5
  19. package/dist/phases/think.js +1 -1
  20. package/dist/schemas/automation-maturity.schema.d.ts +40 -0
  21. package/dist/schemas/automation-maturity.schema.d.ts.map +1 -1
  22. package/dist/schemas/automation-maturity.schema.js +27 -0
  23. package/dist/schemas/index.d.ts +1 -1
  24. package/dist/schemas/index.d.ts.map +1 -1
  25. package/dist/schemas/index.js +1 -1
  26. package/dist/schemas/repo-analysis.schema.d.ts +22 -0
  27. package/dist/schemas/repo-analysis.schema.d.ts.map +1 -1
  28. package/dist/schemas/repo-analysis.schema.js +1 -0
  29. package/dist/telemetry/emit.d.ts +22 -0
  30. package/dist/telemetry/emit.d.ts.map +1 -1
  31. package/dist/telemetry/emit.js +37 -0
  32. package/dist/telemetry/telemetry.interface.d.ts +1 -1
  33. package/dist/telemetry/telemetry.interface.d.ts.map +1 -1
  34. package/dist/tools/apply-auth.d.ts +4 -0
  35. package/dist/tools/apply-auth.d.ts.map +1 -0
  36. package/dist/tools/apply-auth.js +35 -0
  37. package/dist/tools/auth/apply.d.ts +4 -0
  38. package/dist/tools/auth/apply.d.ts.map +1 -0
  39. package/dist/tools/auth/apply.js +35 -0
  40. package/dist/tools/auth/block-gap.d.ts +9 -0
  41. package/dist/tools/auth/block-gap.d.ts.map +1 -0
  42. package/dist/tools/auth/block-gap.js +52 -0
  43. package/dist/tools/auth/custom-providers.d.ts +15 -0
  44. package/dist/tools/auth/custom-providers.d.ts.map +1 -0
  45. package/dist/tools/auth/custom-providers.js +62 -0
  46. package/dist/tools/auth/detect.d.ts +23 -0
  47. package/dist/tools/auth/detect.d.ts.map +1 -0
  48. package/dist/tools/auth/detect.js +526 -0
  49. package/dist/tools/auth/detector.d.ts +23 -0
  50. package/dist/tools/auth/detector.d.ts.map +1 -0
  51. package/dist/tools/auth/detector.js +526 -0
  52. package/dist/tools/auth/explore.d.ts +4 -0
  53. package/dist/tools/auth/explore.d.ts.map +1 -0
  54. package/dist/tools/auth/explore.js +346 -0
  55. package/dist/tools/auth/explorer.d.ts +4 -0
  56. package/dist/tools/auth/explorer.d.ts.map +1 -0
  57. package/dist/tools/auth/explorer.js +346 -0
  58. package/dist/tools/auth/gaps.d.ts +9 -0
  59. package/dist/tools/auth/gaps.d.ts.map +1 -0
  60. package/dist/tools/auth/gaps.js +52 -0
  61. package/dist/tools/auth/oauth-providers.d.ts +7 -0
  62. package/dist/tools/auth/oauth-providers.d.ts.map +1 -0
  63. package/dist/tools/auth/oauth-providers.js +21 -0
  64. package/dist/tools/auth/providers.d.ts +7 -0
  65. package/dist/tools/auth/providers.d.ts.map +1 -0
  66. package/dist/tools/auth/providers.js +21 -0
  67. package/dist/tools/auth/surface-analyzer.d.ts +4 -0
  68. package/dist/tools/auth/surface-analyzer.d.ts.map +1 -0
  69. package/dist/tools/auth/surface-analyzer.js +170 -0
  70. package/dist/tools/auth/surface.d.ts +4 -0
  71. package/dist/tools/auth/surface.d.ts.map +1 -0
  72. package/dist/tools/auth/surface.js +170 -0
  73. package/dist/tools/auth/user-providers.d.ts +15 -0
  74. package/dist/tools/auth/user-providers.d.ts.map +1 -0
  75. package/dist/tools/auth/user-providers.js +62 -0
  76. package/dist/tools/auth-block-gap.d.ts +6 -0
  77. package/dist/tools/auth-block-gap.d.ts.map +1 -1
  78. package/dist/tools/auth-block-gap.js +42 -9
  79. package/dist/tools/auth-detector.d.ts +19 -0
  80. package/dist/tools/auth-detector.d.ts.map +1 -1
  81. package/dist/tools/auth-detector.js +186 -8
  82. package/dist/tools/automation-maturity.d.ts.map +1 -1
  83. package/dist/tools/automation-maturity.js +76 -20
  84. package/dist/tools/explorers/browser.d.ts +3 -0
  85. package/dist/tools/explorers/browser.d.ts.map +1 -0
  86. package/dist/tools/explorers/browser.js +13 -0
  87. package/dist/tools/explorers/cypress-explorer.d.ts +8 -0
  88. package/dist/tools/explorers/cypress-explorer.d.ts.map +1 -0
  89. package/dist/tools/explorers/cypress-explorer.js +5 -0
  90. package/dist/tools/explorers/cypress.d.ts +8 -0
  91. package/dist/tools/explorers/cypress.d.ts.map +1 -0
  92. package/dist/tools/explorers/cypress.js +5 -0
  93. package/dist/tools/explorers/explorer.interface.d.ts +7 -0
  94. package/dist/tools/explorers/explorer.interface.d.ts.map +1 -0
  95. package/dist/tools/explorers/explorer.interface.js +1 -0
  96. package/dist/tools/explorers/factory.d.ts +4 -0
  97. package/dist/tools/explorers/factory.d.ts.map +1 -0
  98. package/dist/tools/explorers/factory.js +12 -0
  99. package/dist/tools/explorers/playwright-explorer.d.ts +8 -0
  100. package/dist/tools/explorers/playwright-explorer.d.ts.map +1 -0
  101. package/dist/tools/explorers/playwright-explorer.js +172 -0
  102. package/dist/tools/explorers/playwright.d.ts +8 -0
  103. package/dist/tools/explorers/playwright.d.ts.map +1 -0
  104. package/dist/tools/explorers/playwright.js +172 -0
  105. package/dist/tools/explorers/types.d.ts +7 -0
  106. package/dist/tools/explorers/types.d.ts.map +1 -0
  107. package/dist/tools/explorers/types.js +1 -0
  108. package/dist/tools/playwright-explorer.js +1 -1
  109. package/dist/tools/repo/detect-framework.d.ts +15 -0
  110. package/dist/tools/repo/detect-framework.d.ts.map +1 -0
  111. package/dist/tools/repo/detect-framework.js +153 -0
  112. package/dist/tools/repo/framework-detector.d.ts +15 -0
  113. package/dist/tools/repo/framework-detector.d.ts.map +1 -0
  114. package/dist/tools/repo/framework-detector.js +153 -0
  115. package/dist/tools/repo/scan.d.ts +19 -0
  116. package/dist/tools/repo/scan.d.ts.map +1 -0
  117. package/dist/tools/repo/scan.js +181 -0
  118. package/dist/tools/repo/scanner.d.ts +19 -0
  119. package/dist/tools/repo/scanner.d.ts.map +1 -0
  120. package/dist/tools/repo/scanner.js +181 -0
  121. package/dist/tools/repo-scanner.d.ts.map +1 -1
  122. package/dist/tools/repo-scanner.js +7 -2
  123. package/dist/tools/scoring/automation-maturity.d.ts +4 -0
  124. package/dist/tools/scoring/automation-maturity.d.ts.map +1 -0
  125. package/dist/tools/scoring/automation-maturity.js +219 -0
  126. package/dist/tools/scoring/gap-engine.d.ts +8 -0
  127. package/dist/tools/scoring/gap-engine.d.ts.map +1 -0
  128. package/dist/tools/scoring/gap-engine.js +138 -0
  129. package/dist/tools/scoring/gaps.d.ts +8 -0
  130. package/dist/tools/scoring/gaps.d.ts.map +1 -0
  131. package/dist/tools/scoring/gaps.js +138 -0
  132. package/dist/tools/scoring/public-surface.d.ts +5 -0
  133. package/dist/tools/scoring/public-surface.d.ts.map +1 -0
  134. package/dist/tools/scoring/public-surface.js +13 -0
  135. package/package.json +3 -3
@@ -0,0 +1,4 @@
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=surface.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"surface.d.ts","sourceRoot":"","sources":["../../../src/tools/auth/surface.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,sCAAsC,CAAC;AAWhE,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,YAAY,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,GAAG,EAAE,CAAC,CAwKhB"}
@@ -0,0 +1,170 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { launchBrowser } from '../explorers/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
+ }
@@ -0,0 +1,15 @@
1
+ import type { OAuthProvider } from './oauth-providers.js';
2
+ export interface SerializedProvider {
3
+ id: string;
4
+ label: string;
5
+ patterns: string[];
6
+ }
7
+ export declare function loadUserProviders(): OAuthProvider[];
8
+ export declare function addUserProvider(input: {
9
+ id: string;
10
+ label: string;
11
+ pattern: string;
12
+ }): void;
13
+ export declare function removeUserProvider(id: string): boolean;
14
+ export declare function listUserProviders(): SerializedProvider[];
15
+ //# sourceMappingURL=user-providers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-providers.d.ts","sourceRoot":"","sources":["../../../src/tools/auth/user-providers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAI1D,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAgB,iBAAiB,IAAI,aAAa,EAAE,CAOnD;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAc3F;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAStD;AAED,wBAAgB,iBAAiB,IAAI,kBAAkB,EAAE,CAExD"}
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const USER_PROVIDERS_PATH = join(homedir(), '.qulib', 'providers.json');
5
+ export function loadUserProviders() {
6
+ const raw = loadSerialized();
7
+ return raw.map((p) => ({
8
+ id: p.id,
9
+ label: p.label,
10
+ patterns: p.patterns.map((src) => new RegExp(src, 'i')),
11
+ }));
12
+ }
13
+ export function addUserProvider(input) {
14
+ const existing = loadSerialized();
15
+ const idx = existing.findIndex((p) => p.id === input.id);
16
+ if (idx >= 0) {
17
+ const p = existing[idx];
18
+ if (!p.patterns.includes(input.pattern)) {
19
+ p.patterns.push(input.pattern);
20
+ }
21
+ p.label = input.label;
22
+ }
23
+ else {
24
+ existing.push({ id: input.id, label: input.label, patterns: [input.pattern] });
25
+ }
26
+ ensureDir();
27
+ writeFileSync(USER_PROVIDERS_PATH, JSON.stringify(existing, null, 2), 'utf-8');
28
+ }
29
+ export function removeUserProvider(id) {
30
+ const existing = loadSerialized();
31
+ const filtered = existing.filter((p) => p.id !== id);
32
+ if (filtered.length === existing.length) {
33
+ return false;
34
+ }
35
+ ensureDir();
36
+ writeFileSync(USER_PROVIDERS_PATH, JSON.stringify(filtered, null, 2), 'utf-8');
37
+ return true;
38
+ }
39
+ export function listUserProviders() {
40
+ return loadSerialized();
41
+ }
42
+ function loadSerialized() {
43
+ if (!existsSync(USER_PROVIDERS_PATH)) {
44
+ return [];
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(USER_PROVIDERS_PATH, 'utf-8'));
48
+ if (!Array.isArray(parsed)) {
49
+ return [];
50
+ }
51
+ return parsed;
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ function ensureDir() {
58
+ const dir = dirname(USER_PROVIDERS_PATH);
59
+ if (!existsSync(dir)) {
60
+ mkdirSync(dir, { recursive: true });
61
+ }
62
+ }
@@ -1,3 +1,9 @@
1
1
  import type { Gap } from '../schemas/gap-analysis.schema.js';
2
+ import type { StorageStateInvalidReason } from './auth-detector.js';
2
3
  export declare function buildAuthBlockGap(url: string): Gap;
4
+ export declare function buildStorageStateInvalidGap(input: {
5
+ url: string;
6
+ reasonCode: StorageStateInvalidReason;
7
+ reason: string;
8
+ }): Gap;
3
9
  //# sourceMappingURL=auth-block-gap.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-block-gap.d.ts","sourceRoot":"","sources":["../../src/tools/auth-block-gap.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAE7D,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAiBlD"}
1
+ {"version":3,"file":"auth-block-gap.d.ts","sourceRoot":"","sources":["../../src/tools/auth-block-gap.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mCAAmC,CAAC;AAC7D,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAmBpE,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAYlD;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE;IACjD,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,yBAAyB,CAAC;IACtC,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,GAAG,CAqBN"}
@@ -1,12 +1,23 @@
1
+ function safeOriginAndPath(url) {
2
+ try {
3
+ const u = new URL(url);
4
+ return `${u.origin}${u.pathname}`;
5
+ }
6
+ catch {
7
+ return url;
8
+ }
9
+ }
10
+ function safeHost(url) {
11
+ try {
12
+ return new URL(url).hostname;
13
+ }
14
+ catch {
15
+ return url;
16
+ }
17
+ }
1
18
  export function buildAuthBlockGap(url) {
2
- const host = (() => {
3
- try {
4
- return new URL(url).hostname;
5
- }
6
- catch {
7
- return url;
8
- }
9
- })();
19
+ const host = safeHost(url);
20
+ const safeUrl = safeOriginAndPath(url);
10
21
  return {
11
22
  id: 'auth-block',
12
23
  path: '/',
@@ -14,6 +25,28 @@ export function buildAuthBlockGap(url) {
14
25
  category: 'coverage',
15
26
  reason: `Scan blocked by authentication. No authenticated pages were evaluated for ${host}.`,
16
27
  description: 'Scan blocked by authentication. 0 authenticated pages were evaluated.',
17
- recommendation: `Run \`qulib auth init --base-url ${url}\` to capture a storage state, then re-run with --auth storage-state.`,
28
+ recommendation: `Run \`qulib auth init --base-url ${safeUrl}\` to capture a storage state, then re-run with --auth storage-state.`,
29
+ };
30
+ }
31
+ export function buildStorageStateInvalidGap(input) {
32
+ const host = safeHost(input.url);
33
+ const safeUrl = safeOriginAndPath(input.url);
34
+ const recoveryByCode = {
35
+ 'missing-file': `Storage state file was not found. Run \`qulib auth login --base-url ${safeUrl} --out <path>\` (or \`qulib auth init\`) to capture a fresh state, then re-run \`qulib analyze --url ${safeUrl} --auth-storage-state <path>\`.`,
36
+ 'unreadable-file': `Storage state file exists but could not be read. Check file permissions, then re-run \`qulib auth login\` if needed.`,
37
+ 'invalid-json': `Storage state file is not valid JSON. Run \`qulib auth login --base-url ${safeUrl} --out <path>\` again to regenerate it.`,
38
+ 'wrong-origin': `Storage state belongs to a different origin than ${host}. Re-run \`qulib auth login --base-url ${safeUrl}\` against this target and pass the new file to \`qulib analyze\`.`,
39
+ 'expired-or-unauthorized': `The session in the storage state has expired or is unauthorized. Run \`qulib auth login --base-url ${safeUrl}\` to capture a fresh state, then re-run \`qulib analyze --url ${safeUrl} --auth-storage-state <path>\`.`,
40
+ 'no-auth-cookies': `Storage state file contains no cookies or localStorage entries — it is effectively empty. Run \`qulib auth login --base-url ${safeUrl}\` to capture a real session.`,
41
+ unknown: `Storage state could not be validated. Try \`qulib auth login --base-url ${safeUrl}\` again, and verify the file was saved on the same origin.`,
42
+ };
43
+ return {
44
+ id: 'storage-state-invalid',
45
+ path: '/',
46
+ severity: 'critical',
47
+ category: 'coverage',
48
+ reason: `Authenticated scan could not continue because the provided storage state is invalid for ${host}. Reason: ${input.reasonCode} — ${input.reason}.`,
49
+ description: `Storage state validation failed before crawling. The session was checked against ${host} and rejected with reason code "${input.reasonCode}".`,
50
+ recommendation: recoveryByCode[input.reasonCode],
18
51
  };
19
52
  }
@@ -1,4 +1,23 @@
1
+ import type { Page } from '@playwright/test';
1
2
  import type { DetectedAuth } from '../schemas/config.schema.js';
2
3
  import type { AnalyzeProgressSink } from '../harness/progress-log.js';
4
+ export type StorageStateInvalidReason = 'missing-file' | 'unreadable-file' | 'invalid-json' | 'wrong-origin' | 'expired-or-unauthorized' | 'no-auth-cookies' | 'unknown';
5
+ export interface StorageStateValidationResult {
6
+ valid: boolean;
7
+ reasonCode: StorageStateInvalidReason | 'ok';
8
+ reason: string;
9
+ }
10
+ export declare function evaluateStorageStateValidity(signals: {
11
+ expectedOrigin: string;
12
+ finalUrl: string;
13
+ visiblePasswordCount: number;
14
+ hadUnauthorizedHttp: boolean;
15
+ }): StorageStateValidationResult;
16
+ export declare function preflightStorageStateFile(storagePath: string): Promise<StorageStateValidationResult | null>;
17
+ export declare function waitForReturnToOrigin(page: Page, baseUrl: string, timeoutMs?: number): Promise<{
18
+ returned: boolean;
19
+ finalUrl: string;
20
+ }>;
21
+ export declare function validateStorageState(url: string, storagePath: string, timeoutMs?: number): Promise<StorageStateValidationResult>;
3
22
  export declare function detectAuth(url: string, timeoutMs?: number, progress?: AnalyzeProgressSink): Promise<DetectedAuth>;
4
23
  //# sourceMappingURL=auth-detector.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth-detector.d.ts","sourceRoot":"","sources":["../../src/tools/auth-detector.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAY,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC1E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAsPtE,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,SAAQ,EACjB,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAkJvB"}
1
+ {"version":3,"file":"auth-detector.d.ts","sourceRoot":"","sources":["../../src/tools/auth-detector.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,KAAK,EAAY,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC1E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAItE,MAAM,MAAM,yBAAyB,GACjC,cAAc,GACd,iBAAiB,GACjB,cAAc,GACd,cAAc,GACd,yBAAyB,GACzB,iBAAiB,GACjB,SAAS,CAAC;AAEd,MAAM,WAAW,4BAA4B;IAC3C,KAAK,EAAE,OAAO,CAAC;IACf,UAAU,EAAE,yBAAyB,GAAG,IAAI,CAAC;IAC7C,MAAM,EAAE,MAAM,CAAC;CAChB;AAsQD,wBAAgB,4BAA4B,CAAC,OAAO,EAAE;IACpD,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,mBAAmB,EAAE,OAAO,CAAC;CAC9B,GAAG,4BAA4B,CA6B/B;AAOD,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,4BAA4B,GAAG,IAAI,CAAC,CAgD9C;AAED,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,MAAM,EACf,SAAS,SAAQ,GAChB,OAAO,CAAC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC,CAelD;AAED,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,SAAS,SAAQ,GAChB,OAAO,CAAC,4BAA4B,CAAC,CAyDvC;AAED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,SAAS,SAAQ,EACjB,QAAQ,CAAC,EAAE,mBAAmB,GAC7B,OAAO,CAAC,YAAY,CAAC,CAqJvB"}
@@ -1,3 +1,5 @@
1
+ import { resolve } from 'node:path';
2
+ import { readFile, stat } from 'node:fs/promises';
1
3
  import { launchBrowser } from './browser.js';
2
4
  import { BUILT_IN_OAUTH_PROVIDERS } from './oauth-providers.js';
3
5
  async function waitNetworkIdleBestEffort(page) {
@@ -166,6 +168,7 @@ async function probeClickToRevealForms(page, loginUrl, alreadyMatchedTexts, time
166
168
  continue;
167
169
  seenLabels.add(label);
168
170
  candidateAttempts += 1;
171
+ const originBefore = new URL(page.url()).origin;
169
172
  if (debugAuth()) {
170
173
  progress?.debug(`detect_auth click-reveal try label="${label.slice(0, 80)}"`);
171
174
  }
@@ -191,7 +194,21 @@ async function probeClickToRevealForms(page, loginUrl, alreadyMatchedTexts, time
191
194
  continue;
192
195
  }
193
196
  try {
194
- await page.locator('input[type="password"]').first().waitFor({ state: 'visible', timeout: 2000 });
197
+ await page.waitForLoadState('domcontentloaded', { timeout: 5000 });
198
+ }
199
+ catch {
200
+ /* best-effort after navigation */
201
+ }
202
+ if (new URL(page.url()).origin !== originBefore) {
203
+ if (debugAuth()) {
204
+ progress?.debug(`detect_auth click-reveal aborted (cross-origin after click): was ${originBefore} now ${new URL(page.url()).origin}`);
205
+ }
206
+ await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
207
+ await waitNetworkIdleBestEffort(page);
208
+ continue;
209
+ }
210
+ try {
211
+ await page.locator('input[type="password"]:visible').first().waitFor({ state: 'visible', timeout: 2000 });
195
212
  }
196
213
  catch {
197
214
  await page.goto(loginUrl, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
@@ -218,6 +235,161 @@ async function probeClickToRevealForms(page, loginUrl, alreadyMatchedTexts, time
218
235
  }
219
236
  return out;
220
237
  }
238
+ export function evaluateStorageStateValidity(signals) {
239
+ let finalOrigin = null;
240
+ try {
241
+ finalOrigin = new URL(signals.finalUrl).origin;
242
+ }
243
+ catch {
244
+ finalOrigin = null;
245
+ }
246
+ if (finalOrigin === null || finalOrigin !== signals.expectedOrigin) {
247
+ return {
248
+ valid: false,
249
+ reasonCode: 'wrong-origin',
250
+ reason: 'session redirected to a different origin than the target app',
251
+ };
252
+ }
253
+ if (signals.visiblePasswordCount > 0) {
254
+ return {
255
+ valid: false,
256
+ reasonCode: 'expired-or-unauthorized',
257
+ reason: 'login form still visible after loading storage state (session likely expired)',
258
+ };
259
+ }
260
+ if (signals.hadUnauthorizedHttp) {
261
+ return {
262
+ valid: false,
263
+ reasonCode: 'expired-or-unauthorized',
264
+ reason: 'HTTP 401/403 on authenticated request (session likely expired or invalid)',
265
+ };
266
+ }
267
+ return { valid: true, reasonCode: 'ok', reason: 'session appears active' };
268
+ }
269
+ export async function preflightStorageStateFile(storagePath) {
270
+ let exists = false;
271
+ try {
272
+ const s = await stat(storagePath);
273
+ exists = s.isFile();
274
+ }
275
+ catch {
276
+ exists = false;
277
+ }
278
+ if (!exists) {
279
+ return {
280
+ valid: false,
281
+ reasonCode: 'missing-file',
282
+ reason: 'storage state file does not exist',
283
+ };
284
+ }
285
+ let raw;
286
+ try {
287
+ raw = await readFile(storagePath, 'utf8');
288
+ }
289
+ catch {
290
+ return {
291
+ valid: false,
292
+ reasonCode: 'unreadable-file',
293
+ reason: 'storage state file is not readable',
294
+ };
295
+ }
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(raw);
299
+ }
300
+ catch {
301
+ return {
302
+ valid: false,
303
+ reasonCode: 'invalid-json',
304
+ reason: 'storage state file is not valid JSON',
305
+ };
306
+ }
307
+ const cookieCount = Array.isArray(parsed?.cookies) ? parsed.cookies.length : 0;
308
+ const originEntries = Array.isArray(parsed?.origins) ? parsed.origins : [];
309
+ const localStorageEntryCount = originEntries.reduce((sum, entry) => {
310
+ return sum + (Array.isArray(entry?.localStorage) ? entry.localStorage.length : 0);
311
+ }, 0);
312
+ if (cookieCount === 0 && localStorageEntryCount === 0) {
313
+ return {
314
+ valid: false,
315
+ reasonCode: 'no-auth-cookies',
316
+ reason: 'storage state file contains no cookies and no localStorage entries',
317
+ };
318
+ }
319
+ return null;
320
+ }
321
+ export async function waitForReturnToOrigin(page, baseUrl, timeoutMs = 15000) {
322
+ const targetOrigin = new URL(baseUrl).origin;
323
+ const deadline = Date.now() + timeoutMs;
324
+ while (Date.now() < deadline) {
325
+ const finalUrl = page.url();
326
+ try {
327
+ if (new URL(finalUrl).origin === targetOrigin) {
328
+ return { returned: true, finalUrl };
329
+ }
330
+ }
331
+ catch {
332
+ /* ignore transient invalid URLs */
333
+ }
334
+ await new Promise((r) => setTimeout(r, 500));
335
+ }
336
+ return { returned: false, finalUrl: page.url() };
337
+ }
338
+ export async function validateStorageState(url, storagePath, timeoutMs = 10000) {
339
+ let expectedOrigin;
340
+ try {
341
+ expectedOrigin = new URL(url).origin;
342
+ }
343
+ catch {
344
+ return {
345
+ valid: false,
346
+ reasonCode: 'unknown',
347
+ reason: 'target URL could not be parsed for origin matching',
348
+ };
349
+ }
350
+ const storageAbs = resolve(process.cwd(), storagePath);
351
+ const preflight = await preflightStorageStateFile(storageAbs);
352
+ if (preflight !== null) {
353
+ return preflight;
354
+ }
355
+ let hadUnauthorizedHttp = false;
356
+ const browser = await launchBrowser();
357
+ try {
358
+ const context = await browser.newContext({ storageState: storageAbs });
359
+ const page = await context.newPage();
360
+ page.on('response', (res) => {
361
+ const s = res.status();
362
+ if (s === 401 || s === 403) {
363
+ hadUnauthorizedHttp = true;
364
+ }
365
+ });
366
+ try {
367
+ await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' });
368
+ }
369
+ catch {
370
+ return {
371
+ valid: false,
372
+ reasonCode: 'unknown',
373
+ reason: 'navigation to target URL failed while validating storage state',
374
+ };
375
+ }
376
+ await waitNetworkIdleBestEffort(page);
377
+ const finalUrl = page.url();
378
+ const visiblePasswordCount = await page
379
+ .locator('input[type="password"]:visible')
380
+ .count()
381
+ .catch(() => 0);
382
+ return evaluateStorageStateValidity({
383
+ expectedOrigin,
384
+ finalUrl,
385
+ visiblePasswordCount,
386
+ hadUnauthorizedHttp,
387
+ });
388
+ }
389
+ finally {
390
+ await browser.close();
391
+ }
392
+ }
221
393
  export async function detectAuth(url, timeoutMs = 15000, progress) {
222
394
  const browser = await launchBrowser();
223
395
  try {
@@ -232,7 +404,7 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
232
404
  }
233
405
  let loginUrl = url;
234
406
  const looksLikeLoginPage = /login|sign[- ]?in|auth/i.test(page.url()) ||
235
- (await page.locator('input[type="password"]').count()) > 0;
407
+ (await page.locator('input[type="password"]:visible').count()) > 0;
236
408
  if (!looksLikeLoginPage) {
237
409
  const loginLink = page.locator('a').filter({ hasText: /^(log ?in|sign ?in|sign in)$/i }).first();
238
410
  const loginLinkCount = await loginLink.count();
@@ -247,7 +419,7 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
247
419
  }
248
420
  }
249
421
  }
250
- const passwordInputs = page.locator('input[type="password"]');
422
+ const passwordInputs = page.locator('input[type="password"]:visible');
251
423
  const passwordCount = await passwordInputs.count();
252
424
  progress?.debug(`detect_auth selector input[type=password] count=${passwordCount}`);
253
425
  const hasFormLogin = passwordCount > 0;
@@ -274,13 +446,19 @@ export async function detectAuth(url, timeoutMs = 15000, progress) {
274
446
  matchedAny = true;
275
447
  }
276
448
  }
277
- // Capture unrecognized SSO-like buttons so they appear in the result
278
- if (!matchedAny && !oauthButtons.find((b) => b.text === trimmed.slice(0, 100))) {
279
- oauthButtons.push({ provider: 'unknown', text: trimmed.slice(0, 100) });
449
+ if (!matchedAny) {
450
+ const builtIn = BUILT_IN_OAUTH_PROVIDERS.find((p) => p.label.toLowerCase() === trimmed.toLowerCase());
451
+ if (builtIn) {
452
+ if (!oauthButtons.find((b) => b.provider === builtIn.id)) {
453
+ oauthButtons.push({ provider: builtIn.id, text: trimmed.slice(0, 100) });
454
+ }
455
+ }
456
+ else if (!oauthButtons.find((b) => b.text === trimmed.slice(0, 100))) {
457
+ oauthButtons.push({ provider: 'unknown', text: trimmed.slice(0, 100) });
458
+ }
280
459
  }
281
460
  }
282
- // Only skip buttons already tied to a built-in IdP — leave `unknown` labels probe-able for click-to-reveal forms.
283
- const skipProbeLabels = new Set(oauthButtons.filter((b) => b.provider !== 'unknown').map((b) => b.text.trim()));
461
+ const skipProbeLabels = new Set(oauthButtons.map((b) => b.text.trim()));
284
462
  const clickRevealForms = await probeClickToRevealForms(page, loginUrl, skipProbeLabels, timeoutMs, progress);
285
463
  const pageText = await page.locator('body').innerText().catch(() => '');
286
464
  const hasMagicLink = MAGIC_LINK_PATTERNS.some((p) => p.test(pageText));
@@ -1 +1 @@
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,EAAE,kBAAkB,EAA+B,MAAM,0CAA0C,CAAC;AAiDhH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,YAAY,GAAG,kBAAkB,CA6HhF"}
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"}