-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathliveness-browser.mjs
More file actions
106 lines (94 loc) · 3.71 KB
/
Copy pathliveness-browser.mjs
File metadata and controls
106 lines (94 loc) · 3.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/**
* liveness-browser.mjs — Playwright-driven liveness check for a single URL.
*
* Shared by check-liveness.mjs (CLI tool) and scan.mjs (--verify flag).
* Returns the same shape as classifyLiveness: { result, reason }.
*/
import { classifyLiveness } from './liveness-core.mjs';
const NAVIGATE_TIMEOUT_MS = 15_000;
const HYDRATION_WAIT_MS = 2_000;
// Defensive guards: URLs come from ATS feeds (mostly trusted) but a misconfigured
// portals.yml entry or a hijacked feed shouldn't be able to point Playwright at
// internal infrastructure. Only allow http(s) and reject loopback/private/link-local.
const PRIVATE_HOST_PATTERNS = [
/^localhost$/i,
/^127\./,
/^10\./,
/^192\.168\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^169\.254\./,
/^::1$/,
/^fc[0-9a-f]{2}:/i,
/^fe80:/i,
];
// Returns null when the URL is safe to fetch, otherwise a structured guard
// result with a stable `code` (used for routing in scan.mjs) plus a human
// `reason`. Stable codes — not regex on reason strings — drive downstream
// dispatch so the wording can change freely without breaking callers.
function rejectPrivateOrInvalid(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { code: 'invalid_url', reason: 'invalid URL' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { code: 'unsupported_protocol', reason: `unsupported protocol ${parsed.protocol}` };
}
if (PRIVATE_HOST_PATTERNS.some((pattern) => pattern.test(parsed.hostname))) {
return { code: 'blocked_host', reason: `blocked host ${parsed.hostname}` };
}
return null;
}
export async function checkUrlLiveness(page, url) {
const guardError = rejectPrivateOrInvalid(url);
if (guardError) {
return { result: 'uncertain', code: guardError.code, reason: guardError.reason };
}
try {
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
const status = response?.status() ?? 0;
// Give SPAs (Ashby, Lever, Workday) time to hydrate
await page.waitForTimeout(HYDRATION_WAIT_MS);
const finalUrl = page.url();
const bodyText = await page.evaluate(() => document.body?.innerText ?? '');
const applyControls = await page.evaluate(() => {
const candidates = Array.from(
document.querySelectorAll('a, button, input[type="submit"], input[type="button"], [role="button"]')
);
return candidates
.filter((element) => {
if (element.closest('nav, header, footer')) return false;
if (element.closest('[aria-hidden="true"]')) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (!element.getClientRects().length) return false;
return Array.from(element.getClientRects()).some((rect) => rect.width > 0 && rect.height > 0);
})
.map((element) => {
const label = [
element.innerText,
element.value,
element.getAttribute('aria-label'),
element.getAttribute('title'),
]
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
return label;
})
.filter(Boolean);
});
return classifyLiveness({ status, finalUrl, bodyText, applyControls });
} catch (err) {
// Transient failures (timeout, DNS, TLS, 5xx) shouldn't be treated as expired —
// doing so would cause scan --verify to drop the URL and write it to scan-history,
// permanently filtering it out on subsequent scans.
return {
result: 'uncertain',
code: 'navigation_error',
reason: `navigation error: ${err.message.split('\n')[0]}`,
};
}
}