@govtechsg/oobee 0.10.61 → 0.10.65
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/Dockerfile +8 -3
- package/README.md +3 -0
- package/package.json +4 -2
- package/src/cli.ts +32 -39
- package/src/combine.ts +6 -8
- package/src/constants/cliFunctions.ts +5 -4
- package/src/constants/common.ts +72 -54
- package/src/constants/constants.ts +56 -50
- package/src/constants/questions.ts +15 -4
- package/src/crawlers/commonCrawlerFunc.ts +2 -2
- package/src/crawlers/crawlDomain.ts +4 -3
- package/src/crawlers/crawlIntelligentSitemap.ts +2 -3
- package/src/crawlers/crawlLocalFile.ts +31 -31
- package/src/crawlers/crawlSitemap.ts +10 -9
- package/src/crawlers/custom/utils.ts +2 -2
- package/src/crawlers/pdfScanFunc.ts +24 -51
- package/src/crawlers/runCustom.ts +4 -3
- package/src/index.ts +7 -5
- package/src/logs.ts +35 -9
- package/src/mergeAxeResults.ts +23 -11
- package/src/npmIndex.ts +2 -3
- package/src/proxyService.ts +405 -0
- package/src/screenshotFunc/htmlScreenshotFunc.ts +4 -4
- package/src/screenshotFunc/pdfScreenshotFunc.ts +2 -5
- package/src/static/ejs/partials/scripts/utils.ejs +8 -11
- package/src/utils.ts +310 -65
package/src/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
cleanUp,
|
|
8
8
|
getUserDataTxt,
|
|
9
9
|
writeToUserDataTxt,
|
|
10
|
+
listenForCleanUp,
|
|
11
|
+
cleanUpAndExit,
|
|
10
12
|
} from './utils.js';
|
|
11
13
|
import {
|
|
12
14
|
prepareData,
|
|
@@ -106,19 +108,19 @@ const runScan = async (answers: Answers) => {
|
|
|
106
108
|
answers.metadata = '{}';
|
|
107
109
|
|
|
108
110
|
const data: Data = await prepareData(answers);
|
|
111
|
+
|
|
112
|
+
// Executes cleanUp script if error encountered
|
|
113
|
+
listenForCleanUp(data.randomToken);
|
|
114
|
+
|
|
109
115
|
data.userDataDirectory = getClonedProfilesWithRandomToken(data.browser, data.randomToken);
|
|
110
116
|
|
|
111
117
|
printMessage(['Scanning website...'], messageOptions);
|
|
112
118
|
|
|
113
119
|
await combineRun(data, screenToScan);
|
|
114
120
|
|
|
115
|
-
// Delete cloned directory
|
|
116
|
-
deleteClonedProfiles(data.browser, data.randomToken);
|
|
117
|
-
|
|
118
121
|
// Delete dataset and request queues
|
|
119
|
-
|
|
122
|
+
cleanUpAndExit(0, data.randomToken);
|
|
120
123
|
|
|
121
|
-
process.exit(0);
|
|
122
124
|
};
|
|
123
125
|
|
|
124
126
|
if (userData) {
|
package/src/logs.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
/* eslint-disable no-shadow */
|
|
3
3
|
import { createLogger, format, transports } from 'winston';
|
|
4
4
|
import { guiInfoStatusTypes } from './constants/constants.js';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
5
7
|
|
|
6
8
|
const { combine, timestamp, printf } = format;
|
|
7
9
|
|
|
@@ -20,12 +22,32 @@ const logFormat = printf(({ timestamp, level, message }) => {
|
|
|
20
22
|
// transport: storage device for logs
|
|
21
23
|
// Enabled for console and storing into files; Files are overwritten each time
|
|
22
24
|
// All logs in combined.txt, error in errors.txt
|
|
25
|
+
const uuid = randomUUID();
|
|
26
|
+
let basePath: string;
|
|
27
|
+
|
|
28
|
+
if (process.env.OOBEE_LOGS_PATH) {
|
|
29
|
+
basePath = process.env.OOBEE_LOGS_PATH;
|
|
30
|
+
} else if (process.platform === 'win32') {
|
|
31
|
+
basePath = path.join(process.env.APPDATA, 'Oobee');
|
|
32
|
+
} else if (process.platform === 'darwin') {
|
|
33
|
+
basePath = path.join(process.env.HOME, 'Library', 'Application Support', 'Oobee');
|
|
34
|
+
} else {
|
|
35
|
+
basePath = path.join(process.cwd());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const errorsTxtPath = path.join(basePath, `${uuid}.txt`);
|
|
23
39
|
|
|
24
40
|
const consoleLogger = createLogger({
|
|
25
41
|
silent: !(process.env.RUNNING_FROM_PH_GUI || process.env.OOBEE_VERBOSE),
|
|
26
42
|
format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat),
|
|
27
|
-
transports:
|
|
28
|
-
|
|
43
|
+
transports: [
|
|
44
|
+
new transports.Console({ level: 'info' }),
|
|
45
|
+
new transports.File({
|
|
46
|
+
filename: errorsTxtPath,
|
|
47
|
+
level: 'info',
|
|
48
|
+
handleExceptions: true,
|
|
49
|
+
}),
|
|
50
|
+
],
|
|
29
51
|
});
|
|
30
52
|
|
|
31
53
|
// No display in consoles, this will mostly be used within the interactive script to avoid disrupting the flow
|
|
@@ -34,9 +56,10 @@ const consoleLogger = createLogger({
|
|
|
34
56
|
const silentLogger = createLogger({
|
|
35
57
|
format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat),
|
|
36
58
|
transports: [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
new transports.File({
|
|
60
|
+
filename: errorsTxtPath,
|
|
61
|
+
level: 'warn',
|
|
62
|
+
handleExceptions: true }),
|
|
40
63
|
].filter(Boolean),
|
|
41
64
|
});
|
|
42
65
|
|
|
@@ -46,16 +69,17 @@ export const guiInfoLog = (status: string, data: { numScanned?: number; urlScann
|
|
|
46
69
|
switch (status) {
|
|
47
70
|
case guiInfoStatusTypes.COMPLETED:
|
|
48
71
|
console.log('Scan completed');
|
|
72
|
+
silentLogger.info('Scan completed');
|
|
49
73
|
break;
|
|
50
74
|
case guiInfoStatusTypes.SCANNED:
|
|
51
75
|
case guiInfoStatusTypes.SKIPPED:
|
|
52
76
|
case guiInfoStatusTypes.ERROR:
|
|
53
77
|
case guiInfoStatusTypes.DUPLICATE:
|
|
54
|
-
|
|
55
|
-
`crawling::${data.numScanned || 0}::${status}::${
|
|
78
|
+
const msg = `crawling::${data.numScanned || 0}::${status}::${
|
|
56
79
|
data.urlScanned || 'no url provided'
|
|
57
|
-
}
|
|
58
|
-
);
|
|
80
|
+
}`;
|
|
81
|
+
console.log(msg);
|
|
82
|
+
silentLogger.info(msg);
|
|
59
83
|
break;
|
|
60
84
|
default:
|
|
61
85
|
console.log(`Status provided to gui info log not recognized: ${status}`);
|
|
@@ -64,4 +88,6 @@ export const guiInfoLog = (status: string, data: { numScanned?: number; urlScann
|
|
|
64
88
|
}
|
|
65
89
|
};
|
|
66
90
|
|
|
91
|
+
consoleLogger.info(`Logger writing to: ${errorsTxtPath}`);
|
|
92
|
+
|
|
67
93
|
export { logFormat, consoleLogger, silentLogger };
|
package/src/mergeAxeResults.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { Base64Encode } from 'base64-stream';
|
|
|
14
14
|
import { pipeline } from 'stream/promises';
|
|
15
15
|
// @ts-ignore
|
|
16
16
|
import * as Sentry from '@sentry/node';
|
|
17
|
-
import constants, { ScannerTypes, sentryConfig, setSentryUser } from './constants/constants.js';
|
|
17
|
+
import constants, { BrowserTypes, ScannerTypes, sentryConfig, setSentryUser } from './constants/constants.js';
|
|
18
18
|
import { getBrowserToRun, getPlaywrightLaunchOptions } from './constants/common.js';
|
|
19
19
|
|
|
20
20
|
import {
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
getWcagCriteriaMap,
|
|
30
30
|
categorizeWcagCriteria,
|
|
31
31
|
getUserDataTxt,
|
|
32
|
+
register
|
|
32
33
|
} from './utils.js';
|
|
33
34
|
import { consoleLogger, silentLogger } from './logs.js';
|
|
34
35
|
import itemTypeDescription from './constants/itemTypeDescription.js';
|
|
@@ -961,8 +962,6 @@ const writeScanDetailsCsv = async (
|
|
|
961
962
|
});
|
|
962
963
|
};
|
|
963
964
|
|
|
964
|
-
let browserChannel = getBrowserToRun().browserToRun;
|
|
965
|
-
|
|
966
965
|
const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filename = 'summary', browser: string, userDataDirectory: string) => {
|
|
967
966
|
const htmlFilePath = `${storagePath}/${filename}.html`;
|
|
968
967
|
const fileDestinationPath = `${storagePath}/${filename}.pdf`;
|
|
@@ -975,6 +974,8 @@ const writeSummaryPdf = async (storagePath: string, pagesScanned: number, filena
|
|
|
975
974
|
...getPlaywrightLaunchOptions(browser),
|
|
976
975
|
});
|
|
977
976
|
|
|
977
|
+
register(context);
|
|
978
|
+
|
|
978
979
|
const page = await context.newPage();
|
|
979
980
|
|
|
980
981
|
const data = fs.readFileSync(htmlFilePath, { encoding: 'utf-8' });
|
|
@@ -1710,9 +1711,9 @@ const generateArtifacts = async (
|
|
|
1710
1711
|
zip: string = undefined, // optional
|
|
1711
1712
|
generateJsonFiles = false,
|
|
1712
1713
|
) => {
|
|
1713
|
-
const intermediateDatasetsPath = `${getStoragePath(randomToken)}/crawlee`;
|
|
1714
|
-
const oobeeAppVersion = getVersion();
|
|
1715
1714
|
const storagePath = getStoragePath(randomToken);
|
|
1715
|
+
const intermediateDatasetsPath = `${storagePath}/crawlee`;
|
|
1716
|
+
const oobeeAppVersion = getVersion();
|
|
1716
1717
|
|
|
1717
1718
|
const formatAboutStartTime = (dateString: string) => {
|
|
1718
1719
|
const utcStartTimeDate = new Date(dateString);
|
|
@@ -1982,15 +1983,21 @@ const generateArtifacts = async (
|
|
|
1982
1983
|
]);
|
|
1983
1984
|
}
|
|
1984
1985
|
|
|
1986
|
+
let browserChannel = getBrowserToRun(randomToken, BrowserTypes.CHROME, false).browserToRun;
|
|
1987
|
+
|
|
1985
1988
|
// Should consider refactor constants.userDataDirectory to be a parameter in future
|
|
1986
1989
|
await retryFunction(() => writeSummaryPdf(storagePath, pagesScanned.length, 'summary', browserChannel, constants.userDataDirectory), 1);
|
|
1987
1990
|
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1991
|
+
try {
|
|
1992
|
+
fs.rmSync(path.join(storagePath, 'crawlee'), { recursive: true, force: true });
|
|
1993
|
+
} catch (error) {
|
|
1994
|
+
consoleLogger.warn(`Unable to force remove crawlee folder: ${error.message}`);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
try {
|
|
1998
|
+
fs.rmSync(path.join(storagePath, 'pdfs'), { recursive: true, force: true });
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
consoleLogger.warn(`Unable to force remove pdfs folder: ${error.message}`);
|
|
1994
2001
|
}
|
|
1995
2002
|
|
|
1996
2003
|
// Take option if set
|
|
@@ -2000,6 +2007,11 @@ const generateArtifacts = async (
|
|
|
2000
2007
|
if (!zip.endsWith('.zip')) {
|
|
2001
2008
|
constants.cliZipFileName += '.zip';
|
|
2002
2009
|
}
|
|
2010
|
+
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
if (!path.isAbsolute(constants.cliZipFileName) || path.dirname(constants.cliZipFileName) === '.') {
|
|
2014
|
+
constants.cliZipFileName = path.join(storagePath, constants.cliZipFileName);
|
|
2003
2015
|
}
|
|
2004
2016
|
|
|
2005
2017
|
await fs
|
package/src/npmIndex.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
submitForm,
|
|
13
13
|
} from './constants/common.js';
|
|
14
14
|
import { createCrawleeSubFolders, filterAxeResults } from './crawlers/commonCrawlerFunc.js';
|
|
15
|
-
import { createAndUpdateResultsFolders
|
|
15
|
+
import { createAndUpdateResultsFolders } from './utils.js';
|
|
16
16
|
import generateArtifacts from './mergeAxeResults.js';
|
|
17
17
|
import { takeScreenshotForHTMLElements } from './screenshotFunc/htmlScreenshotFunc.js';
|
|
18
18
|
import { consoleLogger, silentLogger } from './logs.js';
|
|
@@ -205,7 +205,7 @@ export const init = async ({
|
|
|
205
205
|
throwErrorIfTerminated();
|
|
206
206
|
if (includeScreenshots) {
|
|
207
207
|
// use chrome by default
|
|
208
|
-
const { browserToRun, clonedBrowserDataDir } = getBrowserToRun(BrowserTypes.CHROME, false
|
|
208
|
+
const { browserToRun, clonedBrowserDataDir } = getBrowserToRun(randomToken, BrowserTypes.CHROME, false);
|
|
209
209
|
const browserContext = await constants.launcher.launchPersistentContext(
|
|
210
210
|
clonedBrowserDataDir,
|
|
211
211
|
{ viewport: viewportSettings, ...getPlaywrightLaunchOptions(browserToRun) },
|
|
@@ -271,7 +271,6 @@ export const init = async ({
|
|
|
271
271
|
if (urlsCrawled.scanned.length === 0) {
|
|
272
272
|
printMessage([`No pages were scanned.`], alertMessageOptions);
|
|
273
273
|
} else {
|
|
274
|
-
await createDetailsAndLogs(randomToken);
|
|
275
274
|
await createAndUpdateResultsFolders(randomToken);
|
|
276
275
|
const pagesNotScanned = [
|
|
277
276
|
...scanDetails.urlsCrawled.error,
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// getProxyInfo.ts
|
|
2
|
+
// Cross-platform proxy detector for Playwright/Chromium with PAC + credentials + macOS Keychain support.
|
|
3
|
+
//
|
|
4
|
+
// Windows: WinINET registry (HKCU → HKLM)
|
|
5
|
+
// macOS: env vars first, then `scutil --proxy` (reads per-protocol usernames/passwords;
|
|
6
|
+
// if password is missing, fetch it from Keychain via `/usr/bin/security`)
|
|
7
|
+
// Linux/others: env vars only
|
|
8
|
+
//
|
|
9
|
+
// Output precedence in proxyInfoToArgs():
|
|
10
|
+
// 1) pacUrl → ["--proxy-pac-url=<url>", ...bypass]
|
|
11
|
+
// 2) manual proxies → ["--proxy-server=...", ...bypass] (embeds creds if provided/available)
|
|
12
|
+
// 3) autoDetect → ["--proxy-auto-detect", ...bypass]
|
|
13
|
+
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { spawnSync } from 'child_process';
|
|
18
|
+
|
|
19
|
+
export interface ProxyInfo {
|
|
20
|
+
// host:port OR user:pass@host:port (no scheme)
|
|
21
|
+
http?: string;
|
|
22
|
+
https?: string;
|
|
23
|
+
socks?: string;
|
|
24
|
+
// PAC + autodetect
|
|
25
|
+
pacUrl?: string;
|
|
26
|
+
autoDetect?: boolean;
|
|
27
|
+
// optional bypass list (semicolon-separated)
|
|
28
|
+
bypassList?: string;
|
|
29
|
+
// optional global credentials to embed (URL-encoded)
|
|
30
|
+
username?: string;
|
|
31
|
+
password?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ProxySettings {
|
|
35
|
+
server: string;
|
|
36
|
+
username?: string;
|
|
37
|
+
password?: string;
|
|
38
|
+
bypass?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// New discriminated union that the launcher can switch on
|
|
42
|
+
export type ProxyResolution =
|
|
43
|
+
| { kind: 'manual'; settings: ProxySettings } // Playwright proxy option
|
|
44
|
+
| { kind: 'pac'; pacUrl: string; bypass?: string } // Use --proxy-pac-url
|
|
45
|
+
| { kind: 'none' };
|
|
46
|
+
|
|
47
|
+
/* ============================ helpers ============================ */
|
|
48
|
+
|
|
49
|
+
function stripScheme(u: string): string {
|
|
50
|
+
return u.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '');
|
|
51
|
+
}
|
|
52
|
+
function semiJoin(arr?: string[]): string | undefined {
|
|
53
|
+
if (!arr) return undefined;
|
|
54
|
+
const cleaned = arr.map(s => s.trim()).filter(Boolean);
|
|
55
|
+
return cleaned.length ? cleaned.join(';') : undefined;
|
|
56
|
+
}
|
|
57
|
+
function readCredsFromEnv(): { username?: string; password?: string } {
|
|
58
|
+
const username = process.env.PROXY_USERNAME || process.env.HTTP_PROXY_USERNAME || undefined;
|
|
59
|
+
const password = process.env.PROXY_PASSWORD || process.env.HTTP_PROXY_PASSWORD || undefined;
|
|
60
|
+
return { username, password };
|
|
61
|
+
}
|
|
62
|
+
function hasUserinfo(v: string | undefined): boolean {
|
|
63
|
+
return !!(v && /@/.test(v.split('/')[0]));
|
|
64
|
+
}
|
|
65
|
+
function pick(map: Record<string, string>, keys: string[]): string | undefined {
|
|
66
|
+
for (const k of keys) {
|
|
67
|
+
const v = map[k];
|
|
68
|
+
if (v && String(v).trim().length) return String(v).trim();
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
function extractHost(valueHostPort: string): string {
|
|
73
|
+
// valueHostPort may be "user:pass@host:port" or "host:port"
|
|
74
|
+
const noUser = valueHostPort.includes('@') ? valueHostPort.split('@', 2)[1] : valueHostPort;
|
|
75
|
+
return noUser.split(':', 2)[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ============================ env (macOS + Linux) ============================ */
|
|
79
|
+
|
|
80
|
+
function parseEnvProxyCommon(): ProxyInfo | null {
|
|
81
|
+
const http = process.env.HTTP_PROXY || process.env.http_proxy || '';
|
|
82
|
+
const https = process.env.HTTPS_PROXY || process.env.https_proxy || '';
|
|
83
|
+
const socks = process.env.ALL_PROXY || process.env.all_proxy || '';
|
|
84
|
+
const noProxy = process.env.NO_PROXY || process.env.no_proxy || '';
|
|
85
|
+
|
|
86
|
+
const info: ProxyInfo = {};
|
|
87
|
+
if (http) info.http = stripScheme(http);
|
|
88
|
+
if (https) info.https = stripScheme(https);
|
|
89
|
+
if (socks) info.socks = stripScheme(socks);
|
|
90
|
+
if (noProxy) info.bypassList = semiJoin(noProxy.split(/[,;]/));
|
|
91
|
+
|
|
92
|
+
const { username, password } = readCredsFromEnv();
|
|
93
|
+
if (username && password) { info.username = username; info.password = password; }
|
|
94
|
+
|
|
95
|
+
return (info.http || info.https || info.socks || info.bypassList) ? info : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ============================ macOS Keychain ============================ */
|
|
99
|
+
/**
|
|
100
|
+
* Try to read an Internet Password from macOS Keychain for a given host/account.
|
|
101
|
+
* We intentionally avoid passing any user-controlled strings; host/account come from scutil.
|
|
102
|
+
* Returns the password (raw) or undefined.
|
|
103
|
+
*/
|
|
104
|
+
export function keychainFindInternetPassword(host: string, account?: string): string | undefined {
|
|
105
|
+
console.log("Attempting to find internet proxy password in macOS keychain...");
|
|
106
|
+
|
|
107
|
+
// Only attempt on macOS, in an interactive session, or when explicitly allowed.
|
|
108
|
+
if (process.platform !== 'darwin') return undefined;
|
|
109
|
+
const allow = process.stdin.isTTY || process.env.ENABLE_KEYCHAIN_LOOKUP === '1';
|
|
110
|
+
if (!allow) return undefined;
|
|
111
|
+
|
|
112
|
+
const SECURITY_BIN = '/usr/bin/security';
|
|
113
|
+
const OUTPUT_LIMIT = 64 * 1024; // 64 KiB
|
|
114
|
+
|
|
115
|
+
// Verify absolute binary and realpath
|
|
116
|
+
try {
|
|
117
|
+
if (!fs.existsSync(SECURITY_BIN)) return undefined;
|
|
118
|
+
const real = fs.realpathSync(SECURITY_BIN);
|
|
119
|
+
if (real !== SECURITY_BIN) return undefined;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Minimal sanitized env (avoid proxy/env influence)
|
|
125
|
+
const env = {
|
|
126
|
+
PATH: '/usr/bin:/bin',
|
|
127
|
+
http_proxy: '', https_proxy: '', all_proxy: '', no_proxy: '',
|
|
128
|
+
HTTP_PROXY: '', HTTPS_PROXY: '', ALL_PROXY: '', NO_PROXY: '',
|
|
129
|
+
NODE_OPTIONS: '', NODE_PATH: '', DYLD_LIBRARY_PATH: '', LD_LIBRARY_PATH: '',
|
|
130
|
+
} as NodeJS.ProcessEnv;
|
|
131
|
+
|
|
132
|
+
const baseArgs = ['find-internet-password', '-s', host, '-w'];
|
|
133
|
+
const args = account ? [...baseArgs, '-a', account] : baseArgs;
|
|
134
|
+
|
|
135
|
+
// No timeout: allow user to respond to Keychain prompt
|
|
136
|
+
const res = spawnSync(SECURITY_BIN, args, {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
shell: false,
|
|
140
|
+
env,
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (!res.error && res.status === 0 && res.stdout) {
|
|
145
|
+
const out = res.stdout.slice(0, OUTPUT_LIMIT).replace(/\r?\n$/, '');
|
|
146
|
+
return out || undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Retry without account if first try used one
|
|
150
|
+
if (account) {
|
|
151
|
+
const retry = spawnSync(SECURITY_BIN, baseArgs, {
|
|
152
|
+
encoding: 'utf8',
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
shell: false,
|
|
155
|
+
env,
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
157
|
+
});
|
|
158
|
+
if (!retry.error && retry.status === 0 && retry.stdout) {
|
|
159
|
+
const out = retry.stdout.slice(0, OUTPUT_LIMIT).replace(/\r?\n$/, '');
|
|
160
|
+
return out || undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Common Keychain errors you may see in retry.stderr:
|
|
165
|
+
// - "User interaction is not allowed." (Keychain locked / non-interactive / no UI permission)
|
|
166
|
+
// - "The specified item could not be found in the keychain."
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ============================ macOS fallback (scutil) ============================ */
|
|
171
|
+
|
|
172
|
+
function parseMacScutil(): ProxyInfo | null {
|
|
173
|
+
const out = spawnSync('/usr/sbin/scutil', ['--proxy'], {
|
|
174
|
+
encoding: 'utf8',
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
shell: false,
|
|
177
|
+
});
|
|
178
|
+
if (out.error || !out.stdout) return null;
|
|
179
|
+
|
|
180
|
+
const map: Record<string, string> = {};
|
|
181
|
+
for (const line of out.stdout.split(/\r?\n/)) {
|
|
182
|
+
const m = line.match(/^\s*([A-Za-z0-9]+)\s*:\s*(.+?)\s*$/);
|
|
183
|
+
if (m) map[m[1]] = m[2];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const info: ProxyInfo = {};
|
|
187
|
+
|
|
188
|
+
// PAC + autodetect
|
|
189
|
+
if (map['ProxyAutoConfigEnable'] === '1' && map['ProxyAutoConfigURLString']) {
|
|
190
|
+
info.pacUrl = map['ProxyAutoConfigURLString'].trim();
|
|
191
|
+
}
|
|
192
|
+
if (map['ProxyAutoDiscoveryEnable'] === '1') info.autoDetect = true;
|
|
193
|
+
|
|
194
|
+
// Collect per-protocol creds (Apple keys vary by macOS version)
|
|
195
|
+
const httpUser = pick(map, ['HTTPProxyUsername', 'HTTPUser', 'HTTPUsername']);
|
|
196
|
+
let httpPass = pick(map, ['HTTPProxyPassword', 'HTTPPassword']);
|
|
197
|
+
const httpsUser = pick(map, ['HTTPSProxyUsername', 'HTTPSUser', 'HTTPSUsername']);
|
|
198
|
+
let httpsPass = pick(map, ['HTTPSProxyPassword', 'HTTPSPassword']);
|
|
199
|
+
const socksUser = pick(map, ['SOCKSProxyUsername', 'SOCKSUser', 'SOCKSUsername']);
|
|
200
|
+
let socksPass = pick(map, ['SOCKSProxyPassword', 'SOCKSPassword']);
|
|
201
|
+
|
|
202
|
+
// Manual proxies (always set host:port only; never include creds)
|
|
203
|
+
if (map['HTTPEnable'] === '1' && map['HTTPProxy'] && map['HTTPPort']) {
|
|
204
|
+
const hostPort = `${map['HTTPProxy']}:${map['HTTPPort']}`;
|
|
205
|
+
info.http = hostPort;
|
|
206
|
+
// If macOS has username but no password, try Keychain for this host/account
|
|
207
|
+
if (httpUser && !httpPass) httpPass = keychainFindInternetPassword(extractHost(hostPort), httpUser);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (map['HTTPSEnable'] === '1' && map['HTTPSProxy'] && map['HTTPSPort']) {
|
|
211
|
+
const hostPort = `${map['HTTPSProxy']}:${map['HTTPSPort']}`;
|
|
212
|
+
info.https = hostPort;
|
|
213
|
+
if (httpsUser && !httpsPass) httpsPass = keychainFindInternetPassword(extractHost(hostPort), httpsUser);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (map['SOCKSEnable'] === '1' && map['SOCKSProxy'] && map['SOCKSPort']) {
|
|
217
|
+
const hostPort = `${map['SOCKSProxy']}:${map['SOCKSPort']}`;
|
|
218
|
+
info.socks = hostPort;
|
|
219
|
+
if (socksUser && !socksPass) socksPass = keychainFindInternetPassword(extractHost(hostPort), socksUser);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Choose one set of creds to expose globally: prefer HTTP, else HTTPS, else SOCKS
|
|
223
|
+
// (Do not overwrite if env already provided username/password.)
|
|
224
|
+
if (!info.username && !info.password) {
|
|
225
|
+
if (httpUser && httpPass) {
|
|
226
|
+
info.username = httpUser;
|
|
227
|
+
info.password = httpPass;
|
|
228
|
+
} else if (httpsUser && httpsPass) {
|
|
229
|
+
info.username = httpsUser;
|
|
230
|
+
info.password = httpsPass;
|
|
231
|
+
} else if (socksUser && socksPass) {
|
|
232
|
+
info.username = socksUser;
|
|
233
|
+
info.password = socksPass;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Bypass list
|
|
238
|
+
let exceptions: string[] = [];
|
|
239
|
+
if (map['ExceptionsList']) {
|
|
240
|
+
const quoted = [...map['ExceptionsList'].matchAll(/"([^"]+)"/g)].map(m => m[1]);
|
|
241
|
+
exceptions = quoted.length
|
|
242
|
+
? quoted
|
|
243
|
+
: map['ExceptionsList'].replace(/[<>{}()]/g, ' ')
|
|
244
|
+
.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
|
245
|
+
}
|
|
246
|
+
if (map['ExcludeSimpleHostnames'] === '1' && !exceptions.includes('<local>')) exceptions.unshift('<local>');
|
|
247
|
+
const bypassList = semiJoin(exceptions);
|
|
248
|
+
if (bypassList) info.bypassList = bypassList;
|
|
249
|
+
|
|
250
|
+
// If scutil did not provide creds anywhere, still allow env creds as global fallback
|
|
251
|
+
if ((!info.username || !info.password)) {
|
|
252
|
+
const envCreds = readCredsFromEnv();
|
|
253
|
+
if (envCreds.username && envCreds.password) {
|
|
254
|
+
info.username = info.username || envCreds.username;
|
|
255
|
+
info.password = info.password || envCreds.password;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (info.pacUrl || info.http || info.https || info.socks || info.autoDetect || info.bypassList) ? info : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ============================ Windows (Registry only) ============================ */
|
|
263
|
+
|
|
264
|
+
function regExeCandidates(): string[] {
|
|
265
|
+
const root = process.env.SystemRoot || 'C:\\Windows';
|
|
266
|
+
const sys32 = path.join(root, 'System32', 'reg.exe');
|
|
267
|
+
const syswow64 = path.join(root, 'SysWOW64', 'reg.exe');
|
|
268
|
+
const sysnative = path.join(root, 'Sysnative', 'reg.exe'); // for 32-bit node on 64-bit OS
|
|
269
|
+
const set = new Set([sysnative, sys32, syswow64]);
|
|
270
|
+
return [...set].filter(p => p && fs.existsSync(p));
|
|
271
|
+
}
|
|
272
|
+
function execReg(args: string[]): string | null {
|
|
273
|
+
for (const exe of regExeCandidates()) {
|
|
274
|
+
const out = spawnSync(exe, args, {
|
|
275
|
+
encoding: 'utf8',
|
|
276
|
+
windowsHide: true,
|
|
277
|
+
shell: false,
|
|
278
|
+
});
|
|
279
|
+
if (!out.error && out.stdout) return out.stdout;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
type WinVals = {
|
|
285
|
+
ProxyEnable?: string;
|
|
286
|
+
ProxyServer?: string;
|
|
287
|
+
ProxyOverride?: string;
|
|
288
|
+
AutoConfigURL?: string;
|
|
289
|
+
AutoDetect?: string;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function readRegVals(hiveKey: string): WinVals | null {
|
|
293
|
+
const stdout = execReg(['query', hiveKey]);
|
|
294
|
+
if (!stdout) return null;
|
|
295
|
+
|
|
296
|
+
const take = (name: string) => {
|
|
297
|
+
const m = stdout.match(new RegExp(`\\s${name}\\s+REG_\\w+\\s+(.+)$`, 'mi'));
|
|
298
|
+
return m ? m[1].trim() : '';
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
ProxyEnable: take('ProxyEnable'),
|
|
303
|
+
ProxyServer: take('ProxyServer'),
|
|
304
|
+
ProxyOverride: take('ProxyOverride'),
|
|
305
|
+
AutoConfigURL: take('AutoConfigURL'),
|
|
306
|
+
AutoDetect: take('AutoDetect'),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parseWindowsRegistry(): ProxyInfo | null {
|
|
311
|
+
const HKCU = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
|
|
312
|
+
const HKLM = 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
|
|
313
|
+
|
|
314
|
+
const cu = readRegVals(HKCU);
|
|
315
|
+
const lm = readRegVals(HKLM);
|
|
316
|
+
const v = cu || lm;
|
|
317
|
+
if (!v) return null;
|
|
318
|
+
|
|
319
|
+
const info: ProxyInfo = {};
|
|
320
|
+
const enabledManual = !!v.ProxyEnable && v.ProxyEnable !== '0' && v.ProxyEnable.toLowerCase() !== '0x0';
|
|
321
|
+
const enabledAuto = !!v.AutoDetect && v.AutoDetect !== '0' && v.AutoDetect.toLowerCase() !== '0x0';
|
|
322
|
+
const anyEnabled = enabledManual || enabledAuto || !!v.AutoConfigURL;
|
|
323
|
+
|
|
324
|
+
// PAC + autodetect (only when something is actually enabled)
|
|
325
|
+
if (v.AutoConfigURL) info.pacUrl = v.AutoConfigURL.trim(); // PAC stands on its own
|
|
326
|
+
if (enabledAuto) info.autoDetect = true; // autodetect still gated
|
|
327
|
+
|
|
328
|
+
// Manual proxies
|
|
329
|
+
if (enabledManual && v.ProxyServer) {
|
|
330
|
+
const s = v.ProxyServer.trim();
|
|
331
|
+
if (s.includes('=')) {
|
|
332
|
+
for (const part of s.split(';')) {
|
|
333
|
+
const [proto, addr] = part.split('=');
|
|
334
|
+
if (!proto || !addr) continue;
|
|
335
|
+
const p = proto.trim().toLowerCase();
|
|
336
|
+
const a = stripScheme(addr.trim());
|
|
337
|
+
if (p === 'http') info.http = a;
|
|
338
|
+
else if (p === 'https') info.https = a;
|
|
339
|
+
else if (p === 'socks' || p === 'socks5') info.socks = a;
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
const a = stripScheme(s);
|
|
343
|
+
info.http = a;
|
|
344
|
+
info.https = a;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Bypass list
|
|
349
|
+
if (anyEnabled && v.ProxyOverride) info.bypassList = semiJoin(v.ProxyOverride.split(/[,;]/));
|
|
350
|
+
|
|
351
|
+
// Env creds as global fallback (Windows does not store proxy creds in ProxyServer)
|
|
352
|
+
const { username, password } = readCredsFromEnv();
|
|
353
|
+
if (username && password) { info.username = username; info.password = password; }
|
|
354
|
+
|
|
355
|
+
return (info.pacUrl || info.http || info.https || info.socks || info.autoDetect || info.bypassList) ? info : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ============================ Public API ============================ */
|
|
359
|
+
|
|
360
|
+
export function getProxyInfo(): ProxyInfo | null {
|
|
361
|
+
const plat = os.platform();
|
|
362
|
+
if (plat === 'win32') return parseEnvProxyCommon() || parseWindowsRegistry();
|
|
363
|
+
if (plat === 'darwin') return parseEnvProxyCommon() || parseMacScutil();
|
|
364
|
+
return parseEnvProxyCommon(); // Linux/others
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function proxyInfoToResolution(info: ProxyInfo | null): ProxyResolution {
|
|
368
|
+
if (!info) return { kind: 'none' };
|
|
369
|
+
|
|
370
|
+
// Prefer manual proxies first (these work with Playwright's proxy option)
|
|
371
|
+
if (info.http) {
|
|
372
|
+
return { kind: 'manual', settings: {
|
|
373
|
+
server: `http://${info.http}`,
|
|
374
|
+
username: info.username,
|
|
375
|
+
password: info.password,
|
|
376
|
+
bypass: info.bypassList,
|
|
377
|
+
}};
|
|
378
|
+
}
|
|
379
|
+
if (info.https) {
|
|
380
|
+
return { kind: 'manual', settings: {
|
|
381
|
+
server: `http://${info.https}`,
|
|
382
|
+
username: info.username,
|
|
383
|
+
password: info.password,
|
|
384
|
+
bypass: info.bypassList,
|
|
385
|
+
}};
|
|
386
|
+
}
|
|
387
|
+
if (info.socks) {
|
|
388
|
+
return { kind: 'manual', settings: {
|
|
389
|
+
server: `socks5://${info.socks}`,
|
|
390
|
+
username: info.username,
|
|
391
|
+
password: info.password,
|
|
392
|
+
bypass: info.bypassList,
|
|
393
|
+
}};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// PAC → handle via Chromium args; do NOT try proxy.server = 'pac+...'
|
|
397
|
+
if (info.pacUrl) {
|
|
398
|
+
// Minor hardening: prefer 127.0.0.1 over localhost for loopback PAC
|
|
399
|
+
const pacUrl = info.pacUrl.replace('://localhost', '://127.0.0.1');
|
|
400
|
+
return { kind: 'pac', pacUrl, bypass: info.bypassList };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Auto-detect not supported directly
|
|
404
|
+
return { kind: 'none' };
|
|
405
|
+
}
|
|
@@ -22,7 +22,7 @@ export const takeScreenshotForHTMLElements = async (
|
|
|
22
22
|
for (const violation of violations) {
|
|
23
23
|
if (screenshotCount >= maxScreenshots) {
|
|
24
24
|
/*
|
|
25
|
-
|
|
25
|
+
consoleLogger.warn(
|
|
26
26
|
`Skipping screenshots for ${violation.id} as maxScreenshots (${maxScreenshots}) exceeded. You can increase it by specifying a higher value when calling takeScreenshotForHTMLElements.`,
|
|
27
27
|
);
|
|
28
28
|
*/
|
|
@@ -34,7 +34,7 @@ export const takeScreenshotForHTMLElements = async (
|
|
|
34
34
|
|
|
35
35
|
// Check if rule ID is 'oobee-grading-text-contents' and skip screenshot logic
|
|
36
36
|
if (rule === 'oobee-grading-text-contents') {
|
|
37
|
-
//
|
|
37
|
+
// consoleLogger.info('Skipping screenshot for rule oobee-grading-text-contents');
|
|
38
38
|
newViolations.push(violation); // Make sure it gets added
|
|
39
39
|
continue;
|
|
40
40
|
}
|
|
@@ -59,13 +59,13 @@ export const takeScreenshotForHTMLElements = async (
|
|
|
59
59
|
nodeWithScreenshotPath.screenshotPath = screenshotPath;
|
|
60
60
|
screenshotCount++;
|
|
61
61
|
} else {
|
|
62
|
-
//
|
|
62
|
+
// consoleLogger.info(`Element at ${currLocator} is not visible`);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
break; // Stop looping after finding the first visible locator
|
|
66
66
|
}
|
|
67
67
|
} catch (e) {
|
|
68
|
-
//
|
|
68
|
+
// consoleLogger.info(`Unable to take element screenshot at ${selector}`);
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
newViolationNodes.push(nodeWithScreenshotPath);
|