jourilo 1.2.0 → 1.2.1
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 +13 -13
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/dist/jourilo.mjs +624 -129
- package/plugin/skills/jourilo/references/cli.md +33 -13
- package/schemas/run.schema.json +4 -0
package/plugin/dist/jourilo.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Jourilo 1.2.
|
|
2
|
+
// Jourilo 1.2.1 - generated by scripts/build.mjs. Do not edit; edit src/ instead.
|
|
3
3
|
import { createRequire as __jouriloCreateRequire } from 'node:module';
|
|
4
4
|
const require = __jouriloCreateRequire(import.meta.url);
|
|
5
5
|
var __create = Object.create;
|
|
@@ -1096,23 +1096,23 @@ var require_axe = __commonJS({
|
|
|
1096
1096
|
}
|
|
1097
1097
|
function publish(promise) {
|
|
1098
1098
|
var subscribers = promise._subscribers;
|
|
1099
|
-
var
|
|
1099
|
+
var settled2 = promise._state;
|
|
1100
1100
|
if (subscribers.length === 0) {
|
|
1101
1101
|
return;
|
|
1102
1102
|
}
|
|
1103
1103
|
var child = void 0, callback = void 0, detail = promise._result;
|
|
1104
1104
|
for (var i2 = 0; i2 < subscribers.length; i2 += 3) {
|
|
1105
1105
|
child = subscribers[i2];
|
|
1106
|
-
callback = subscribers[i2 +
|
|
1106
|
+
callback = subscribers[i2 + settled2];
|
|
1107
1107
|
if (child) {
|
|
1108
|
-
invokeCallback(
|
|
1108
|
+
invokeCallback(settled2, child, callback, detail);
|
|
1109
1109
|
} else {
|
|
1110
1110
|
callback(detail);
|
|
1111
1111
|
}
|
|
1112
1112
|
}
|
|
1113
1113
|
promise._subscribers.length = 0;
|
|
1114
1114
|
}
|
|
1115
|
-
function invokeCallback(
|
|
1115
|
+
function invokeCallback(settled2, promise, callback, detail) {
|
|
1116
1116
|
var hasCallback = isFunction(callback), value = void 0, error = void 0, succeeded = true;
|
|
1117
1117
|
if (hasCallback) {
|
|
1118
1118
|
try {
|
|
@@ -1133,9 +1133,9 @@ var require_axe = __commonJS({
|
|
|
1133
1133
|
resolve10(promise, value);
|
|
1134
1134
|
} else if (succeeded === false) {
|
|
1135
1135
|
reject(promise, error);
|
|
1136
|
-
} else if (
|
|
1136
|
+
} else if (settled2 === FULFILLED) {
|
|
1137
1137
|
fulfill(promise, value);
|
|
1138
|
-
} else if (
|
|
1138
|
+
} else if (settled2 === REJECTED) {
|
|
1139
1139
|
reject(promise, value);
|
|
1140
1140
|
}
|
|
1141
1141
|
}
|
|
@@ -38823,7 +38823,7 @@ var require_axe = __commonJS({
|
|
|
38823
38823
|
init_errors();
|
|
38824
38824
|
|
|
38825
38825
|
// src/core/version.ts
|
|
38826
|
-
var VERSION = true ? "1.2.
|
|
38826
|
+
var VERSION = true ? "1.2.1" : "0.0.0-dev";
|
|
38827
38827
|
|
|
38828
38828
|
// src/cli/framework.ts
|
|
38829
38829
|
init_errors();
|
|
@@ -38877,6 +38877,26 @@ function num(ctx, key, fallback) {
|
|
|
38877
38877
|
throw new JouriloError("USAGE", `--${key} must be a non-negative number.`);
|
|
38878
38878
|
return n;
|
|
38879
38879
|
}
|
|
38880
|
+
function pagesOption(ctx, fallback) {
|
|
38881
|
+
const v = str(ctx, "pages");
|
|
38882
|
+
if (v === void 0) return fallback;
|
|
38883
|
+
const n = Number(v);
|
|
38884
|
+
if (v.trim() === "" || !Number.isInteger(n) || n < 1)
|
|
38885
|
+
throw new JouriloError("USAGE", `--pages must be a whole number of 1 or more (got "${v}").`);
|
|
38886
|
+
return n;
|
|
38887
|
+
}
|
|
38888
|
+
var MAX_DURATION_LIMIT_S = 86400;
|
|
38889
|
+
function maxDurationOption(ctx) {
|
|
38890
|
+
const v = str(ctx, "max-duration");
|
|
38891
|
+
if (v === void 0) return void 0;
|
|
38892
|
+
const n = Number(v);
|
|
38893
|
+
if (v.trim() === "" || !Number.isFinite(n) || n < 1 || n > MAX_DURATION_LIMIT_S)
|
|
38894
|
+
throw new JouriloError(
|
|
38895
|
+
"USAGE",
|
|
38896
|
+
`--max-duration must be from 1 to ${MAX_DURATION_LIMIT_S} seconds (got "${v}").`
|
|
38897
|
+
);
|
|
38898
|
+
return n;
|
|
38899
|
+
}
|
|
38880
38900
|
function list(ctx, key) {
|
|
38881
38901
|
const v = ctx.values[key];
|
|
38882
38902
|
if (Array.isArray(v))
|
|
@@ -38917,6 +38937,29 @@ function parseStateOptions(ctx) {
|
|
|
38917
38937
|
function flag(ctx, key) {
|
|
38918
38938
|
return ctx.values[key] === true;
|
|
38919
38939
|
}
|
|
38940
|
+
function denyLocalFrom(ctx) {
|
|
38941
|
+
if (flag(ctx, "deny-local")) return true;
|
|
38942
|
+
const raw = process.env.JOURILO_DENY_LOCAL ?? "";
|
|
38943
|
+
const v = raw.trim().toLowerCase();
|
|
38944
|
+
if (!v || ["0", "false", "no", "off"].includes(v)) return false;
|
|
38945
|
+
if (["1", "true", "yes", "on"].includes(v)) return true;
|
|
38946
|
+
if (!warnedDenyLocal) {
|
|
38947
|
+
warnedDenyLocal = true;
|
|
38948
|
+
ctx.log(
|
|
38949
|
+
`JOURILO_DENY_LOCAL=${raw} is not recognised (use 1, true, yes or on to enable, 0, false, no or off to disable); --deny-local is on.`
|
|
38950
|
+
);
|
|
38951
|
+
}
|
|
38952
|
+
return true;
|
|
38953
|
+
}
|
|
38954
|
+
var warnedDenyLocal = false;
|
|
38955
|
+
function refuseDenyLocalWebkit(denyLocal, browser) {
|
|
38956
|
+
if (denyLocal && browser === "webkit")
|
|
38957
|
+
throw new JouriloError(
|
|
38958
|
+
"UNSAFE_URL",
|
|
38959
|
+
"--deny-local cannot be combined with --browser webkit: WebKit sends HTTPS redirects outside the network guard, so a redirect to a local address could not be blocked.",
|
|
38960
|
+
"Use --browser chromium or firefox."
|
|
38961
|
+
);
|
|
38962
|
+
}
|
|
38920
38963
|
function requirePositional(ctx, index, name, usage2) {
|
|
38921
38964
|
const v = ctx.positionals[index];
|
|
38922
38965
|
if (!v) throw new JouriloError("USAGE", `Missing <${name}>.`, `Usage: ${usage2}`);
|
|
@@ -39073,7 +39116,7 @@ Re-run with --write to install.`
|
|
|
39073
39116
|
};
|
|
39074
39117
|
|
|
39075
39118
|
// src/cli/commands/audit.ts
|
|
39076
|
-
import { existsSync as existsSync12,
|
|
39119
|
+
import { existsSync as existsSync12, writeSync as writeSync2 } from "node:fs";
|
|
39077
39120
|
import { resolve as resolve6 } from "node:path";
|
|
39078
39121
|
|
|
39079
39122
|
// src/audit/engine.ts
|
|
@@ -39597,8 +39640,16 @@ function lexiconSource(concept, opts = {}) {
|
|
|
39597
39640
|
if (opts.exact) return `^\\s*(?:${alt})\\s*[.!\u2026]?\\s*$`;
|
|
39598
39641
|
return `(?<![\\p{L}\\p{N}])(?:${alt})${STEMS.has(concept) ? "" : "(?![\\p{L}\\p{N}])"}`;
|
|
39599
39642
|
}
|
|
39643
|
+
function turkishFold(text) {
|
|
39644
|
+
return text.replace(/İ/g, "i").replace(/I/g, "\u0131").toLowerCase();
|
|
39645
|
+
}
|
|
39646
|
+
var LexiconRegExp = class extends RegExp {
|
|
39647
|
+
test(text) {
|
|
39648
|
+
return super.test(text) || super.test(turkishFold(text));
|
|
39649
|
+
}
|
|
39650
|
+
};
|
|
39600
39651
|
function lexicon(concept, opts = {}) {
|
|
39601
|
-
return new
|
|
39652
|
+
return new LexiconRegExp(lexiconSource(concept, opts), "iu");
|
|
39602
39653
|
}
|
|
39603
39654
|
function lexiconSources() {
|
|
39604
39655
|
return Object.fromEntries(Object.keys(LEX).map((k) => [k, lexiconSource(k)]));
|
|
@@ -39613,11 +39664,12 @@ import net, { isIP as isIP2 } from "node:net";
|
|
|
39613
39664
|
|
|
39614
39665
|
// src/core/url-safety.ts
|
|
39615
39666
|
init_errors();
|
|
39667
|
+
import { execFile } from "node:child_process";
|
|
39616
39668
|
import { lookup } from "node:dns/promises";
|
|
39617
39669
|
import { isIP } from "node:net";
|
|
39618
39670
|
var LOCAL_SUFFIXES = [".localhost", ".test"];
|
|
39619
39671
|
function isLocalHostname(host) {
|
|
39620
|
-
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
39672
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
|
|
39621
39673
|
return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h) || h === "0.0.0.0" || LOCAL_SUFFIXES.some((s) => h.endsWith(s));
|
|
39622
39674
|
}
|
|
39623
39675
|
var METADATA_V4 = /* @__PURE__ */ new Set(["169.254.169.254", "169.254.170.2", "169.254.169.123", "100.100.100.200"]);
|
|
@@ -39685,7 +39737,7 @@ function classifyIp(ip) {
|
|
|
39685
39737
|
if (family === 6) return classifyV6(v);
|
|
39686
39738
|
return "reserved";
|
|
39687
39739
|
}
|
|
39688
|
-
function addressVerdict(ip, policy) {
|
|
39740
|
+
function addressVerdict(ip, policy, port) {
|
|
39689
39741
|
const cls = classifyIp(ip);
|
|
39690
39742
|
switch (cls) {
|
|
39691
39743
|
case "public":
|
|
@@ -39698,6 +39750,8 @@ function addressVerdict(ip, policy) {
|
|
|
39698
39750
|
case "reserved":
|
|
39699
39751
|
return { ok: false, reason: `${ip} is a reserved or special-purpose address.` };
|
|
39700
39752
|
case "loopback":
|
|
39753
|
+
if (policy.denyLocal && !(port !== void 0 && policy.allowLoopbackPorts?.includes(port) && ip === "127.0.0.1"))
|
|
39754
|
+
return { ok: false, reason: `${ip} is this machine (loopback) and --deny-local is set.` };
|
|
39701
39755
|
return policy.target === "local" || policy.allowPrivate ? { ok: true } : { ok: false, reason: `${ip} is this machine (loopback); a public target may not reach it.` };
|
|
39702
39756
|
default:
|
|
39703
39757
|
return policy.allowPrivate ? { ok: true } : {
|
|
@@ -39720,27 +39774,85 @@ function parseTargetUrl(raw) {
|
|
|
39720
39774
|
return url;
|
|
39721
39775
|
}
|
|
39722
39776
|
var systemResolver = (host) => lookup(host, { all: true, verbatim: true });
|
|
39777
|
+
var RESOLVE_TIMEOUT_MS = 5e3;
|
|
39778
|
+
function withTimeout(work, ms, message) {
|
|
39779
|
+
let timer;
|
|
39780
|
+
const limit = new Promise((_, reject) => {
|
|
39781
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
39782
|
+
timer.unref();
|
|
39783
|
+
});
|
|
39784
|
+
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
|
|
39785
|
+
}
|
|
39786
|
+
var LookupFailed = class extends Error {
|
|
39787
|
+
};
|
|
39788
|
+
function resolveInChild(host, ms, exec = execFile) {
|
|
39789
|
+
const script = "require('node:dns').lookup(process.env.JOURILO_RESOLVE_HOST,{all:true,verbatim:true},(e,a)=>{if(e)process.exit(2);process.stdout.write(JSON.stringify(a),()=>process.exit(0))})";
|
|
39790
|
+
const env = { ...process.env, NODE_OPTIONS: "", JOURILO_RESOLVE_HOST: host };
|
|
39791
|
+
return new Promise((resolve10, reject) => {
|
|
39792
|
+
try {
|
|
39793
|
+
exec(process.execPath, ["-e", script], { timeout: ms, encoding: "utf8", env }, (err, stdout) => {
|
|
39794
|
+
if (err) {
|
|
39795
|
+
if (err.killed || err.code === 2) return reject(new LookupFailed(`DNS lookup failed for ${host}`));
|
|
39796
|
+
return resolve10([]);
|
|
39797
|
+
}
|
|
39798
|
+
try {
|
|
39799
|
+
resolve10(JSON.parse(stdout));
|
|
39800
|
+
} catch {
|
|
39801
|
+
resolve10([]);
|
|
39802
|
+
}
|
|
39803
|
+
});
|
|
39804
|
+
} catch {
|
|
39805
|
+
resolve10([]);
|
|
39806
|
+
}
|
|
39807
|
+
});
|
|
39808
|
+
}
|
|
39809
|
+
var DENY_LOCAL_HINT = "Unset --deny-local / JOURILO_DENY_LOCAL to audit a local development server, or pass a public URL.";
|
|
39723
39810
|
var PRIVATE_HINT = "Pass --allow-private if you are authorized to test this host.";
|
|
39724
39811
|
async function checkTarget(url, opts = {}) {
|
|
39812
|
+
return (await checkTargetDetailed(url, { ...opts, resolveLocalNames: false })).hostClass;
|
|
39813
|
+
}
|
|
39814
|
+
async function checkTargetDetailed(url, opts = {}) {
|
|
39725
39815
|
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
39726
39816
|
const literal = isIP(host) !== 0;
|
|
39817
|
+
if (opts.denyLocal && (isLocalHostname(host) || literal && classifyIp(host) === "loopback"))
|
|
39818
|
+
throw new JouriloError(
|
|
39819
|
+
"UNSAFE_URL",
|
|
39820
|
+
`${host} is a local address and --deny-local is set.`,
|
|
39821
|
+
DENY_LOCAL_HINT
|
|
39822
|
+
);
|
|
39727
39823
|
if (literal) {
|
|
39728
39824
|
const cls = classifyIp(host);
|
|
39729
39825
|
if (cls === "metadata" || cls === "reserved" && host !== "0.0.0.0")
|
|
39730
39826
|
throw new JouriloError("UNSAFE_URL", `${host} is a ${cls} address; Jourilo does not audit it.`);
|
|
39731
|
-
if (cls === "loopback") return "local";
|
|
39732
|
-
if (cls === "public") return "public";
|
|
39827
|
+
if (cls === "loopback") return { hostClass: "local" };
|
|
39828
|
+
if (cls === "public") return { hostClass: "public" };
|
|
39733
39829
|
if (!opts.allowPrivate)
|
|
39734
39830
|
throw new JouriloError("UNSAFE_URL", `${host} is a private network address.`, PRIVATE_HINT);
|
|
39735
|
-
return "private";
|
|
39831
|
+
return { hostClass: "private" };
|
|
39736
39832
|
}
|
|
39737
|
-
if (isLocalHostname(host))
|
|
39738
|
-
|
|
39833
|
+
if (isLocalHostname(host)) {
|
|
39834
|
+
const h = host.toLowerCase();
|
|
39835
|
+
const needsLookup = LOCAL_SUFFIXES.some((x) => x !== ".localhost" && h.endsWith(x));
|
|
39836
|
+
if (opts.resolveDns === false || opts.resolveLocalNames === false || !needsLookup)
|
|
39837
|
+
return { hostClass: "local" };
|
|
39838
|
+
try {
|
|
39839
|
+
const limit = opts.resolveTimeoutMs ?? RESOLVE_TIMEOUT_MS;
|
|
39840
|
+
await withTimeout(
|
|
39841
|
+
opts.resolver ? opts.resolver(host) : resolveInChild(host, limit),
|
|
39842
|
+
limit,
|
|
39843
|
+
`DNS lookup failed for ${host} (timed out)`
|
|
39844
|
+
);
|
|
39845
|
+
} catch {
|
|
39846
|
+
return { hostClass: "local", unresolved: true };
|
|
39847
|
+
}
|
|
39848
|
+
return { hostClass: "local" };
|
|
39849
|
+
}
|
|
39850
|
+
if (opts.resolveDns === false) return { hostClass: "public" };
|
|
39739
39851
|
let addrs;
|
|
39740
39852
|
try {
|
|
39741
39853
|
addrs = await (opts.resolver ?? systemResolver)(host);
|
|
39742
39854
|
} catch {
|
|
39743
|
-
return "public";
|
|
39855
|
+
return { hostClass: "public" };
|
|
39744
39856
|
}
|
|
39745
39857
|
const classes = addrs.map((a) => ({ address: a.address, cls: classifyIp(a.address) }));
|
|
39746
39858
|
const forbidden = classes.find((c) => c.cls === "metadata" || c.cls === "reserved");
|
|
@@ -39749,8 +39861,17 @@ async function checkTarget(url, opts = {}) {
|
|
|
39749
39861
|
"UNSAFE_URL",
|
|
39750
39862
|
`${host} resolves to ${forbidden.address}, a ${forbidden.cls} address; Jourilo does not audit it.`
|
|
39751
39863
|
);
|
|
39864
|
+
if (opts.denyLocal) {
|
|
39865
|
+
const loop = classes.find((c) => c.cls === "loopback");
|
|
39866
|
+
if (loop)
|
|
39867
|
+
throw new JouriloError(
|
|
39868
|
+
"UNSAFE_URL",
|
|
39869
|
+
`${host} resolves to ${loop.address}, a local address, and --deny-local is set.`,
|
|
39870
|
+
DENY_LOCAL_HINT
|
|
39871
|
+
);
|
|
39872
|
+
}
|
|
39752
39873
|
const nonPublic = classes.filter((c) => c.cls !== "public");
|
|
39753
|
-
if (!nonPublic.length) return "public";
|
|
39874
|
+
if (!nonPublic.length) return { hostClass: "public" };
|
|
39754
39875
|
if (!opts.allowPrivate) {
|
|
39755
39876
|
const mixed = nonPublic.length < classes.length;
|
|
39756
39877
|
throw new JouriloError(
|
|
@@ -39759,7 +39880,7 @@ async function checkTarget(url, opts = {}) {
|
|
|
39759
39880
|
PRIVATE_HINT
|
|
39760
39881
|
);
|
|
39761
39882
|
}
|
|
39762
|
-
return "private";
|
|
39883
|
+
return { hostClass: "private" };
|
|
39763
39884
|
}
|
|
39764
39885
|
function isCrawlable(candidate, origin) {
|
|
39765
39886
|
let u;
|
|
@@ -39782,6 +39903,10 @@ function normalizeForCrawl(raw) {
|
|
|
39782
39903
|
u.hash = "";
|
|
39783
39904
|
for (const k of [...u.searchParams.keys()])
|
|
39784
39905
|
if (/^utm_|^(fbclid|gclid|ref)$/i.test(k)) u.searchParams.delete(k);
|
|
39906
|
+
return u.toString();
|
|
39907
|
+
}
|
|
39908
|
+
function crawlKey(url) {
|
|
39909
|
+
const u = new URL(normalizeForCrawl(url));
|
|
39785
39910
|
if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.replace(/\/+$/, "");
|
|
39786
39911
|
return u.toString();
|
|
39787
39912
|
}
|
|
@@ -39795,12 +39920,16 @@ var systemResolve = (host) => new Promise(
|
|
|
39795
39920
|
);
|
|
39796
39921
|
async function resolveAllowed(host, port, policy, deps = {}) {
|
|
39797
39922
|
const h = host.replace(/^\[|\]$/g, "");
|
|
39798
|
-
const verdict = deps.verdict ?? ((ip) => addressVerdict(ip, policy));
|
|
39923
|
+
const verdict = deps.verdict ?? ((ip, port2) => addressVerdict(ip, policy, port2));
|
|
39799
39924
|
let addrs;
|
|
39800
39925
|
if (isIP2(h)) addrs = [{ address: h, family: isIP2(h) }];
|
|
39801
39926
|
else {
|
|
39802
39927
|
try {
|
|
39803
|
-
addrs = await (
|
|
39928
|
+
addrs = await withTimeout(
|
|
39929
|
+
(deps.resolve ?? systemResolve)(h),
|
|
39930
|
+
deps.resolveTimeoutMs ?? RESOLVE_TIMEOUT_MS,
|
|
39931
|
+
`DNS lookup failed for ${h} (timed out)`
|
|
39932
|
+
);
|
|
39804
39933
|
} catch (err) {
|
|
39805
39934
|
throw Object.assign(new Error(`DNS lookup failed for ${h}`), { cause: err });
|
|
39806
39935
|
}
|
|
@@ -39855,6 +39984,14 @@ function loopbackRedirectVerdict(location, from, policy) {
|
|
|
39855
39984
|
} catch {
|
|
39856
39985
|
return null;
|
|
39857
39986
|
}
|
|
39987
|
+
if (policy.denyLocal) {
|
|
39988
|
+
const h = to.hostname.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
|
|
39989
|
+
const local = isLocalHostname(h) || isIP2(h) !== 0 && classifyIp(h) === "loopback";
|
|
39990
|
+
const port = Number(to.port || (to.protocol === "https:" ? 443 : 80));
|
|
39991
|
+
const own = policy.allowLoopbackPorts?.includes(port) && h === "127.0.0.1";
|
|
39992
|
+
if (local && !own)
|
|
39993
|
+
return `redirect from ${from.host} to ${to.host} (a local address) refused: --deny-local is set.`;
|
|
39994
|
+
}
|
|
39858
39995
|
if (!isLoopbackName(to.hostname)) return null;
|
|
39859
39996
|
if (policy.target === "local" || policy.allowPrivate) return null;
|
|
39860
39997
|
return `redirect from ${from.host} to ${to.host} (this machine) refused.`;
|
|
@@ -39888,8 +40025,9 @@ async function startNetGuard(policy, deps = {}) {
|
|
|
39888
40025
|
};
|
|
39889
40026
|
const sockets = /* @__PURE__ */ new Set();
|
|
39890
40027
|
const track = (s) => {
|
|
40028
|
+
if (sockets.has(s)) return s;
|
|
39891
40029
|
sockets.add(s);
|
|
39892
|
-
s.
|
|
40030
|
+
s.once("close", () => sockets.delete(s));
|
|
39893
40031
|
return s;
|
|
39894
40032
|
};
|
|
39895
40033
|
const connectUpstream = (host, port2, onReady, onFail) => {
|
|
@@ -39966,7 +40104,13 @@ async function startNetGuard(policy, deps = {}) {
|
|
|
39966
40104
|
res.writeHead(up.statusCode ?? 502, up.statusMessage, out);
|
|
39967
40105
|
up.pipe(res);
|
|
39968
40106
|
});
|
|
39969
|
-
upstream.on("
|
|
40107
|
+
upstream.on("socket", (s) => track(s));
|
|
40108
|
+
res.on("close", () => {
|
|
40109
|
+
if (!res.writableFinished) upstream.destroy();
|
|
40110
|
+
});
|
|
40111
|
+
upstream.on("error", (err) => {
|
|
40112
|
+
if (!res.destroyed) fail(502, err.message);
|
|
40113
|
+
});
|
|
39970
40114
|
req.pipe(upstream);
|
|
39971
40115
|
},
|
|
39972
40116
|
(err) => fail(502, err.message)
|
|
@@ -40830,12 +40974,15 @@ var probe_default = `// Runs inside the audited page. Plain JavaScript (no impor
|
|
|
40830
40974
|
for (const fs of document.querySelectorAll('fieldset')) {
|
|
40831
40975
|
if (!isVisible(fs)) continue;
|
|
40832
40976
|
const legend = fs.querySelector(':scope > legend');
|
|
40833
|
-
if (
|
|
40834
|
-
|
|
40835
|
-
|
|
40836
|
-
|
|
40837
|
-
|
|
40838
|
-
|
|
40977
|
+
if (legend && text(legend)) continue;
|
|
40978
|
+
if ((fs.getAttribute('aria-label') || '').trim()) continue;
|
|
40979
|
+
const labelledBy = (fs.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean);
|
|
40980
|
+
if (labelledBy.some((id) => text(document.getElementById(id)))) continue;
|
|
40981
|
+
structure.fieldsets.push({
|
|
40982
|
+
selector: selectorOf(fs),
|
|
40983
|
+
rect: rectOf(fs),
|
|
40984
|
+
issue: legend ? 'empty legend' : 'no name',
|
|
40985
|
+
});
|
|
40839
40986
|
}
|
|
40840
40987
|
const radiosByName = new Map();
|
|
40841
40988
|
for (const r of document.querySelectorAll('input[type="radio"][name]')) {
|
|
@@ -41019,6 +41166,7 @@ var probe_default = `// Runs inside the audited page. Plain JavaScript (no impor
|
|
|
41019
41166
|
`;
|
|
41020
41167
|
|
|
41021
41168
|
// src/browser/keyboard.ts
|
|
41169
|
+
var DEV_OVERLAY = "nextjs-portal, vite-error-overlay, astro-dev-toolbar, #webpack-dev-server-client-overlay";
|
|
41022
41170
|
var FOCUS_INFO = `(el) => {
|
|
41023
41171
|
if (!el || el === document.body || el === document.documentElement) return null;
|
|
41024
41172
|
const sx = window.scrollX, sy = window.scrollY;
|
|
@@ -41078,6 +41226,17 @@ var FOCUS_INFO = `(el) => {
|
|
|
41078
41226
|
if (!hit || hit === el || el.contains(hit) || hit.contains(el)) { visiblePts++; continue; }
|
|
41079
41227
|
if (isCover(hit)) cover = cover || hit;
|
|
41080
41228
|
}
|
|
41229
|
+
const noBox = (e) => { const b = e.getBoundingClientRect(); return b.width <= 1 || b.height <= 1; };
|
|
41230
|
+
const rootHost = el.getRootNode && el.getRootNode().host;
|
|
41231
|
+
// A stop with text or an accessible name is content a person can reach, so a hidden one is a real focus-visible
|
|
41232
|
+
// failure (a clipped skip link that stays 1 x 1 while focused), not a stop without a box.
|
|
41233
|
+
const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean)
|
|
41234
|
+
.map((id) => { const n = document.getElementById(id); return n ? (n.textContent || '') : ''; }).join('').trim();
|
|
41235
|
+
const hasName = !!((el.textContent || '').trim() || (el.getAttribute('aria-label') || '').trim() || labelledBy ||
|
|
41236
|
+
(el.getAttribute('title') || '').trim() || (el.getAttribute('alt') || '').trim());
|
|
41237
|
+
const boxless = !!(el.matches(${JSON.stringify(DEV_OVERLAY)}) || (rootHost && rootHost.matches(${JSON.stringify(DEV_OVERLAY)})) ||
|
|
41238
|
+
(noBox(el) && !hasName && !Array.from(el.querySelectorAll('*')).some((d) => !noBox(d)) &&
|
|
41239
|
+
!(el.shadowRoot && Array.from(el.shadowRoot.querySelectorAll('*')).some((d) => !noBox(d)))));
|
|
41081
41240
|
const name = (el.getAttribute('aria-label') || el.innerText || el.value || el.getAttribute('title') || '').replace(/\\s+/g, ' ').trim().slice(0, 80);
|
|
41082
41241
|
// The element's identity as markup (like an axe node's html), so a state rule (.tool[aria-pressed]) can be found.
|
|
41083
41242
|
const attrs = ['id', 'class', 'role', 'aria-pressed', 'aria-expanded', 'aria-selected', 'aria-checked', 'aria-current', 'data-state']
|
|
@@ -41090,6 +41249,7 @@ var FOCUS_INFO = `(el) => {
|
|
|
41090
41249
|
obscured: !selfHidden && inViewport > 0 && visiblePts === 0 && !!cover,
|
|
41091
41250
|
coverSelector: cover ? sel(cover) : undefined,
|
|
41092
41251
|
inModal: !!el.closest('[aria-modal="true"], dialog[open]'),
|
|
41252
|
+
boxless,
|
|
41093
41253
|
};
|
|
41094
41254
|
}`;
|
|
41095
41255
|
var FOCUS_STYLES = `(el) => {
|
|
@@ -41448,9 +41608,16 @@ function watchContext(browser, ctx) {
|
|
|
41448
41608
|
}
|
|
41449
41609
|
function networkLimitations(r) {
|
|
41450
41610
|
const out = [];
|
|
41451
|
-
|
|
41611
|
+
const list2 = (bs) => `${bs.slice(0, 3).map((b) => `${b.host}:${b.port}`).join(", ")}${bs.length > 3 ? ", \u2026" : ""}`;
|
|
41612
|
+
const denied = r.blocked.filter((b) => b.reason.includes("--deny-local"));
|
|
41613
|
+
const other = r.blocked.filter((b) => !b.reason.includes("--deny-local"));
|
|
41614
|
+
if (denied.length)
|
|
41615
|
+
out.push(
|
|
41616
|
+
`The network guard refused ${denied.length} connection(s) to local addresses because --deny-local is set (${list2(denied)}); content that depends on them was not loaded.`
|
|
41617
|
+
);
|
|
41618
|
+
if (other.length)
|
|
41452
41619
|
out.push(
|
|
41453
|
-
`The network guard refused ${
|
|
41620
|
+
`The network guard refused ${other.length} connection(s) to non-public addresses (${list2(other)}); content that depends on them was not loaded.`
|
|
41454
41621
|
);
|
|
41455
41622
|
if (r.escaped.length)
|
|
41456
41623
|
out.push(
|
|
@@ -41635,7 +41802,11 @@ var STATE = `(controls, mode) => {
|
|
|
41635
41802
|
if (mode === 'same') {
|
|
41636
41803
|
const d = window.__jouriloDialog;
|
|
41637
41804
|
const ae = document.activeElement;
|
|
41638
|
-
|
|
41805
|
+
let deep = ae;
|
|
41806
|
+
while (deep && deep.shadowRoot && deep.shadowRoot.activeElement) deep = deep.shadowRoot.activeElement;
|
|
41807
|
+
// A focus guard (Base UI, Radix, React Aria, Headless UI) is an empty sentinel; focus parked there is not an escape.
|
|
41808
|
+
const onSentinel = !!deep && deep !== document.body && !(deep.textContent || '').trim() && (deep.getAttribute('aria-hidden') === 'true' || deep.getAttributeNames().some((n) => /focus-?guard|focus-?trap|data-.*sentinel/i.test(n)));
|
|
41809
|
+
return { dialogOpen: visible(d) && (d.tagName !== 'DIALOG' || d.open), unlabelled: false, focusInDialog: !!d && !!ae && d.contains(ae), onSentinel, focusables: 0, controlledVisible: null, activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null };
|
|
41639
41810
|
}
|
|
41640
41811
|
const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]')).filter(visible);
|
|
41641
41812
|
let d = dialogs[dialogs.length - 1] || null;
|
|
@@ -41665,6 +41836,20 @@ var STATE = `(controls, mode) => {
|
|
|
41665
41836
|
activeSelector: ae && ae !== document.body ? (ae.id ? '#' + ae.id : ae.tagName.toLowerCase()) : null,
|
|
41666
41837
|
};
|
|
41667
41838
|
}`;
|
|
41839
|
+
var WAIT_FOCUS_STABLE = `new Promise((resolve) => {
|
|
41840
|
+
const deepActive = () => { let a = document.activeElement; while (a && a.shadowRoot && a.shadowRoot.activeElement) a = a.shadowRoot.activeElement; return a; };
|
|
41841
|
+
const start = performance.now();
|
|
41842
|
+
let last = deepActive();
|
|
41843
|
+
let same = 0;
|
|
41844
|
+
const tick = () => {
|
|
41845
|
+
const now = deepActive();
|
|
41846
|
+
same = now === last ? same + 1 : 0;
|
|
41847
|
+
last = now;
|
|
41848
|
+
if (same >= 4 || performance.now() - start >= 250) resolve(true);
|
|
41849
|
+
else requestAnimationFrame(tick);
|
|
41850
|
+
};
|
|
41851
|
+
requestAnimationFrame(tick);
|
|
41852
|
+
})`;
|
|
41668
41853
|
var COMPOSITES = `(() => {
|
|
41669
41854
|
const ITEM = { tablist: 'tab', radiogroup: 'radio', listbox: 'option', menubar: 'menuitem, [role="menuitemcheckbox"], [role="menuitemradio"]', tree: 'treeitem' };
|
|
41670
41855
|
const visible = (e) => e.getClientRects().length > 0 && getComputedStyle(e).visibility !== 'hidden';
|
|
@@ -41779,8 +41964,9 @@ async function checkWidgets(page) {
|
|
|
41779
41964
|
);
|
|
41780
41965
|
} else {
|
|
41781
41966
|
for (let k = 0; k <= after.focusables; k++) await page.keyboard.press("Tab");
|
|
41967
|
+
await page.evaluate(WAIT_FOCUS_STABLE).catch(() => void 0);
|
|
41782
41968
|
const tabbed = await state(t.controls, "same");
|
|
41783
|
-
if (tabbed.dialogOpen && !tabbed.focusInDialog)
|
|
41969
|
+
if (tabbed.dialogOpen && !tabbed.focusInDialog && !tabbed.onSentinel)
|
|
41784
41970
|
issue(
|
|
41785
41971
|
"dialog-focus-escapes",
|
|
41786
41972
|
`Pressing Tab ${after.focusables + 1} time(s) moved focus out of the open dialog to ${tabbed.activeSelector ?? "the page"}.`
|
|
@@ -41913,11 +42099,54 @@ async function settle(page, timeoutMs) {
|
|
|
41913
42099
|
await page.waitForTimeout(300);
|
|
41914
42100
|
}
|
|
41915
42101
|
var AXE_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"];
|
|
42102
|
+
var HIDDEN_SKIP_RULES = /* @__PURE__ */ new Set(["color-contrast", "color-contrast-enhanced"]);
|
|
41916
42103
|
async function runAxe(page) {
|
|
41917
42104
|
await page.addScriptTag({ content: import_axe_core.default.source });
|
|
41918
42105
|
const raw = await page.evaluate(
|
|
41919
42106
|
`axe.run(document, { runOnly: { type: 'tag', values: ${JSON.stringify(AXE_TAGS)} }, resultTypes: ['violations', 'incomplete'], elementRef: false, rules: { 'target-size': { enabled: true } } })`
|
|
41920
42107
|
);
|
|
42108
|
+
const hiddenFlags = await page.evaluate(
|
|
42109
|
+
`(${String(
|
|
42110
|
+
(groups) => groups.map((target) => {
|
|
42111
|
+
try {
|
|
42112
|
+
let root = document;
|
|
42113
|
+
let el = null;
|
|
42114
|
+
const chain = [];
|
|
42115
|
+
for (let i = 0; i < target.length; i++) {
|
|
42116
|
+
const part = target[i];
|
|
42117
|
+
const hops = Array.isArray(part) ? part : [part];
|
|
42118
|
+
for (let h = 0; h < hops.length; h++) {
|
|
42119
|
+
el = root.querySelector(hops[h]);
|
|
42120
|
+
if (!el) return false;
|
|
42121
|
+
chain.push(el);
|
|
42122
|
+
if (h < hops.length - 1) {
|
|
42123
|
+
if (!el.shadowRoot) return false;
|
|
42124
|
+
root = el.shadowRoot;
|
|
42125
|
+
}
|
|
42126
|
+
}
|
|
42127
|
+
if (i < target.length - 1) {
|
|
42128
|
+
const doc = el?.contentDocument;
|
|
42129
|
+
if (!doc) return false;
|
|
42130
|
+
root = doc;
|
|
42131
|
+
}
|
|
42132
|
+
}
|
|
42133
|
+
return chain.some((c) => c.closest('[aria-hidden="true"]') !== null);
|
|
42134
|
+
} catch {
|
|
42135
|
+
return false;
|
|
42136
|
+
}
|
|
42137
|
+
})
|
|
42138
|
+
)})(${JSON.stringify(
|
|
42139
|
+
raw.incomplete.flatMap((r) => HIDDEN_SKIP_RULES.has(r.id) ? r.nodes.map((n) => n.target) : [])
|
|
42140
|
+
)})`
|
|
42141
|
+
).catch(() => []);
|
|
42142
|
+
{
|
|
42143
|
+
let q = 0;
|
|
42144
|
+
for (const r of raw.incomplete) {
|
|
42145
|
+
if (!HIDDEN_SKIP_RULES.has(r.id)) continue;
|
|
42146
|
+
r.nodes = r.nodes.filter(() => !hiddenFlags[q++]);
|
|
42147
|
+
}
|
|
42148
|
+
raw.incomplete = raw.incomplete.filter((r) => !HIDDEN_SKIP_RULES.has(r.id) || r.nodes.length > 0);
|
|
42149
|
+
}
|
|
41921
42150
|
const map = (r) => ({
|
|
41922
42151
|
id: r.id,
|
|
41923
42152
|
impact: r.impact ?? null,
|
|
@@ -41986,6 +42215,10 @@ async function applyThrottle(page) {
|
|
|
41986
42215
|
return false;
|
|
41987
42216
|
}
|
|
41988
42217
|
}
|
|
42218
|
+
function captureScreenshotFiles(url, vp) {
|
|
42219
|
+
const base2 = `${slug(new URL(url).pathname.replace(/^\/$/, "home"), 40)}-${shortHash(url).slice(0, 6)}-${vp.name}`;
|
|
42220
|
+
return { full: join6("screenshots", `${base2}.jpg`), fold: join6("screenshots", `${base2}-fold.jpg`) };
|
|
42221
|
+
}
|
|
41989
42222
|
async function capturePage(ctx, url, vp, opts) {
|
|
41990
42223
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
41991
42224
|
const page = await ctx.newPage();
|
|
@@ -42006,6 +42239,20 @@ async function capturePage(ctx, url, vp, opts) {
|
|
|
42006
42239
|
keyboard: null,
|
|
42007
42240
|
errors: []
|
|
42008
42241
|
};
|
|
42242
|
+
const redirects = [];
|
|
42243
|
+
page.on("response", (res) => {
|
|
42244
|
+
const status = res.status();
|
|
42245
|
+
if (status < 300 || status >= 400 || redirects.length >= 10) return;
|
|
42246
|
+
if (!res.request().isNavigationRequest() || res.request().frame() !== page.mainFrame()) return;
|
|
42247
|
+
const location = res.headers().location;
|
|
42248
|
+
if (!location) return;
|
|
42249
|
+
let to = location;
|
|
42250
|
+
try {
|
|
42251
|
+
to = new URL(location, res.url()).href;
|
|
42252
|
+
} catch {
|
|
42253
|
+
}
|
|
42254
|
+
redirects.push({ status, from: redactUrl(res.url()), to: redactUrl(to) });
|
|
42255
|
+
});
|
|
42009
42256
|
try {
|
|
42010
42257
|
if (opts.throttle && !await applyThrottle(page))
|
|
42011
42258
|
obs.errors.push("Throttling is only available in Chromium.");
|
|
@@ -42015,6 +42262,7 @@ async function capturePage(ctx, url, vp, opts) {
|
|
|
42015
42262
|
response = await page.goto(url, { waitUntil: "load", timeout: timeoutMs });
|
|
42016
42263
|
} catch (err) {
|
|
42017
42264
|
obs.errors.push(`navigation: ${err.message.split("\n")[0]}`);
|
|
42265
|
+
if (redirects.length) obs.redirects = redirects;
|
|
42018
42266
|
return obs;
|
|
42019
42267
|
}
|
|
42020
42268
|
const signal = response?.headers()[GUARD_UPSTREAM_ERROR_HEADER];
|
|
@@ -42045,11 +42293,9 @@ async function capturePage(ctx, url, vp, opts) {
|
|
|
42045
42293
|
});
|
|
42046
42294
|
}
|
|
42047
42295
|
if (opts.screenshots !== false) {
|
|
42048
|
-
const base2 = `${slug(new URL(url).pathname.replace(/^\/$/, "home"), 40)}-${shortHash(url).slice(0, 6)}-${vp.name}`;
|
|
42049
42296
|
const mask = [...DEFAULT_MASK, ...opts.mask ?? []].map((s) => page.locator(s));
|
|
42050
42297
|
const mode = opts.evidence ?? "full";
|
|
42051
|
-
const full =
|
|
42052
|
-
const fold = join6("screenshots", `${base2}-fold.jpg`);
|
|
42298
|
+
const { full, fold } = captureScreenshotFiles(url, vp);
|
|
42053
42299
|
try {
|
|
42054
42300
|
const height = Math.min(obs.probe?.scroll.height ?? vp.height, FULL_PAGE_MAX_HEIGHT[mode]);
|
|
42055
42301
|
if (mode === "minimal" && height <= vp.height) {
|
|
@@ -42181,12 +42427,21 @@ function pathOf2(url) {
|
|
|
42181
42427
|
return url;
|
|
42182
42428
|
}
|
|
42183
42429
|
}
|
|
42430
|
+
function fingerprintPath(url) {
|
|
42431
|
+
try {
|
|
42432
|
+
const u = new URL(url);
|
|
42433
|
+
const path2 = u.pathname === "/" ? "/" : u.pathname.replace(/\/+$/, "") || "/";
|
|
42434
|
+
return `${path2}${u.search}`;
|
|
42435
|
+
} catch {
|
|
42436
|
+
return url;
|
|
42437
|
+
}
|
|
42438
|
+
}
|
|
42184
42439
|
function makeFinding(input) {
|
|
42185
42440
|
const { key, occurrences, reproduction, ...rest } = input;
|
|
42186
42441
|
const finding2 = {
|
|
42187
42442
|
...rest,
|
|
42188
42443
|
// Viewport is deliberately not part of the fingerprint: the same defect seen at two widths is one finding.
|
|
42189
|
-
fingerprint: shortHash(input.ruleId,
|
|
42444
|
+
fingerprint: shortHash(input.ruleId, fingerprintPath(input.url), key ?? "", input.journey ?? ""),
|
|
42190
42445
|
occurrences: occurrences ?? 1,
|
|
42191
42446
|
reproduction: reproduction ?? defaultReproduction(input)
|
|
42192
42447
|
};
|
|
@@ -42202,6 +42457,11 @@ var SEVERITY_ORDER = ["critical", "serious", "moderate", "minor", "info"];
|
|
|
42202
42457
|
function severityRank(s) {
|
|
42203
42458
|
return SEVERITY_ORDER.indexOf(s);
|
|
42204
42459
|
}
|
|
42460
|
+
var KIND_STRENGTH = { "needs-review": 0, hypothesis: 1, defect: 2 };
|
|
42461
|
+
var CONFIDENCE_STRENGTH = { low: 0, medium: 1, high: 2 };
|
|
42462
|
+
function strength(f) {
|
|
42463
|
+
return KIND_STRENGTH[f.kind] * 10 + CONFIDENCE_STRENGTH[f.confidence];
|
|
42464
|
+
}
|
|
42205
42465
|
function mergeFindings(findings) {
|
|
42206
42466
|
const byFp = /* @__PURE__ */ new Map();
|
|
42207
42467
|
for (const f of findings) {
|
|
@@ -42214,6 +42474,13 @@ function mergeFindings(findings) {
|
|
|
42214
42474
|
viewports.delete("");
|
|
42215
42475
|
prev.viewport = [...viewports].join(", ");
|
|
42216
42476
|
if (severityRank(f.severity) < severityRank(prev.severity)) prev.severity = f.severity;
|
|
42477
|
+
if (strength(f) > strength(prev)) {
|
|
42478
|
+
prev.kind = f.kind;
|
|
42479
|
+
prev.confidence = f.confidence;
|
|
42480
|
+
prev.title = f.title;
|
|
42481
|
+
prev.description = f.description;
|
|
42482
|
+
prev.recommendation = f.recommendation;
|
|
42483
|
+
}
|
|
42217
42484
|
prev.occurrences = Math.max(prev.occurrences, f.occurrences);
|
|
42218
42485
|
for (const e of f.evidence) {
|
|
42219
42486
|
if (prev.evidence.length >= 12) break;
|
|
@@ -42748,6 +43015,20 @@ async function loadRobots(origin, policy, timeoutMs = 8e3) {
|
|
|
42748
43015
|
const text = await fetchText(`${origin}/robots.txt`, timeoutMs, policy);
|
|
42749
43016
|
return text === null ? null : parseRobots(text, USER_AGENT_TOKEN);
|
|
42750
43017
|
}
|
|
43018
|
+
function robotsOverrideLimitation(url) {
|
|
43019
|
+
return `robots.txt disallows ${url}; it was audited because it was requested explicitly.`;
|
|
43020
|
+
}
|
|
43021
|
+
async function explicitRobotsOverrides(urls, policy, timeoutMs = 8e3) {
|
|
43022
|
+
const out = [];
|
|
43023
|
+
const rules = /* @__PURE__ */ new Map();
|
|
43024
|
+
for (const raw of urls) {
|
|
43025
|
+
const u = new URL(raw);
|
|
43026
|
+
if (!rules.has(u.origin)) rules.set(u.origin, await loadRobots(u.origin, policy, timeoutMs));
|
|
43027
|
+
const r = rules.get(u.origin);
|
|
43028
|
+
if (r && !isAllowed(r, `${u.pathname}${u.search}`)) out.push(redactUrl(u.toString()));
|
|
43029
|
+
}
|
|
43030
|
+
return out;
|
|
43031
|
+
}
|
|
42751
43032
|
async function sitemapUrls(origin, robots, timeoutMs, policy) {
|
|
42752
43033
|
const local = policy.target === "local";
|
|
42753
43034
|
const rebase = (raw) => {
|
|
@@ -42783,27 +43064,34 @@ async function crawl(ctx, start, opts) {
|
|
|
42783
43064
|
const origin = new URL(start).origin;
|
|
42784
43065
|
const robots = opts.respectRobots ? await loadRobots(origin, opts.policy) : null;
|
|
42785
43066
|
const skipped = [];
|
|
43067
|
+
const robotsOverridden = [];
|
|
42786
43068
|
const seen = /* @__PURE__ */ new Map();
|
|
42787
43069
|
const queue = [];
|
|
42788
|
-
const push = (raw, depth) => {
|
|
43070
|
+
const push = (raw, depth, explicit = false) => {
|
|
42789
43071
|
let n;
|
|
43072
|
+
let key;
|
|
42790
43073
|
try {
|
|
42791
43074
|
n = normalizeForCrawl(raw);
|
|
43075
|
+
key = crawlKey(raw);
|
|
42792
43076
|
} catch {
|
|
42793
43077
|
return;
|
|
42794
43078
|
}
|
|
42795
|
-
if (seen.has(
|
|
43079
|
+
if (seen.has(key) || !isCrawlable(n, origin)) return;
|
|
42796
43080
|
const u = new URL(n);
|
|
42797
43081
|
if (robots && !isAllowed(robots, `${u.pathname}${u.search}`)) {
|
|
42798
|
-
|
|
42799
|
-
|
|
42800
|
-
|
|
43082
|
+
if (explicit) {
|
|
43083
|
+
robotsOverridden.push(redactUrl(n));
|
|
43084
|
+
} else {
|
|
43085
|
+
skipped.push({ url: redactUrl(n), reason: "robots.txt" });
|
|
43086
|
+
seen.set(key, { url: n, depth, blocked: true });
|
|
43087
|
+
return;
|
|
43088
|
+
}
|
|
42801
43089
|
}
|
|
42802
|
-
seen.set(n, depth);
|
|
43090
|
+
seen.set(key, { url: n, depth, blocked: false });
|
|
42803
43091
|
queue.push({ url: n, depth });
|
|
42804
43092
|
};
|
|
42805
|
-
push(start, 0);
|
|
42806
|
-
for (const extra of opts.include ?? []) push(new URL(extra, start).toString(), 0);
|
|
43093
|
+
push(start, 0, true);
|
|
43094
|
+
for (const extra of opts.include ?? []) push(new URL(extra, start).toString(), 0, true);
|
|
42807
43095
|
const fromSitemap = await sitemapUrls(origin, robots, opts.timeoutMs, opts.policy);
|
|
42808
43096
|
for (const u of fromSitemap.urls) push(u, 1);
|
|
42809
43097
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -42832,7 +43120,7 @@ async function crawl(ctx, start, opts) {
|
|
|
42832
43120
|
} finally {
|
|
42833
43121
|
await page.close().catch(() => void 0);
|
|
42834
43122
|
}
|
|
42835
|
-
const ranked = [...seen.
|
|
43123
|
+
const ranked = [...seen.values()].filter((e) => !e.blocked).map(({ url, depth }) => ({ url, depth, role: pageRole(url), score: rankUrl(url, depth) })).sort((a, b) => b.score - a.score);
|
|
42836
43124
|
const picked = [];
|
|
42837
43125
|
const roles = /* @__PURE__ */ new Set();
|
|
42838
43126
|
for (const p of ranked) {
|
|
@@ -42846,9 +43134,11 @@ async function crawl(ctx, start, opts) {
|
|
|
42846
43134
|
if (picked.length >= opts.maxPages) break;
|
|
42847
43135
|
if (!picked.includes(p)) picked.push(p);
|
|
42848
43136
|
}
|
|
43137
|
+
const auditedOverrides = robotsOverridden.filter((u) => picked.some((p) => redactUrl(p.url) === u));
|
|
42849
43138
|
return {
|
|
42850
43139
|
pages: picked,
|
|
42851
43140
|
skipped,
|
|
43141
|
+
robotsOverridden: auditedOverrides,
|
|
42852
43142
|
robots,
|
|
42853
43143
|
sitemapUrls: fromSitemap.urls.length,
|
|
42854
43144
|
sitemapRebasedFrom: fromSitemap.rebasedFrom
|
|
@@ -43120,6 +43410,9 @@ var AFTER = `([sel, beforeText]) => {
|
|
|
43120
43410
|
const focusOnProblem = !!ae && ae !== document.body && (fields.includes(ae) || /error|alert/i.test((ae.getAttribute('role') || '') + ' ' + (ae.className || '') + ' ' + (ae.id || '')));
|
|
43121
43411
|
return { errorish, freshCount: fresh.length, ariaInvalid, described, live, focusOnProblem, fieldCount: fields.length };
|
|
43122
43412
|
}`;
|
|
43413
|
+
function formScreenshotFile(url, selector, vp) {
|
|
43414
|
+
return toPosix(`screenshots/form-${shortHash(url, selector).slice(0, 8)}-${vp.name}.jpg`);
|
|
43415
|
+
}
|
|
43123
43416
|
async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
|
|
43124
43417
|
const page = await ctx.newPage();
|
|
43125
43418
|
const guard = await installRequestGuard(page, {
|
|
@@ -43166,7 +43459,7 @@ async function probeFormValidation(ctx, url, form, vp, policy, runDir) {
|
|
|
43166
43459
|
await page.waitForLoadState("networkidle", { timeout: 3e3 }).catch(() => void 0);
|
|
43167
43460
|
await page.waitForTimeout(600);
|
|
43168
43461
|
const intercepted = guard.since(submittedAt);
|
|
43169
|
-
const shotFile =
|
|
43462
|
+
const shotFile = formScreenshotFile(url, form.selector, vp);
|
|
43170
43463
|
await page.screenshot({
|
|
43171
43464
|
path: `${runDir}/${shotFile}`,
|
|
43172
43465
|
type: "jpeg",
|
|
@@ -43689,7 +43982,10 @@ async function runJourney(browser, spec, opts) {
|
|
|
43689
43982
|
const startUrl = new URL(interpolate(spec.start, opts.data), opts.base);
|
|
43690
43983
|
const allSteps = [{ action: "goto", url: startUrl.toString() }, ...spec.steps];
|
|
43691
43984
|
const shot = async (i) => {
|
|
43692
|
-
const file2 = join9(
|
|
43985
|
+
const file2 = join9(
|
|
43986
|
+
"screenshots",
|
|
43987
|
+
`journey-${spec.id}-${vp.name}${opts.attempt === void 0 ? "" : `-a${opts.attempt}`}-${String(i).padStart(2, "0")}.jpg`
|
|
43988
|
+
);
|
|
43693
43989
|
try {
|
|
43694
43990
|
await page.screenshot({
|
|
43695
43991
|
path: join9(opts.runDir, file2),
|
|
@@ -44080,7 +44376,8 @@ function keyboardFindings(obs) {
|
|
|
44080
44376
|
const out = [];
|
|
44081
44377
|
const vp = obs.viewport.name;
|
|
44082
44378
|
const shot = obs.screenshot ? { file: obs.screenshot } : {};
|
|
44083
|
-
const
|
|
44379
|
+
const boxed = kb.stops.filter((s) => !s.boxless);
|
|
44380
|
+
const invisible = boxed.filter((s) => s.indicatorChecked && s.indicatorChanges.length === 0);
|
|
44084
44381
|
if (invisible.length) {
|
|
44085
44382
|
out.push(
|
|
44086
44383
|
makeFinding({
|
|
@@ -44093,7 +44390,7 @@ function keyboardFindings(obs) {
|
|
|
44093
44390
|
provenance: "measured",
|
|
44094
44391
|
url: obs.url,
|
|
44095
44392
|
viewport: vp,
|
|
44096
|
-
description: `${invisible.length} of ${
|
|
44393
|
+
description: `${invisible.length} of ${boxed.length} elements reached with Tab look exactly the same focused and unfocused (outline, box-shadow, border, background, color, underline and the parent's styles were compared).`,
|
|
44097
44394
|
impact: "Keyboard users cannot see where they are on the page, so they cannot operate it reliably.",
|
|
44098
44395
|
recommendation: "Do not remove the focus outline without a replacement. Add a clearly visible :focus-visible style (e.g. a 2 px outline with 3:1 contrast) to the shared button/link/input components.",
|
|
44099
44396
|
standards: [wcag("2.4.7")],
|
|
@@ -44487,9 +44784,9 @@ function structureFindings(obs) {
|
|
|
44487
44784
|
provenance: "measured",
|
|
44488
44785
|
url: obs.url,
|
|
44489
44786
|
viewport: vp,
|
|
44490
|
-
description: `${st.fieldsets.length} fieldset(s) have no legend or an empty one, so the group of fields has no name.`,
|
|
44787
|
+
description: `${st.fieldsets.length} fieldset(s) have no legend, aria-label or aria-labelledby (or an empty one), so the group of fields has no name.`,
|
|
44491
44788
|
impact: 'Screen-reader users hear the fields without the question they answer (e.g. "Yes / No" without "Do you have a passport?").',
|
|
44492
|
-
recommendation: "Give every fieldset a first-child <legend> with the group question; remove fieldsets used only for styling.",
|
|
44789
|
+
recommendation: "Give every fieldset a first-child <legend> with the group question (or an aria-label / aria-labelledby that names it); remove fieldsets used only for styling.",
|
|
44493
44790
|
standards: [wcag("1.3.1"), wcag("3.3.2")],
|
|
44494
44791
|
evidence: ev(st.fieldsets, (i) => st.fieldsets[i].issue),
|
|
44495
44792
|
occurrences: st.fieldsets.length
|
|
@@ -44568,28 +44865,35 @@ function responsiveFindings(obs) {
|
|
|
44568
44865
|
if (p.overflow.docWidth > p.overflow.viewportWidth + 1) {
|
|
44569
44866
|
const narrow = vp.width <= 400;
|
|
44570
44867
|
const extra = p.overflow.docWidth - p.overflow.viewportWidth;
|
|
44868
|
+
const located = p.overflow.offenders.length > 0;
|
|
44571
44869
|
out.push(
|
|
44572
44870
|
makeFinding({
|
|
44573
44871
|
ruleId: "jourilo/horizontal-overflow",
|
|
44574
44872
|
title: `Page scrolls sideways at ${vp.width} px`,
|
|
44575
44873
|
category: "responsive",
|
|
44576
|
-
kind: "defect",
|
|
44874
|
+
kind: located ? "defect" : "needs-review",
|
|
44577
44875
|
severity: narrow ? "serious" : "moderate",
|
|
44578
|
-
confidence: "high",
|
|
44876
|
+
confidence: located ? "high" : "low",
|
|
44579
44877
|
provenance: "measured",
|
|
44580
44878
|
url: obs.url,
|
|
44581
44879
|
viewport: vp.name,
|
|
44582
|
-
description: `The document is ${p.overflow.docWidth} px wide in a ${p.overflow.viewportWidth} px viewport (${extra} px of horizontal scrolling).${
|
|
44880
|
+
description: `The document is ${p.overflow.docWidth} px wide in a ${p.overflow.viewportWidth} px viewport (${extra} px of horizontal scrolling).${located ? ` Widest culprit: <${p.overflow.offenders[0].tag}> ending at ${p.overflow.offenders[0].right} px.` : " The measurement is real, but no element could be located that sticks out; check the page by hand (for example scripted or transformed content)."}`,
|
|
44583
44881
|
impact: "Content is cut off or requires two-dimensional scrolling; on phones the page wobbles sideways while scrolling.",
|
|
44584
44882
|
recommendation: "Constrain the offending element (max-width: 100%, flex-wrap, min-width: 0 on flex children, overflow-wrap: anywhere for long strings, responsive tables in a scroll container).",
|
|
44585
44883
|
standards: vp.width <= 320 ? [wcag("1.4.10")] : [wcag("1.4.10"), nielsen(8)],
|
|
44586
|
-
evidence: p.overflow.offenders.map((o) => ({
|
|
44884
|
+
evidence: located ? p.overflow.offenders.map((o) => ({
|
|
44587
44885
|
type: "element",
|
|
44588
44886
|
selector: o.selector,
|
|
44589
44887
|
rect: o.rect,
|
|
44590
44888
|
data: { right: o.right, width: o.width, viewport: p.overflow.viewportWidth },
|
|
44591
44889
|
...shot
|
|
44592
|
-
}))
|
|
44890
|
+
})) : [
|
|
44891
|
+
{
|
|
44892
|
+
type: obs.screenshot ? "screenshot" : "metric",
|
|
44893
|
+
data: { docWidth: p.overflow.docWidth, viewport: p.overflow.viewportWidth },
|
|
44894
|
+
...shot
|
|
44895
|
+
}
|
|
44896
|
+
],
|
|
44593
44897
|
occurrences: Math.max(1, p.overflow.offenders.length)
|
|
44594
44898
|
})
|
|
44595
44899
|
);
|
|
@@ -44650,12 +44954,26 @@ function responsiveFindings(obs) {
|
|
|
44650
44954
|
function reliabilityFindings(obs) {
|
|
44651
44955
|
const out = [];
|
|
44652
44956
|
const vp = obs.viewport.name;
|
|
44653
|
-
const
|
|
44957
|
+
const rawNavError = obs.errors.find((e) => e.startsWith("navigation:"));
|
|
44958
|
+
const navError = rawNavError === void 0 ? void 0 : redactText(rawNavError);
|
|
44654
44959
|
if (navError) {
|
|
44960
|
+
const hops = obs.redirects ?? [];
|
|
44961
|
+
const froms = hops.map((h) => h.from);
|
|
44962
|
+
const loop = /ERR_TOO_MANY_REDIRECTS/i.test(navError) || new Set(froms).size < froms.length;
|
|
44963
|
+
const short = (u) => {
|
|
44964
|
+
try {
|
|
44965
|
+
const a = new URL(redactUrl(u));
|
|
44966
|
+
return a.origin === new URL(obs.url).origin ? `${a.pathname}${a.search}` : a.toString();
|
|
44967
|
+
} catch {
|
|
44968
|
+
return redactText(u);
|
|
44969
|
+
}
|
|
44970
|
+
};
|
|
44971
|
+
const chain = hops.slice(0, 5).map((h) => `${h.status} ${short(h.from)} \u2192 ${short(h.to)}`);
|
|
44972
|
+
const chainText = chain.join(", ") + (hops.length > chain.length ? `, and ${hops.length - chain.length} more` : "");
|
|
44655
44973
|
out.push(
|
|
44656
44974
|
makeFinding({
|
|
44657
44975
|
ruleId: "jourilo/page-unreachable",
|
|
44658
|
-
title: "Page failed to load",
|
|
44976
|
+
title: loop ? "Page redirects to itself in a loop" : "Page failed to load",
|
|
44659
44977
|
category: "reliability",
|
|
44660
44978
|
kind: "defect",
|
|
44661
44979
|
severity: "critical",
|
|
@@ -44663,11 +44981,14 @@ function reliabilityFindings(obs) {
|
|
|
44663
44981
|
provenance: "observed",
|
|
44664
44982
|
url: obs.url,
|
|
44665
44983
|
viewport: vp,
|
|
44666
|
-
description: navError,
|
|
44984
|
+
description: loop ? `The browser followed a redirect loop and gave up (${navError})${chainText ? `. Redirects seen: ${chainText}` : ""}.` : navError,
|
|
44667
44985
|
impact: "Visitors cannot use this page at all.",
|
|
44668
|
-
recommendation: "Check the server, DNS and redirects for this URL.",
|
|
44986
|
+
recommendation: loop ? "Fix the server's redirect rules or host configuration (HTTP/HTTPS, www and trailing-slash redirects, or a proxy or CDN redirect) so the URL stops redirecting back to itself." : "Check the server, DNS and redirects for this URL.",
|
|
44669
44987
|
standards: [],
|
|
44670
|
-
evidence: [
|
|
44988
|
+
evidence: [
|
|
44989
|
+
{ type: "network", snippet: navError },
|
|
44990
|
+
...chain.length ? [{ type: "network", snippet: chain.join("\n") }] : []
|
|
44991
|
+
]
|
|
44671
44992
|
})
|
|
44672
44993
|
);
|
|
44673
44994
|
return out;
|
|
@@ -44879,6 +45200,8 @@ var PAGE_RULES = {
|
|
|
44879
45200
|
};
|
|
44880
45201
|
var AuditStopped = class extends Error {
|
|
44881
45202
|
};
|
|
45203
|
+
var NameUnresolved = class extends Error {
|
|
45204
|
+
};
|
|
44882
45205
|
function untilStopped(p, signal) {
|
|
44883
45206
|
if (!signal) return p;
|
|
44884
45207
|
p.catch(() => void 0);
|
|
@@ -44889,6 +45212,43 @@ function untilStopped(p, signal) {
|
|
|
44889
45212
|
p.then(resolve10, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
44890
45213
|
});
|
|
44891
45214
|
}
|
|
45215
|
+
var STOP_GRACE_MS = 3e3;
|
|
45216
|
+
var ABANDONED = /* @__PURE__ */ Symbol("abandoned");
|
|
45217
|
+
async function withinGrace(work, signal, abandon) {
|
|
45218
|
+
if (!signal) return work;
|
|
45219
|
+
work.catch(() => void 0);
|
|
45220
|
+
let timer;
|
|
45221
|
+
let onAbort = () => void 0;
|
|
45222
|
+
const expired = new Promise((resolve10) => {
|
|
45223
|
+
onAbort = () => {
|
|
45224
|
+
timer = setTimeout(() => resolve10(ABANDONED), STOP_GRACE_MS);
|
|
45225
|
+
};
|
|
45226
|
+
if (signal.aborted) onAbort();
|
|
45227
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
45228
|
+
});
|
|
45229
|
+
try {
|
|
45230
|
+
const first = await Promise.race([work, expired]);
|
|
45231
|
+
if (first !== ABANDONED) return first;
|
|
45232
|
+
} finally {
|
|
45233
|
+
clearTimeout(timer);
|
|
45234
|
+
signal.removeEventListener("abort", onAbort);
|
|
45235
|
+
}
|
|
45236
|
+
await abandon().catch(() => void 0);
|
|
45237
|
+
throw new AuditStopped();
|
|
45238
|
+
}
|
|
45239
|
+
async function settled(work, ms) {
|
|
45240
|
+
let timer;
|
|
45241
|
+
await Promise.race([
|
|
45242
|
+
work.then(
|
|
45243
|
+
() => void 0,
|
|
45244
|
+
() => void 0
|
|
45245
|
+
),
|
|
45246
|
+
new Promise((r) => {
|
|
45247
|
+
timer = setTimeout(r, ms);
|
|
45248
|
+
})
|
|
45249
|
+
]);
|
|
45250
|
+
clearTimeout(timer);
|
|
45251
|
+
}
|
|
44892
45252
|
function stopLimitation(stoppedBy, urls, viewports, captured) {
|
|
44893
45253
|
const by = stoppedBy === "signal" ? "by a stop signal" : "when the --max-duration budget ran out";
|
|
44894
45254
|
if (!urls) return `The run was stopped early ${by}, before pages were discovered, so no page was audited.`;
|
|
@@ -44910,15 +45270,27 @@ function resolveRunId(ws, requested) {
|
|
|
44910
45270
|
`Invalid run id "${requested}".`,
|
|
44911
45271
|
'Use 1-64 letters, digits, "_" or "-", starting with a letter or digit.'
|
|
44912
45272
|
);
|
|
44913
|
-
|
|
45273
|
+
const dir = ws.runDir(requested);
|
|
45274
|
+
if (existsSync6(join10(dir, "run.json")))
|
|
44914
45275
|
throw new JouriloError("INVALID_INPUT", `Run ${requested} already exists.`, "Pick another --run-id.");
|
|
45276
|
+
if (existsSync6(dir))
|
|
45277
|
+
throw new JouriloError(
|
|
45278
|
+
"INVALID_INPUT",
|
|
45279
|
+
`The directory of run ${requested} already exists (${dir}) without a finished run, probably left by a run that did not finish.`,
|
|
45280
|
+
"Pick another --run-id, or remove that directory first."
|
|
45281
|
+
);
|
|
44915
45282
|
return requested;
|
|
44916
45283
|
}
|
|
44917
45284
|
async function runAudit(ws, opts) {
|
|
44918
45285
|
const id = resolveRunId(ws, opts.runId);
|
|
44919
45286
|
const dismissals = loadDismissals(ws);
|
|
44920
45287
|
const target = parseTargetUrl(opts.target);
|
|
44921
|
-
const
|
|
45288
|
+
const check = await checkTargetDetailed(target, {
|
|
45289
|
+
allowPrivate: opts.allowPrivate,
|
|
45290
|
+
// The target of a served directory is Jourilo's own server; the guard still confines the page.
|
|
45291
|
+
...opts.denyLocal && opts.servedPort === void 0 ? { denyLocal: true } : {}
|
|
45292
|
+
});
|
|
45293
|
+
const hostClass = check.hostClass;
|
|
44922
45294
|
const log = opts.onProgress ?? (() => void 0);
|
|
44923
45295
|
const runDir = ws.createRunDir(id);
|
|
44924
45296
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -44929,7 +45301,7 @@ async function runAudit(ws, opts) {
|
|
|
44929
45301
|
};
|
|
44930
45302
|
let urls;
|
|
44931
45303
|
if (opts.urls?.length) {
|
|
44932
|
-
urls = opts.urls.map((u) =>
|
|
45304
|
+
urls = [...new Set(opts.urls.map((u) => new URL(u, target).toString()))].map((url) => ({ url }));
|
|
44933
45305
|
for (const u of urls) {
|
|
44934
45306
|
if (new URL(u.url).origin !== target.origin)
|
|
44935
45307
|
throw new JouriloError("UNSAFE_URL", `--url ${u.url} is not on the target origin ${target.origin}.`);
|
|
@@ -44950,18 +45322,26 @@ async function runAudit(ws, opts) {
|
|
|
44950
45322
|
let network = { blocked: [], escaped: [] };
|
|
44951
45323
|
const endBrowsing = async () => {
|
|
44952
45324
|
if (session) network = session.report();
|
|
44953
|
-
|
|
45325
|
+
const closing = session;
|
|
44954
45326
|
session = void 0;
|
|
44955
|
-
|
|
45327
|
+
await closeBrowsing(closing, setupState);
|
|
44956
45328
|
};
|
|
44957
45329
|
try {
|
|
44958
45330
|
try {
|
|
45331
|
+
if (check.unresolved) throw new NameUnresolved();
|
|
44959
45332
|
log(`Starting ${opts.browser} \u2026`);
|
|
44960
45333
|
halt();
|
|
44961
45334
|
session = await openGuardedBrowser(
|
|
44962
45335
|
opts.browser,
|
|
44963
45336
|
opts.headed,
|
|
44964
|
-
{
|
|
45337
|
+
{
|
|
45338
|
+
target: hostClass,
|
|
45339
|
+
allowPrivate: opts.allowPrivate,
|
|
45340
|
+
...opts.denyLocal ? {
|
|
45341
|
+
denyLocal: true,
|
|
45342
|
+
...opts.servedPort === void 0 ? {} : { allowLoopbackPorts: [opts.servedPort] }
|
|
45343
|
+
} : {}
|
|
45344
|
+
},
|
|
44965
45345
|
signal !== void 0
|
|
44966
45346
|
);
|
|
44967
45347
|
const browser = session.browser;
|
|
@@ -45026,6 +45406,7 @@ async function runAudit(ws, opts) {
|
|
|
45026
45406
|
const robotsSkips = found.skipped.filter((s) => s.reason === "robots.txt").length;
|
|
45027
45407
|
if (robotsSkips)
|
|
45028
45408
|
limitations.push(`${robotsSkips} URL(s) were skipped because robots.txt disallows them.`);
|
|
45409
|
+
for (const u of found.robotsOverridden) limitations.push(robotsOverrideLimitation(u));
|
|
45029
45410
|
if (found.sitemapRebasedFrom)
|
|
45030
45411
|
limitations.push(
|
|
45031
45412
|
`The sitemap lists ${found.sitemapRebasedFrom} URLs; their paths were audited on the local server instead.`
|
|
@@ -45033,6 +45414,16 @@ async function runAudit(ws, opts) {
|
|
|
45033
45414
|
if (!found.robots && opts.respectRobots && hostClass === "public")
|
|
45034
45415
|
limitations.push("No robots.txt was found; crawling stayed on the target origin.");
|
|
45035
45416
|
urls = discovered.length ? discovered : [{ url: target.toString() }];
|
|
45417
|
+
} else if (opts.respectRobots) {
|
|
45418
|
+
halt();
|
|
45419
|
+
for (const u of await untilStopped(
|
|
45420
|
+
explicitRobotsOverrides(
|
|
45421
|
+
urls.map((x) => x.url),
|
|
45422
|
+
session.guard.policy
|
|
45423
|
+
),
|
|
45424
|
+
signal
|
|
45425
|
+
))
|
|
45426
|
+
limitations.push(robotsOverrideLimitation(u));
|
|
45036
45427
|
}
|
|
45037
45428
|
log(
|
|
45038
45429
|
`Auditing ${urls.length} page(s) \xD7 ${opts.viewports.length} viewport(s) in ${opts.browser} ${browserVersion}`
|
|
@@ -45049,7 +45440,7 @@ async function runAudit(ws, opts) {
|
|
|
45049
45440
|
for (const { url } of urls) {
|
|
45050
45441
|
halt();
|
|
45051
45442
|
log(` ${vp.name.padEnd(8)} ${redactUrl(url)}`);
|
|
45052
|
-
const
|
|
45443
|
+
const capture = capturePage(ctx, url, vp, {
|
|
45053
45444
|
runDir,
|
|
45054
45445
|
guardToken: session.guard.token,
|
|
45055
45446
|
timeoutMs: opts.timeoutMs,
|
|
@@ -45060,6 +45451,12 @@ async function runAudit(ws, opts) {
|
|
|
45060
45451
|
throttle: opts.throttle,
|
|
45061
45452
|
...opts.evidence ? { evidence: opts.evidence } : {}
|
|
45062
45453
|
});
|
|
45454
|
+
const obs = await withinGrace(capture, signal, async () => {
|
|
45455
|
+
await ctx.close();
|
|
45456
|
+
await settled(capture, 1e3);
|
|
45457
|
+
const { full, fold } = captureScreenshotFiles(url, vp);
|
|
45458
|
+
for (const f of [full, fold]) rmSync2(join10(runDir, f), { force: true });
|
|
45459
|
+
});
|
|
45063
45460
|
observations.push(obs);
|
|
45064
45461
|
}
|
|
45065
45462
|
} finally {
|
|
@@ -45073,13 +45470,17 @@ async function runAudit(ws, opts) {
|
|
|
45073
45470
|
for (let k = 1; k < runs2; k++) {
|
|
45074
45471
|
halt();
|
|
45075
45472
|
log(` metrics ${obs.viewport.name.padEnd(8)} ${obs.url} (run ${k + 1}/${runs2})`);
|
|
45076
|
-
const v = await
|
|
45077
|
-
|
|
45078
|
-
|
|
45079
|
-
|
|
45080
|
-
|
|
45081
|
-
|
|
45082
|
-
|
|
45473
|
+
const v = await withinGrace(
|
|
45474
|
+
measureVitals(browser, obs.finalUrl, obs.viewport, {
|
|
45475
|
+
runDir,
|
|
45476
|
+
timeoutMs: opts.timeoutMs,
|
|
45477
|
+
throttle: opts.throttle,
|
|
45478
|
+
...storageState ? { storageState } : {},
|
|
45479
|
+
...stateOptions(opts)
|
|
45480
|
+
}),
|
|
45481
|
+
signal,
|
|
45482
|
+
async () => void 0
|
|
45483
|
+
);
|
|
45083
45484
|
if (v) samples.push(v);
|
|
45084
45485
|
}
|
|
45085
45486
|
const m = medianVitals(samples);
|
|
@@ -45106,7 +45507,7 @@ async function runAudit(ws, opts) {
|
|
|
45106
45507
|
for (const form of probeable.slice(0, 3)) {
|
|
45107
45508
|
halt();
|
|
45108
45509
|
log(` form ${o.url} ${form.selector}`);
|
|
45109
|
-
const
|
|
45510
|
+
const probe = probeFormValidation(
|
|
45110
45511
|
ctx,
|
|
45111
45512
|
o.finalUrl,
|
|
45112
45513
|
form,
|
|
@@ -45114,6 +45515,11 @@ async function runAudit(ws, opts) {
|
|
|
45114
45515
|
{ hostClass, allowSubmit: opts.allowSubmit ?? false, allowDestructive: false },
|
|
45115
45516
|
runDir
|
|
45116
45517
|
);
|
|
45518
|
+
const r = await withinGrace(probe, signal, async () => {
|
|
45519
|
+
await ctx.close();
|
|
45520
|
+
await settled(probe, 1e3);
|
|
45521
|
+
rmSync2(join10(runDir, formScreenshotFile(o.finalUrl, form.selector, vp)), { force: true });
|
|
45522
|
+
});
|
|
45117
45523
|
formFindings.push(...r.findings);
|
|
45118
45524
|
if (r.result.outcome === "blocked")
|
|
45119
45525
|
limitations.push(`Form ${form.selector} on ${o.url} was not submitted: ${r.result.detail}`);
|
|
@@ -45133,9 +45539,14 @@ async function runAudit(ws, opts) {
|
|
|
45133
45539
|
);
|
|
45134
45540
|
}
|
|
45135
45541
|
} catch (err) {
|
|
45136
|
-
if (
|
|
45137
|
-
|
|
45138
|
-
|
|
45542
|
+
if (err instanceof NameUnresolved) {
|
|
45543
|
+
const msg = `No page was loaded: the host name ${target.hostname} could not be resolved (no hosts entry or local DNS answer).`;
|
|
45544
|
+
log(msg);
|
|
45545
|
+
limitations.push(msg);
|
|
45546
|
+
} else if (err instanceof AuditStopped) {
|
|
45547
|
+
stoppedBy = signal?.reason === "max-duration" ? "max-duration" : "signal";
|
|
45548
|
+
log(`Stopped early (${stoppedBy}); writing a partial run \u2026`);
|
|
45549
|
+
} else throw err;
|
|
45139
45550
|
}
|
|
45140
45551
|
await endBrowsing();
|
|
45141
45552
|
if (stoppedBy)
|
|
@@ -45201,6 +45612,7 @@ async function runAudit(ws, opts) {
|
|
|
45201
45612
|
title: o.title,
|
|
45202
45613
|
viewport: o.viewport.name,
|
|
45203
45614
|
...o.screenshot ? { screenshot: o.screenshot } : {},
|
|
45615
|
+
...o.foldScreenshot ? { foldScreenshot: o.foldScreenshot } : {},
|
|
45204
45616
|
loadMs: o.loadMs,
|
|
45205
45617
|
metrics: o.vitals ? toMetrics(o.vitals, o.vitalsSpread, o.vitalsRuns) : [],
|
|
45206
45618
|
consoleErrors: o.console.filter((c) => c.type !== "warning").length,
|
|
@@ -45261,6 +45673,13 @@ async function runAudit(ws, opts) {
|
|
|
45261
45673
|
await endBrowsing();
|
|
45262
45674
|
}
|
|
45263
45675
|
}
|
|
45676
|
+
async function closeBrowsing(session, setupState) {
|
|
45677
|
+
try {
|
|
45678
|
+
await session?.close();
|
|
45679
|
+
} finally {
|
|
45680
|
+
rmSync2(setupState, { force: true });
|
|
45681
|
+
}
|
|
45682
|
+
}
|
|
45264
45683
|
function summarize(findings, pages, journeys2) {
|
|
45265
45684
|
const sev = { critical: 0, serious: 0, moderate: 0, minor: 0, info: 0 };
|
|
45266
45685
|
const kind = { defect: 0, hypothesis: 0, "needs-review": 0 };
|
|
@@ -45340,6 +45759,12 @@ function stablePort(root) {
|
|
|
45340
45759
|
for (const ch of resolve4(root)) h = h * 31 + ch.charCodeAt(0) >>> 0;
|
|
45341
45760
|
return 41e3 + h % 8e3;
|
|
45342
45761
|
}
|
|
45762
|
+
function isDirectoryTarget(arg) {
|
|
45763
|
+
return !/^[a-z][a-z0-9+.-]*:\/\//i.test(arg) && existsSync7(arg) && statSync2(arg).isDirectory();
|
|
45764
|
+
}
|
|
45765
|
+
function servedPort(server) {
|
|
45766
|
+
return Number(new URL(server.url).port);
|
|
45767
|
+
}
|
|
45343
45768
|
async function serveStatic(root, port = 0, opts = {}) {
|
|
45344
45769
|
if (!existsSync7(root) || !statSync2(root).isDirectory())
|
|
45345
45770
|
throw new JouriloError("NOT_FOUND", `Not a directory: ${root}`);
|
|
@@ -48646,19 +49071,20 @@ async function suggest(ctx) {
|
|
|
48646
49071
|
if (!target || ctx.ws.listRuns().some((r) => r.id === target) || target === "latest") {
|
|
48647
49072
|
run = ctx.ws.loadRun(target ?? "latest").run;
|
|
48648
49073
|
} else {
|
|
48649
|
-
const isDir =
|
|
49074
|
+
const isDir = isDirectoryTarget(target);
|
|
48650
49075
|
const server = isDir ? await serveStatic(resolve5(target)) : void 0;
|
|
48651
49076
|
try {
|
|
48652
49077
|
ctx.log("Discovering pages to derive journeys (no audit rules applied) \u2026");
|
|
48653
49078
|
run = (await runAudit(ctx.ws, {
|
|
48654
49079
|
target: server?.url ?? target,
|
|
48655
49080
|
viewports: parseViewports("desktop"),
|
|
48656
|
-
maxPages:
|
|
49081
|
+
maxPages: pagesOption(ctx, 8),
|
|
48657
49082
|
maxDepth: 3,
|
|
48658
49083
|
browser: "chromium",
|
|
48659
49084
|
headed: false,
|
|
48660
49085
|
respectRobots: true,
|
|
48661
49086
|
allowPrivate: flag(ctx, "allow-private"),
|
|
49087
|
+
...denyLocalFrom(ctx) ? { denyLocal: true, ...server ? { servedPort: servedPort(server) } : {} } : {},
|
|
48662
49088
|
keyboard: false,
|
|
48663
49089
|
throttle: false,
|
|
48664
49090
|
timeoutMs: 3e4,
|
|
@@ -48748,17 +49174,23 @@ async function runCmd(ctx) {
|
|
|
48748
49174
|
);
|
|
48749
49175
|
const state = parseStateOptions(ctx);
|
|
48750
49176
|
const evidence = parseEvidence(ctx);
|
|
48751
|
-
const isDir =
|
|
49177
|
+
const isDir = isDirectoryTarget(baseArg);
|
|
48752
49178
|
const server = isDir ? await serveStatic(resolve5(baseArg)) : void 0;
|
|
48753
49179
|
try {
|
|
48754
49180
|
const base2 = parseTargetUrl(server?.url ?? baseArg);
|
|
48755
|
-
const
|
|
49181
|
+
const denyLocal = denyLocalFrom(ctx);
|
|
49182
|
+
refuseDenyLocalWebkit(denyLocal, str(ctx, "browser") ?? "chromium");
|
|
49183
|
+
const hostClass = await checkTarget(base2, {
|
|
49184
|
+
allowPrivate: flag(ctx, "allow-private"),
|
|
49185
|
+
denyLocal: denyLocal && !server
|
|
49186
|
+
});
|
|
48756
49187
|
const id = newRunId();
|
|
48757
49188
|
const runDir = ctx.ws.createRunDir(id);
|
|
48758
49189
|
const browserName = str(ctx, "browser") ?? "chromium";
|
|
48759
49190
|
const session = await openGuardedBrowser(browserName, flag(ctx, "headed"), {
|
|
48760
49191
|
target: hostClass,
|
|
48761
|
-
allowPrivate: flag(ctx, "allow-private")
|
|
49192
|
+
allowPrivate: flag(ctx, "allow-private"),
|
|
49193
|
+
...denyLocal ? { denyLocal: true, ...server ? { allowLoopbackPorts: [servedPort(server)] } : {} } : {}
|
|
48762
49194
|
});
|
|
48763
49195
|
const browser = session.browser;
|
|
48764
49196
|
const browserVersion = browser.version();
|
|
@@ -48826,6 +49258,7 @@ async function runCmd(ctx) {
|
|
|
48826
49258
|
...str(ctx, "auth") ? { storageState: str(ctx, "auth") } : {},
|
|
48827
49259
|
...state,
|
|
48828
49260
|
...evidence ? { evidence } : {},
|
|
49261
|
+
...repeat > 1 ? { attempt: k + 1 } : {},
|
|
48829
49262
|
...saveAuth ? { saveAuth } : {},
|
|
48830
49263
|
...allowOrigins.length ? { allowOrigins } : {},
|
|
48831
49264
|
log: k === 0 ? ctx.log : void 0
|
|
@@ -48918,6 +49351,7 @@ var journeys = {
|
|
|
48918
49351
|
"allow-submit": { type: "boolean" },
|
|
48919
49352
|
"allow-destructive": { type: "boolean" },
|
|
48920
49353
|
"allow-private": { type: "boolean" },
|
|
49354
|
+
"deny-local": { type: "boolean" },
|
|
48921
49355
|
"allow-origin": { type: "string", multiple: true },
|
|
48922
49356
|
"fail-on-failure": { type: "boolean" },
|
|
48923
49357
|
browser: { type: "string" },
|
|
@@ -48943,6 +49377,8 @@ Suggest options:
|
|
|
48943
49377
|
--pages N Pages to read when deriving journeys from a site (default 8)
|
|
48944
49378
|
--force Overwrite journey files that already exist (by default existing files are kept)
|
|
48945
49379
|
--allow-private Allow private-network hosts (10.x, 192.168.x, ...)
|
|
49380
|
+
--deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
|
|
49381
|
+
JOURILO_DENY_LOCAL=1 does the same
|
|
48946
49382
|
|
|
48947
49383
|
Run options:
|
|
48948
49384
|
--base URL|DIR The site or static build to run against (required)
|
|
@@ -48963,6 +49399,8 @@ Run options:
|
|
|
48963
49399
|
--evidence MODE full (default) or minimal: keep only the screenshots of the failed step, the step before it and the last step
|
|
48964
49400
|
--repeat N Run each journey N times (max 10); mixed outcomes are reported as flaky
|
|
48965
49401
|
--allow-private Allow private-network hosts (10.x, 192.168.x, ...)
|
|
49402
|
+
--deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
|
|
49403
|
+
JOURILO_DENY_LOCAL=1 does the same. Not available with --browser webkit (exit 3)
|
|
48966
49404
|
--fail-on-failure Exit 1 when a journey fails
|
|
48967
49405
|
|
|
48968
49406
|
Safety: payment, purchase, delete and cancel actions are never performed on non-local hosts; form submission
|
|
@@ -48999,6 +49437,7 @@ var AUDIT_OPTIONS = {
|
|
|
48999
49437
|
throttle: { type: "boolean" },
|
|
49000
49438
|
"ignore-robots": { type: "boolean" },
|
|
49001
49439
|
"allow-private": { type: "boolean" },
|
|
49440
|
+
"deny-local": { type: "boolean" },
|
|
49002
49441
|
mask: { type: "string", multiple: true },
|
|
49003
49442
|
auth: { type: "string" },
|
|
49004
49443
|
locale: { type: "string" },
|
|
@@ -49019,20 +49458,35 @@ var AUDIT_OPTIONS = {
|
|
|
49019
49458
|
};
|
|
49020
49459
|
var SEVERITIES = ["critical", "serious", "moderate", "minor"];
|
|
49021
49460
|
var BROWSERS = ["chromium", "firefox", "webkit", "chrome", "msedge"];
|
|
49461
|
+
function stoppedTwiceLine(json) {
|
|
49462
|
+
const message = "A second stop signal: quitting at once (exit 130). No run was written for this audit.";
|
|
49463
|
+
return json ? `${JSON.stringify({ type: "error", error: { code: "STOPPED_TWICE", message } })}
|
|
49464
|
+
` : `jourilo: ${message}
|
|
49465
|
+
`;
|
|
49466
|
+
}
|
|
49022
49467
|
function stopController(ctx, maxDurationS) {
|
|
49023
49468
|
const controller = new AbortController();
|
|
49469
|
+
const grace = `${STOP_GRACE_MS / 1e3} s`;
|
|
49024
49470
|
let signals = 0;
|
|
49025
49471
|
const onSignal = () => {
|
|
49026
|
-
if (++signals > 1)
|
|
49472
|
+
if (++signals > 1) {
|
|
49473
|
+
try {
|
|
49474
|
+
writeSync2(2, stoppedTwiceLine(ctx.json));
|
|
49475
|
+
} catch {
|
|
49476
|
+
}
|
|
49477
|
+
process.exit(130);
|
|
49478
|
+
}
|
|
49027
49479
|
ctx.log(
|
|
49028
|
-
|
|
49480
|
+
`Stopping: the page in progress gets up to ${grace} to finish, then a partial run is written (signal again to quit now) \u2026`
|
|
49029
49481
|
);
|
|
49030
49482
|
controller.abort("signal");
|
|
49031
49483
|
};
|
|
49032
49484
|
process.on("SIGINT", onSignal);
|
|
49033
49485
|
process.on("SIGTERM", onSignal);
|
|
49034
49486
|
const timer = maxDurationS ? setTimeout(() => {
|
|
49035
|
-
ctx.log(
|
|
49487
|
+
ctx.log(
|
|
49488
|
+
`--max-duration ${maxDurationS}s reached: the page in progress gets up to ${grace} to finish, then a partial run is written \u2026`
|
|
49489
|
+
);
|
|
49036
49490
|
controller.abort("max-duration");
|
|
49037
49491
|
}, maxDurationS * 1e3) : void 0;
|
|
49038
49492
|
return {
|
|
@@ -49049,32 +49503,34 @@ async function auditCommand(ctx, usage2, categories) {
|
|
|
49049
49503
|
const browser = str(ctx, "browser") ?? "chromium";
|
|
49050
49504
|
if (!BROWSERS.includes(browser))
|
|
49051
49505
|
throw new JouriloError("USAGE", `--browser must be one of ${BROWSERS.join(", ")}.`);
|
|
49506
|
+
refuseDenyLocalWebkit(denyLocalFrom(ctx), browser);
|
|
49052
49507
|
const failOn = str(ctx, "fail-on");
|
|
49053
49508
|
if (failOn && !SEVERITIES.includes(failOn))
|
|
49054
49509
|
throw new JouriloError("USAGE", `--fail-on must be one of ${SEVERITIES.join(", ")}.`);
|
|
49055
49510
|
const state = parseStateOptions(ctx);
|
|
49056
49511
|
const auth = str(ctx, "auth");
|
|
49057
49512
|
if (auth && !existsSync12(auth)) throw new JouriloError("NOT_FOUND", `Auth state file not found: ${auth}`);
|
|
49058
|
-
const maxDuration =
|
|
49059
|
-
|
|
49513
|
+
const maxDuration = maxDurationOption(ctx);
|
|
49514
|
+
const maxPages = pagesOption(ctx, 6);
|
|
49060
49515
|
const evidence = parseEvidence(ctx);
|
|
49061
49516
|
const setupRef = str(ctx, "setup-journey");
|
|
49062
49517
|
const setupJourney = setupRef ? loadJourneys(ctx.ws, [setupRef])[0] : void 0;
|
|
49063
49518
|
const stop = stopController(ctx, maxDuration);
|
|
49064
49519
|
let server;
|
|
49065
49520
|
try {
|
|
49066
|
-
const isDir =
|
|
49521
|
+
const isDir = isDirectoryTarget(targetArg);
|
|
49067
49522
|
server = isDir ? await serveStatic(resolve6(targetArg)) : void 0;
|
|
49068
49523
|
const repo = str(ctx, "repo") ?? (isDir ? resolve6(targetArg) : void 0);
|
|
49069
49524
|
const opts = {
|
|
49070
49525
|
target: server ? server.url : targetArg,
|
|
49071
49526
|
viewports: parseViewports(str(ctx, "viewports")),
|
|
49072
|
-
maxPages
|
|
49527
|
+
maxPages,
|
|
49073
49528
|
maxDepth: num(ctx, "depth", 3),
|
|
49074
49529
|
browser,
|
|
49075
49530
|
headed: flag(ctx, "headed"),
|
|
49076
49531
|
respectRobots: !flag(ctx, "ignore-robots"),
|
|
49077
49532
|
allowPrivate: flag(ctx, "allow-private"),
|
|
49533
|
+
...denyLocalFrom(ctx) ? { denyLocal: true, ...server ? { servedPort: servedPort(server) } : {} } : {},
|
|
49078
49534
|
keyboard: !flag(ctx, "no-keyboard"),
|
|
49079
49535
|
throttle: flag(ctx, "throttle"),
|
|
49080
49536
|
runs: num(ctx, "runs", 1),
|
|
@@ -49174,7 +49630,8 @@ function partialLines(run) {
|
|
|
49174
49630
|
function noPageLines(run) {
|
|
49175
49631
|
if (run.outcome !== "no-page-loaded") return [];
|
|
49176
49632
|
const first = run.findings.find((f) => f.ruleId === "jourilo/page-unreachable");
|
|
49177
|
-
|
|
49633
|
+
const why = first?.description ?? run.limitations.find((l) => l.includes("could not be resolved"));
|
|
49634
|
+
return [`No page could be loaded from ${run.target}.`, ...why ? [` ${why}`] : [], ""];
|
|
49178
49635
|
}
|
|
49179
49636
|
var audit = {
|
|
49180
49637
|
name: "audit",
|
|
@@ -49182,7 +49639,7 @@ var audit = {
|
|
|
49182
49639
|
usage: "jourilo audit <url|dir> [--pages 6] [--viewports desktop,mobile] [--url /path ...] [--repo .] [--fail-on serious]",
|
|
49183
49640
|
options: AUDIT_OPTIONS,
|
|
49184
49641
|
details: `Options:
|
|
49185
|
-
--pages N Pages to audit after discovery (default 6; 1 = only the target)
|
|
49642
|
+
--pages N Pages to audit after discovery (default 6; 1 = only the target; 0 is a usage error)
|
|
49186
49643
|
--depth N Link depth for discovery (default 3)
|
|
49187
49644
|
--url PATH Audit exactly these paths/URLs on the target origin (repeatable; skips discovery)
|
|
49188
49645
|
--include PATH Add pages to discovery (repeatable)
|
|
@@ -49204,6 +49661,8 @@ var audit = {
|
|
|
49204
49661
|
--brand SLUG Markilo brand to check design consistency against (default: master brand)
|
|
49205
49662
|
--ignore-robots Ignore robots.txt (only for sites you own or are authorized to test)
|
|
49206
49663
|
--allow-private Allow private-network hosts (10.x, 192.168.x, ...)
|
|
49664
|
+
--deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
|
|
49665
|
+
JOURILO_DENY_LOCAL=1 does the same. Not available with --browser webkit (exit 3). For hosted integrations; a directory target is still served
|
|
49207
49666
|
--no-forms Skip the form validation probe
|
|
49208
49667
|
(by default, on local hosts, an empty submission is simulated: nothing that sends data
|
|
49209
49668
|
leaves the browser, so no database is written; server-side feedback is reported as untested)
|
|
@@ -49213,9 +49672,11 @@ var audit = {
|
|
|
49213
49672
|
--sarif Also write report.sarif for code scanning
|
|
49214
49673
|
--fail-on SEVERITY Exit 1 if a defect at or above this severity exists (CI gate)
|
|
49215
49674
|
--evidence MODE full (default) or minimal: keep first screens and only the full-page screenshots findings need; JPEG quality 55
|
|
49216
|
-
--run-id ID Use this run id instead of a generated one (1-64 of A-Z a-z 0-9 _ -); refused
|
|
49217
|
-
|
|
49218
|
-
|
|
49675
|
+
--run-id ID Use this run id instead of a generated one (1-64 of A-Z a-z 0-9 _ -); refused (exit 2) if its
|
|
49676
|
+
directory exists, even without a run.json (left by a crashed run)
|
|
49677
|
+
--max-duration SECS Stop after this many seconds (1 to 86400) and write a partial run (exit 6), as SIGINT/SIGTERM do;
|
|
49678
|
+
the page in progress gets up to 3 s to finish, then it is left out; a second signal quits at
|
|
49679
|
+
once (exit 130) after one line on stderr (STOPPED_TWICE with --json)`,
|
|
49219
49680
|
run: (ctx) => auditCommand(ctx, audit.usage)
|
|
49220
49681
|
};
|
|
49221
49682
|
var accessibility = {
|
|
@@ -49263,18 +49724,33 @@ async function benchFixture(dir, wsRoot) {
|
|
|
49263
49724
|
context: loadContext(dir)
|
|
49264
49725
|
});
|
|
49265
49726
|
locateFindings(run, dir);
|
|
49266
|
-
const
|
|
49727
|
+
const evidenceOk = (f, e) => {
|
|
49728
|
+
if (!e.evidenceExactly) return true;
|
|
49729
|
+
const texts = f.evidence.map((ev) => `${ev.snippet ?? ""} ${ev.selector ?? ""}`);
|
|
49730
|
+
return texts.length > 0 && texts.every((t) => e.evidenceExactly.some((n) => t.includes(n))) && e.evidenceExactly.every((n) => texts.some((t) => t.includes(n)));
|
|
49731
|
+
};
|
|
49732
|
+
const missed = expected.mustFind.filter(
|
|
49733
|
+
(e) => !run.findings.some((f) => matches(f, e) && evidenceOk(f, e))
|
|
49734
|
+
);
|
|
49267
49735
|
for (const r of expected.recommendations ?? []) {
|
|
49268
49736
|
const f = run.findings.find((x) => matches(x, r));
|
|
49269
49737
|
if (!f?.recommendation.includes(r.includes))
|
|
49270
49738
|
missed.push({ rule: r.rule, path: r.path, why: `recommendation should include "${r.includes}"` });
|
|
49271
49739
|
}
|
|
49272
49740
|
const falsePositives = expected.mustNotFind.filter((e) => run.findings.some((f) => matches(f, e))).map((e) => ({ rule: e.rule, path: e.path, ...e.why ? { why: e.why } : {} }));
|
|
49273
|
-
const
|
|
49274
|
-
|
|
49275
|
-
)
|
|
49276
|
-
|
|
49277
|
-
|
|
49741
|
+
const limits = expected.maxDefects ? [expected.maxDefects].flat() : [];
|
|
49742
|
+
const excessDefects = [];
|
|
49743
|
+
for (const lim of limits) {
|
|
49744
|
+
const over = [];
|
|
49745
|
+
for (const f of run.findings) {
|
|
49746
|
+
if (f.kind !== "defect" || severityRank(f.severity) > severityRank(lim.minSeverity)) continue;
|
|
49747
|
+
if (lim.rule && f.ruleId !== lim.rule) continue;
|
|
49748
|
+
if (lim.paths && !lim.paths.includes(pathOf2(f.url).split("?")[0].replace(/\/$/, "") || "/"))
|
|
49749
|
+
continue;
|
|
49750
|
+
over.push({ rule: f.ruleId, path: pathOf2(f.url), severity: f.severity });
|
|
49751
|
+
}
|
|
49752
|
+
excessDefects.push(...over.slice(0, Math.max(0, over.length - lim.count)));
|
|
49753
|
+
}
|
|
49278
49754
|
const sourceMisses = [];
|
|
49279
49755
|
for (const s of expected.sources ?? []) {
|
|
49280
49756
|
const f = run.findings.find((x) => matches(x, { rule: s.rule, path: s.path }));
|
|
@@ -49287,10 +49763,7 @@ async function benchFixture(dir, wsRoot) {
|
|
|
49287
49763
|
expected: expected.mustFind.length,
|
|
49288
49764
|
missed,
|
|
49289
49765
|
falsePositives,
|
|
49290
|
-
excessDefects
|
|
49291
|
-
0,
|
|
49292
|
-
Math.max(0, excessDefects.length - (expected.maxDefects?.count ?? 0))
|
|
49293
|
-
),
|
|
49766
|
+
excessDefects,
|
|
49294
49767
|
sourceHits: (expected.sources?.length ?? 0) - sourceMisses.length,
|
|
49295
49768
|
sourceExpected: expected.sources?.length ?? 0,
|
|
49296
49769
|
sourceMisses,
|
|
@@ -49365,21 +49838,21 @@ var bench = {
|
|
|
49365
49838
|
};
|
|
49366
49839
|
|
|
49367
49840
|
// src/cli/commands/discover.ts
|
|
49368
|
-
import { existsSync as existsSync16, statSync as
|
|
49841
|
+
import { existsSync as existsSync16, statSync as statSync7, writeFileSync as writeFileSync10 } from "node:fs";
|
|
49369
49842
|
import { join as join21, resolve as resolve8 } from "node:path";
|
|
49370
49843
|
|
|
49371
49844
|
// src/discovery/repo.ts
|
|
49372
49845
|
init_fs_safety();
|
|
49373
|
-
import { existsSync as existsSync15, readdirSync as readdirSync11, readFileSync as readFileSync14, statSync as
|
|
49846
|
+
import { existsSync as existsSync15, readdirSync as readdirSync11, readFileSync as readFileSync14, statSync as statSync6 } from "node:fs";
|
|
49374
49847
|
import { basename as basename5, dirname as dirname8, join as join20, relative as relative5 } from "node:path";
|
|
49375
49848
|
|
|
49376
49849
|
// src/discovery/laravel.ts
|
|
49377
49850
|
init_fs_safety();
|
|
49378
|
-
import { existsSync as existsSync14, readdirSync as readdirSync10, readFileSync as readFileSync13, statSync as
|
|
49851
|
+
import { existsSync as existsSync14, readdirSync as readdirSync10, readFileSync as readFileSync13, statSync as statSync5 } from "node:fs";
|
|
49379
49852
|
import { basename as basename4, dirname as dirname7, join as join19, relative as relative4 } from "node:path";
|
|
49380
49853
|
function read(file2) {
|
|
49381
49854
|
try {
|
|
49382
|
-
return
|
|
49855
|
+
return statSync5(file2).size > 4e5 ? "" : readFileSync13(file2, "utf8");
|
|
49383
49856
|
} catch {
|
|
49384
49857
|
return "";
|
|
49385
49858
|
}
|
|
@@ -49622,7 +50095,7 @@ function walk(root, max = 2e4) {
|
|
|
49622
50095
|
const full = join20(dir, n);
|
|
49623
50096
|
let st;
|
|
49624
50097
|
try {
|
|
49625
|
-
st =
|
|
50098
|
+
st = statSync6(full);
|
|
49626
50099
|
} catch {
|
|
49627
50100
|
continue;
|
|
49628
50101
|
}
|
|
@@ -49642,7 +50115,7 @@ function readJson2(file2) {
|
|
|
49642
50115
|
}
|
|
49643
50116
|
function read2(file2, max = 4e5) {
|
|
49644
50117
|
try {
|
|
49645
|
-
const st =
|
|
50118
|
+
const st = statSync6(file2);
|
|
49646
50119
|
if (st.size > max) return "";
|
|
49647
50120
|
return readFileSync14(file2, "utf8");
|
|
49648
50121
|
} catch {
|
|
@@ -50248,20 +50721,23 @@ function siteText(p) {
|
|
|
50248
50721
|
var discover = {
|
|
50249
50722
|
name: "discover",
|
|
50250
50723
|
summary: "Product intelligence: map a repository (frameworks, routes, design system, i18n, auth, analytics) or a website (pages, conversion points, services).",
|
|
50251
|
-
usage: "jourilo discover <path|url> [--pages 10] [--ignore-robots] [--allow-private]",
|
|
50724
|
+
usage: "jourilo discover <path|url> [--pages 10] [--ignore-robots] [--allow-private] [--deny-local]",
|
|
50252
50725
|
options: {
|
|
50253
50726
|
pages: { type: "string" },
|
|
50254
50727
|
"allow-private": { type: "boolean" },
|
|
50728
|
+
"deny-local": { type: "boolean" },
|
|
50255
50729
|
"ignore-robots": { type: "boolean" }
|
|
50256
50730
|
},
|
|
50257
50731
|
details: `Options:
|
|
50258
50732
|
--pages N Pages to read when mapping a website (default 10)
|
|
50259
50733
|
--ignore-robots Ignore robots.txt (only for sites you own or are authorized to test)
|
|
50260
|
-
--allow-private Allow private-network hosts (10.x, 192.168.x, ...)
|
|
50734
|
+
--allow-private Allow private-network hosts (10.x, 192.168.x, ...)
|
|
50735
|
+
--deny-local Refuse local targets (localhost, 127.0.0.1, [::1], *.localhost, *.test) (exit 3); a redirect or request to one is blocked by the network guard and listed in the limitations;
|
|
50736
|
+
JOURILO_DENY_LOCAL=1 does the same`,
|
|
50261
50737
|
async run(ctx) {
|
|
50262
50738
|
const target = requirePositional(ctx, 0, "path|url", discover.usage);
|
|
50263
50739
|
ctx.ws.ensure();
|
|
50264
|
-
if (!target.includes("://") && existsSync16(target) &&
|
|
50740
|
+
if (!target.includes("://") && existsSync16(target) && statSync7(target).isDirectory()) {
|
|
50265
50741
|
const profile2 = discoverRepository(resolve8(target));
|
|
50266
50742
|
const file3 = join21(ctx.ws.root, "product.json");
|
|
50267
50743
|
writeFileSync10(file3, `${JSON.stringify(profile2, null, 2)}
|
|
@@ -50273,12 +50749,13 @@ var discover = {
|
|
|
50273
50749
|
const { run, observations } = await runAudit(ctx.ws, {
|
|
50274
50750
|
target,
|
|
50275
50751
|
viewports: parseViewports("desktop"),
|
|
50276
|
-
maxPages:
|
|
50752
|
+
maxPages: pagesOption(ctx, 10),
|
|
50277
50753
|
maxDepth: 3,
|
|
50278
50754
|
browser: "chromium",
|
|
50279
50755
|
headed: false,
|
|
50280
50756
|
respectRobots: !flag(ctx, "ignore-robots"),
|
|
50281
50757
|
allowPrivate: flag(ctx, "allow-private"),
|
|
50758
|
+
...denyLocalFrom(ctx) ? { denyLocal: true } : {},
|
|
50282
50759
|
keyboard: false,
|
|
50283
50760
|
throttle: false,
|
|
50284
50761
|
timeoutMs: 3e4,
|
|
@@ -51042,14 +51519,7 @@ function metricVerdict(name, a, b) {
|
|
|
51042
51519
|
}
|
|
51043
51520
|
return Math.abs(delta) < (RESOLUTION[name] ?? 0) ? "noise" : "unconfirmed";
|
|
51044
51521
|
}
|
|
51045
|
-
|
|
51046
|
-
try {
|
|
51047
|
-
const x = new URL(u);
|
|
51048
|
-
return `${x.pathname}${x.search}`;
|
|
51049
|
-
} catch {
|
|
51050
|
-
return u;
|
|
51051
|
-
}
|
|
51052
|
-
}
|
|
51522
|
+
var pathKey = fingerprintPath;
|
|
51053
51523
|
function compareRuns(base2, head) {
|
|
51054
51524
|
const before = new Map(base2.findings.map((f) => [f.fingerprint, f]));
|
|
51055
51525
|
const after = new Map(head.findings.map((f) => [f.fingerprint, f]));
|
|
@@ -51207,6 +51677,24 @@ function metricSummary(metrics) {
|
|
|
51207
51677
|
);
|
|
51208
51678
|
return lines;
|
|
51209
51679
|
}
|
|
51680
|
+
function verifyTargetUrl(target) {
|
|
51681
|
+
const hint = "Give a directory to serve, or a URL that starts with http:// or https://.";
|
|
51682
|
+
if (existsSync19(target))
|
|
51683
|
+
throw new JouriloError("INVALID_INPUT", `--target ${target} is a file, not a directory or a URL.`, hint);
|
|
51684
|
+
let url;
|
|
51685
|
+
try {
|
|
51686
|
+
url = new URL(target);
|
|
51687
|
+
} catch {
|
|
51688
|
+
url = void 0;
|
|
51689
|
+
}
|
|
51690
|
+
if (!url || url.protocol !== "http:" && url.protocol !== "https:" || !url.hostname)
|
|
51691
|
+
throw new JouriloError(
|
|
51692
|
+
"INVALID_INPUT",
|
|
51693
|
+
`--target ${target} is neither an existing directory nor an http(s) URL.`,
|
|
51694
|
+
hint
|
|
51695
|
+
);
|
|
51696
|
+
return url;
|
|
51697
|
+
}
|
|
51210
51698
|
var verify = {
|
|
51211
51699
|
name: "verify",
|
|
51212
51700
|
summary: "Repeat a run with the same scope (pages, viewports) after a change and compare: fixed, new, persisting, metric deltas.",
|
|
@@ -51214,25 +51702,31 @@ var verify = {
|
|
|
51214
51702
|
options: {
|
|
51215
51703
|
target: { type: "string" },
|
|
51216
51704
|
"fail-on-regression": { type: "boolean" },
|
|
51705
|
+
"deny-local": { type: "boolean" },
|
|
51217
51706
|
repo: { type: "string" }
|
|
51218
51707
|
},
|
|
51219
51708
|
details: `Options:
|
|
51220
51709
|
--target URL|DIR Where to repeat the audit (default: the baseline run's target)
|
|
51221
51710
|
--repo PATH Repository for source mapping (default: the baseline run's repo)
|
|
51711
|
+
--deny-local Refuse a local target (localhost, 127.0.0.1, [::1], *.localhost, *.test) with exit 3; a redirect to one is blocked by the network guard and listed in the limitations;
|
|
51712
|
+
JOURILO_DENY_LOCAL=1 does the same. Not available with --browser webkit (exit 3)
|
|
51222
51713
|
--fail-on-regression Exit 1 when new serious or critical defects appear`,
|
|
51223
51714
|
async run(ctx) {
|
|
51715
|
+
const target = str(ctx, "target");
|
|
51716
|
+
const targetDir = target !== void 0 && isDirectoryTarget(target);
|
|
51717
|
+
const targetUrl = target !== void 0 && !targetDir ? verifyTargetUrl(target) : void 0;
|
|
51224
51718
|
const base2 = ctx.ws.loadRun(ctx.positionals[0] ?? "latest").run;
|
|
51719
|
+
refuseDenyLocalWebkit(denyLocalFrom(ctx), String(base2.browser.name ?? "chromium"));
|
|
51225
51720
|
const scope = base2.settings.scope;
|
|
51226
51721
|
if (!scope)
|
|
51227
51722
|
throw new JouriloError(
|
|
51228
51723
|
"INVALID_INPUT",
|
|
51229
51724
|
`Run ${base2.id} has no recorded scope (created by an older version or by journeys).`
|
|
51230
51725
|
);
|
|
51231
|
-
const
|
|
51232
|
-
const servedDir = target ? existsSync19(target) ? resolve9(target) : void 0 : base2.settings.servedDir;
|
|
51726
|
+
const servedDir = target !== void 0 ? targetDir ? resolve9(target) : void 0 : base2.settings.servedDir;
|
|
51233
51727
|
const server = servedDir ? await serveStatic(servedDir) : void 0;
|
|
51234
51728
|
try {
|
|
51235
|
-
const origin = server?.url ?? new URL(
|
|
51729
|
+
const origin = server?.url ?? (targetUrl ?? new URL(scope.urls[0])).origin;
|
|
51236
51730
|
const urls = scope.urls.map((u) => {
|
|
51237
51731
|
const x = new URL(u);
|
|
51238
51732
|
return `${x.pathname}${x.search}`;
|
|
@@ -51261,6 +51755,7 @@ var verify = {
|
|
|
51261
51755
|
headed: false,
|
|
51262
51756
|
respectRobots: base2.settings.respectRobots !== false,
|
|
51263
51757
|
allowPrivate: base2.settings.hostClass === "private",
|
|
51758
|
+
...denyLocalFrom(ctx) ? { denyLocal: true, ...server ? { servedPort: servedPort(server) } : {} } : {},
|
|
51264
51759
|
keyboard: base2.settings.keyboard !== false,
|
|
51265
51760
|
throttle: base2.settings.throttle === true,
|
|
51266
51761
|
// Same number of visits as the baseline, so medians are compared with medians.
|