"use strict";
const fs = require("fs");
const path = require("path");
const http = require("http");
const crypto = require("crypto");
const cp = require("child_process");
const os = require("os");
const TOOL_NAME = "jquery35-local-agent";
const TOOL_VERSION = "5.6.10";
const TARGET_JQUERY_FLOOR_VERSION = "3.5.0";
const DEFAULT_JQUERY_VERSION = "3.5.1";
const DEFAULT_MIGRATE_VERSION = "3.6.0";
const CVE_ID = "CVE-2020-11023";
const PROBE_FILE_NAME = "jquery35-test-probe.js";
const PROBE_MARKER = "JQUERY35_RUNTIME_PROBE";
const PAGE_EXTS = [".jsp", ".jspx", ".html", ".htm", ".tag", ".tagx", ".inc", ".xhtml"];
const TEXT_EXTS = PAGE_EXTS.concat([".js", ".css"]);
const EXCLUDE_DIRS = Object.assign(Object.create(null), { ".git": 1, ".svn": 1, ".hg": 1, "node_modules": 1, "target": 1, "build": 1, "dist": 1, ".idea": 1, ".settings": 1 });
const MODES = ["plan", "autofix", "patch-jquery", "probe", "lab", "verify-clean", "pr-report", "packet", "ai-verdict-packet", "review-pack", "hermes-pack", "airgap-manifest", "release-zip", "ui", "self-test"];
const DEFAULT_PATH_VARS = {
"${js}": "/js",
"${css}": "/css",
"${images}": "/images",
"${img}": "/images",
"${context}": "",
"${ctx}": "",
"${pageContext.request.contextPath}": "",
"${request.contextPath}": ""
};
const DEFAULT_VENDOR_PATTERNS = [
"resources/jqgrid/", "jquery-ui", "jquery.ui", "select2", "autonumeric",
"jqgrid", "jquery.jqgrid", "grid.locale", "bootstrap", "jquery.validate",
"jquery-validate", "datepicker", "/plugin/", "/plugins/", "/lib/", "/libs/",
"/vendor/", "/vendors/", "/thirdparty/", "/third-party/"
];
const DEFAULT_APP_HINTS = ["js/util.js", "js/common.js"];
const DEFAULT_IGNORE_ATTR_PATTERNS = ["aria-"];
const DEFAULT_WEB_ROOT_CANDIDATES = ["WebContent", "src/main/webapp", "webapp", "web", "www", "wwwroot", "public"];
const DEFAULT_WEB_ROOT_SIGNALS = ["WEB-INF", "META-INF", "js", "css", "resources", "static", "assets", "WEB-INF/views", "WEB-INF/layouts"];
const DEFAULT_PROBE_HINTS = [
"WEB-INF/layouts/common_script_lib.jsp",
"WEB-INF/layouts/common.jsp",
"WEB-INF/jsp/common_script_lib.jsp",
"WEB-INF/views/common/script.jsp",
"WEB-INF/views/layout/common.jsp"
];
const DEFAULT_SERVER_SCAN = {
enabled: true,
sourceDirs: ["src/main/java", "src", "java"],
includeXml: true,
maxFileBytes: 1024 * 1024
};
const DEFAULT_MOCK_DEFAULTS = {
json: {
result: "OK",
success: true,
mock: true,
message: "jq35 local lab mock response",
rows: [
{ col1: "SAMPLE1", col2: "100", col3: "Y" },
{ col1: "SAMPLE2", col2: "200", col3: "N" }
],
totalCount: 2
},
html: "
MOCK HTML RESPONSE
",
text: "MOCK TEXT RESPONSE"
};
const RUNTIME_VALIDATION_LANES = [
{
key: "CODE_ONLY_STATIC",
label: "1망 code-only/static",
description: "Node + Bitbucket 소스만으로 정적 근거, AS-IS/TO-BE diff, selector/page 매칭, Local Lab/mock 후보를 생성합니다."
},
{
key: "CHROME_SMOKE",
label: "2망 Chrome smoke",
description: "실제 Spring/Tomcat 또는 개발계 화면을 Chrome으로 열어 렌더링, 주요 동작, JSERROR/AJAXERROR, JQMIGRATE 경고를 확인합니다."
},
{
key: "IE_FINAL_SAMPLE",
label: "Edge IE mode final sample",
description: "IE 전용 분기, jqGrid/구형 벤더, ActiveX/object, iframe/popup/file upload 등 고위험 화면만 Edge IE mode에서 최종 샘플링합니다."
}
];
const BOOL_ATTRS = Object.assign(Object.create(null), { disabled: 1, readonly: 1, checked: 1, selected: 1 });
const TAINT_NAMES = Object.create(null);
["response", "responsetext", "result", "resultdata", "data", "html", "content",
"input", "value", "msg", "message", "param", "params", "title", "name",
"formatted", "returnvalue", "doctypeselect", "cardlist", "alter", "altername",
"json", "list", "rows", "body", "text", "resp", "res"].forEach(function (n) { TAINT_NAMES[n] = 1; });
const SKIP_CALLBACK_BASES = Object.assign(Object.create(null), { console: 1, window: 1, Math: 1, JSON: 1, logger: 1, log: 1, alert: 1 });
const PRIORITY_RANK = Object.assign(Object.create(null), {
Critical: 90, XssHigh: 80, Manual: 70, Review: 60,
AutoInferred: 50, AutoFixed: 40, VendorReview: 30, StaticHtmlLow: 20, Ignored: 10
});
function log(msg) { process.stdout.write("[jq35] " + msg + "\n"); }
function warn(msg) { process.stdout.write("[jq35][WARN] " + msg + "\n"); }
function fail(msg) { process.stdout.write("[jq35][FAIL] " + msg + "\n"); }
function ensureDir(p) { fs.mkdirSync(p, { recursive: true }); }
function readLatin1(p) { return fs.readFileSync(p).toString("latin1"); }
function writeLatin1(p, text) { ensureDir(path.dirname(p)); fs.writeFileSync(p, Buffer.from(text, "latin1")); }
function readUtf8(p) { return fs.readFileSync(p, "utf8"); }
function writeUtf8(p, text, bom) { ensureDir(path.dirname(p)); fs.writeFileSync(p, (bom ? "\uFEFF" : "") + text, "utf8"); }
function exists(p) { try { fs.statSync(p); return true; } catch (e) { return false; } }
function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } }
function toPosix(p) { return String(p).split(path.sep).join("/"); }
function trunc(s, n) { s = String(s == null ? "" : s).replace(/[\r\n\t]+/g, " "); return s.length > n ? s.slice(0, n) + "..." : s; }
function uniq(arr) { const seen = {}; const out = []; arr.forEach(function (a) { if (!seen[a]) { seen[a] = 1; out.push(a); } }); return out; }
function positiveIntOpt(raw, def) { const n = parseInt(raw, 10); return (Number.isFinite(n) && n > 0) ? n : def; }
function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function htmlEsc(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); }
function xmlEsc(s) { return htmlEsc(s).replace(/'/g, "'").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); }
function jsonClone(v) { return JSON.parse(JSON.stringify(v)); }
function isPlainObject(v) { return v && typeof v === "object" && !Array.isArray(v); }
function mergeConfig(base, extra) {
if (!isPlainObject(extra)) return base;
Object.keys(extra).forEach(function (k) {
if (Array.isArray(extra[k])) base[k] = extra[k].slice();
else if (isPlainObject(extra[k])) {
if (!isPlainObject(base[k])) base[k] = {};
mergeConfig(base[k], extra[k]);
} else {
base[k] = extra[k];
}
});
return base;
}
function readJsonMaybe(file, required) {
if (!file || !exists(file)) {
if (required) throw new Error("json file not found: " + file);
return null;
}
try {
return JSON.parse(readUtf8(file).replace(/^\uFEFF/, ""));
} catch (e) {
if (required) throw new Error("json parse failed: " + file + " - " + e.message);
warn("json parse failed, skipped: " + file + " - " + e.message);
return null;
}
}
function emptyRulepack() {
return {
webRootCandidates: DEFAULT_WEB_ROOT_CANDIDATES.slice(),
webRootSignals: DEFAULT_WEB_ROOT_SIGNALS.slice(),
pathVariables: Object.assign({}, DEFAULT_PATH_VARS),
vendorPatterns: DEFAULT_VENDOR_PATTERNS.slice(),
appScriptHints: DEFAULT_APP_HINTS.slice(),
ignoreAttrPatterns: DEFAULT_IGNORE_ATTR_PATTERNS.slice(),
probe: { injectTargetHints: DEFAULT_PROBE_HINTS.slice() },
serverScan: jsonClone(DEFAULT_SERVER_SCAN),
mockDefaults: jsonClone(DEFAULT_MOCK_DEFAULTS),
vendorRecommendations: {},
files: []
};
}
function mergeRulepackFile(pack, file, required) {
const raw = readJsonMaybe(file, required);
if (!raw) return;
mergeConfig(pack, raw);
if (pack.files.indexOf(file) < 0) pack.files.push(file);
}
function mergeRulepackDir(pack, dir, required) {
if (!dir || !isDir(dir)) {
if (required) throw new Error("rulepack directory not found: " + dir);
return;
}
["public-defaults.json", "vendor-compat.json", "mock-defaults.json"].forEach(function (name) {
const file = path.join(dir, name);
if (exists(file)) mergeRulepackFile(pack, file, false);
});
}
function loadRulepack(opts, sourceRoot) {
const pack = emptyRulepack();
const defaultDir = path.join(__dirname, "rules");
mergeRulepackDir(pack, defaultDir, false);
let custom = opts.rulepack || "";
if (!custom && sourceRoot && exists(path.join(sourceRoot, "jquery35-rulepack.json"))) custom = path.join(sourceRoot, "jquery35-rulepack.json");
if (!custom && sourceRoot && isDir(path.join(sourceRoot, "jquery35-rulepack"))) custom = path.join(sourceRoot, "jquery35-rulepack");
if (custom) {
const p = path.resolve(custom);
if (isDir(p)) mergeRulepackDir(pack, p, true);
else mergeRulepackFile(pack, p, true);
log("rulepack loaded: " + p);
}
pack.webRootCandidates = uniq((pack.webRootCandidates || []).concat([pack.webContentDir || "WebContent"])).filter(Boolean);
pack.webRootSignals = uniq(pack.webRootSignals || DEFAULT_WEB_ROOT_SIGNALS);
pack.vendorPatterns = uniq(pack.vendorPatterns || DEFAULT_VENDOR_PATTERNS);
pack.appScriptHints = uniq(pack.appScriptHints || DEFAULT_APP_HINTS);
pack.ignoreAttrPatterns = uniq(pack.ignoreAttrPatterns || DEFAULT_IGNORE_ATTR_PATTERNS);
pack.probe = pack.probe || {};
pack.probe.injectTargetHints = uniq(pack.probe.injectTargetHints || DEFAULT_PROBE_HINTS);
pack.mockDefaults = mergeConfig(jsonClone(DEFAULT_MOCK_DEFAULTS), pack.mockDefaults || {});
pack.serverScan = mergeConfig(jsonClone(DEFAULT_SERVER_SCAN), pack.serverScan || {});
return pack;
}
function csvCell(v) {
let s = String(v == null ? "" : v);
if (/[",\r\n]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
return s;
}
function writeCsv(file, header, rows) {
const lines = [header.map(csvCell).join(",")];
rows.forEach(function (r) { lines.push(r.map(csvCell).join(",")); });
writeUtf8(file, lines.join("\r\n") + "\r\n", true);
}
function isUnderDir(child, parent) {
if (!parent) return false;
const c = path.resolve(child).toLowerCase() + path.sep;
const p = path.resolve(parent).toLowerCase() + path.sep;
return c.indexOf(p) === 0;
}
function walkFiles(root, excludeAbs) {
const out = [];
function rec(dir) {
let names;
try { names = fs.readdirSync(dir); } catch (e) { return; }
names.sort();
for (let i = 0; i < names.length; i++) {
const abs = path.join(dir, names[i]);
let st;
try { st = fs.statSync(abs); } catch (e) { continue; }
if (st.isDirectory()) {
if (EXCLUDE_DIRS[names[i].toLowerCase()]) continue;
let skip = false;
for (let k = 0; k < excludeAbs.length; k++) {
if (path.resolve(abs).toLowerCase() === path.resolve(excludeAbs[k]).toLowerCase() || isUnderDir(abs, excludeAbs[k])) { skip = true; break; }
}
if (skip) continue;
rec(abs);
} else {
out.push({ abs: abs, size: st.size });
}
}
}
rec(root);
return out;
}
function lineStartsOf(text) {
const starts = [0];
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
return starts;
}
function lineOf(starts, idx) {
let lo = 0, hi = starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (starts[mid] <= idx) lo = mid; else hi = mid - 1;
}
return lo + 1;
}
function lineTextAt(text, starts, lineNo) {
const s = starts[lineNo - 1];
const e = lineNo < starts.length ? starts[lineNo] : text.length;
return text.slice(s, e).replace(/[\r\n]+$/, "");
}
function detectEol(text) {
const crlf = (text.match(/\r\n/g) || []).length;
const lf = (text.match(/\n/g) || []).length;
return crlf > 0 && crlf * 2 >= lf ? "\r\n" : "\n";
}
function maskJs(src, keepStrings) {
const n = src.length;
const out = new Array(n);
let i = 0;
let lastSig = "";
let lastWord = "";
let prevWasWord = false;
const REGEX_WORDS = { "return": 1, "typeof": 1, "instanceof": 1, "in": 1, "of": 1, "new": 1, "delete": 1, "void": 1, "case": 1, "do": 1, "else": 1, "throw": 1 };
while (i < n) {
const c = src[i];
const d = i + 1 < n ? src[i + 1] : "";
if (c === "/" && d === "/") {
while (i < n && src[i] !== "\n") { out[i] = " "; i++; }
continue;
}
if (c === "/" && d === "*") {
out[i] = " "; out[i + 1] = " "; i += 2;
while (i < n && !(src[i] === "*" && src[i + 1] === "/")) { out[i] = src[i] === "\n" ? "\n" : " "; i++; }
if (i < n) { out[i] = " "; out[i + 1] = " "; i += 2; }
continue;
}
if (c === '"' || c === "'") {
const q = c;
out[i] = q; i++;
while (i < n) {
if (src[i] === "\\" && i + 1 < n) { out[i] = keepStrings ? src[i] : " "; out[i + 1] = keepStrings ? src[i + 1] : " "; i += 2; continue; }
if (src[i] === q) { out[i] = q; i++; break; }
if (src[i] === "\n") { out[i] = "\n"; i++; break; }
out[i] = keepStrings ? src[i] : " "; i++;
}
lastSig = q; prevWasWord = false;
continue;
}
if (c === "`") {
out[i] = "`"; i++;
while (i < n) {
if (src[i] === "\\" && i + 1 < n) { out[i] = " "; out[i + 1] = " "; i += 2; continue; }
if (src[i] === "`") { out[i] = "`"; i++; break; }
out[i] = src[i] === "\n" ? "\n" : (keepStrings ? src[i] : " "); i++;
}
lastSig = "`"; prevWasWord = false;
continue;
}
if (c === "/") {
let regexOk = false;
if (lastSig === "") regexOk = true;
else if ("(,=:[!&|?{};+-*%~^<>".indexOf(lastSig) >= 0) regexOk = true;
else if (/[A-Za-z0-9_$]/.test(lastSig) && REGEX_WORDS[lastWord]) regexOk = true;
if (regexOk) {
out[i] = "/"; i++;
let inClass = false;
while (i < n) {
if (src[i] === "\\" && i + 1 < n) { out[i] = " "; out[i + 1] = " "; i += 2; continue; }
if (src[i] === "[") { inClass = true; out[i] = " "; i++; continue; }
if (src[i] === "]") { inClass = false; out[i] = " "; i++; continue; }
if (src[i] === "/" && !inClass) { out[i] = "/"; i++; break; }
if (src[i] === "\n") { out[i] = "\n"; i++; break; }
out[i] = " "; i++;
}
while (i < n && /[a-z]/i.test(src[i])) { out[i] = src[i]; i++; }
lastSig = "/"; prevWasWord = false;
continue;
}
}
out[i] = c;
if (/\s/.test(c)) {
prevWasWord = false;
} else {
lastSig = c;
if (/[A-Za-z0-9_$]/.test(c)) {
lastWord = prevWasWord ? lastWord + c : c;
prevWasWord = true;
} else {
lastWord = "";
prevWasWord = false;
}
}
i++;
}
return out.join("");
}
function matchParen(masked, openIdx) {
let depth = 0;
for (let i = openIdx; i < masked.length; i++) {
const c = masked[i];
if (c === "(") depth++;
else if (c === ")") { depth--; if (depth === 0) return i; }
}
return -1;
}
function matchBrace(masked, openIdx) {
let depth = 0;
for (let i = openIdx; i < masked.length; i++) {
const c = masked[i];
if (c === "{") depth++;
else if (c === "}") { depth--; if (depth === 0) return i; }
}
return -1;
}
function splitTopArgs(masked, start, end) {
const parts = [];
let depth = 0;
let s = start;
for (let i = start; i < end; i++) {
const c = masked[i];
if (c === "(" || c === "[" || c === "{") depth++;
else if (c === ")" || c === "]" || c === "}") depth--;
else if (c === "," && depth === 0) { parts.push({ s: s, e: i }); s = i + 1; }
}
if (end > s || parts.length > 0) parts.push({ s: s, e: end });
if (parts.length === 1 && masked.slice(parts[0].s, parts[0].e).trim() === "") return [];
return parts;
}
function receiverInfo(masked, orig, dotIdx) {
let i = dotIdx - 1;
let end = -1;
let guard = 0;
while (i >= 0 && guard++ < 600) {
while (i >= 0 && /\s/.test(masked[i])) i--;
if (i < 0) break;
if (end < 0) end = i + 1;
const c = masked[i];
if (c === ")" || c === "]") {
let depth = 0;
while (i >= 0) {
const ch = masked[i];
if (ch === ")" || ch === "]") depth++;
else if (ch === "(" || ch === "[") { depth--; if (depth === 0) break; }
i--;
}
if (i < 0) break;
i--;
continue;
}
if (/[A-Za-z0-9_$]/.test(c)) {
const e2 = i;
while (i >= 0 && /[A-Za-z0-9_$]/.test(masked[i])) i--;
const base = masked.slice(i + 1, e2 + 1);
let j = i;
while (j >= 0 && /\s/.test(masked[j])) j--;
if (j >= 0 && masked[j] === ".") { i = j - 1; continue; }
const start = i + 1;
return { base: base, start: start, end: end, text: orig.slice(start, end) };
}
if (c === "}") return { base: "}", start: i, end: end, text: "" };
return { base: "", start: i + 1, end: end, text: orig.slice(i + 1, end) };
}
return { base: "", start: Math.max(0, dotIdx), end: dotIdx, text: "" };
}
function isJqReceiver(info) {
if (!info) return false;
if (info.base === "$" || info.base === "jQuery") return true;
if (info.base && info.base.charAt(0) === "$" && info.base.length > 1) return true;
return false;
}
function isWindowJq(info) {
return /^(\$|jQuery)\s*\(\s*(window|top|self)\s*\)$/.test(String(info.text || "").trim());
}
function versionParts(v) {
return String(v || "").split(".").map(function (x) { const n = parseInt(x, 10); return isNaN(n) ? 0 : n; });
}
function versionLt(a, b) {
const pa = versionParts(a), pb = versionParts(b);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const x = pa[i] || 0, y = pb[i] || 0;
if (x < y) return true;
if (x > y) return false;
}
return false;
}
function versionFromName(name) {
const m = String(name).match(/(\d+(?:\.\d+){1,3})/);
return m ? m[1] : "";
}
function sniffJqueryVersion(absPath) {
try {
const fd = fs.openSync(absPath, "r");
const buf = Buffer.alloc(4096);
const n = fs.readSync(fd, buf, 0, 4096, 0);
fs.closeSync(fd);
const head = buf.slice(0, n).toString("latin1");
let m = head.match(/jQuery\s+(?:JavaScript Library\s+)?v(\d+(?:\.\d+){1,3})/i);
if (m) return m[1];
m = head.match(/jquery:\s*["'](\d+(?:\.\d+){1,3})["']/i);
if (m) return m[1];
m = head.match(/fn\.jquery\s*=\s*["'](\d+(?:\.\d+){1,3})["']/i);
if (m) return m[1];
return "";
} catch (e) { return ""; }
}
function fileNameOf(ref) {
const q = String(ref).split(/[?#]/)[0];
const idx = q.lastIndexOf("/");
return (idx >= 0 ? q.slice(idx + 1) : q).toLowerCase();
}
function isJqueryCoreName(name) {
if (/jquery[-.]migrate/.test(name)) return false;
if (/jquery[-.]ui/.test(name)) return false;
return /^jquery([-.]?\d[\d.]*)?(\.slim)?(\.min)?\.js$/.test(name);
}
function isMigrateName(name) {
return /jquery[-.]migrate/.test(name) && /\.js$/.test(name);
}
function classifyLib(rel, profile) {
const relLower = toPosix(rel).toLowerCase();
const name = fileNameOf(relLower);
if (name === PROBE_FILE_NAME) return "probe";
if (isMigrateName(name)) return "jquery-migrate";
if (/jquery[-.]ui/.test(name) || /jquery[-.]ui/.test(relLower)) return "jquery-ui";
if (/jqgrid|jquery\.jqgrid|grid\.locale/.test(name) || /\/jqgrid\//.test(relLower)) return "jqgrid";
if (/select2/.test(name)) return "select2";
if (/autonumeric/.test(name)) return "autoNumeric";
if (/bootstrap/.test(name)) return "bootstrap";
if (/jquery[-.]validat/.test(name)) return "jquery-validate";
if (/datepicker/.test(name)) return "datepicker";
if (isJqueryCoreName(name)) return "jquery-core";
for (let i = 0; i < profile.appScriptHints.length; i++) {
if (relLower.indexOf(profile.appScriptHints[i].toLowerCase()) >= 0) return "app";
}
for (let i = 0; i < profile.vendorPatterns.length; i++) {
if (relLower.indexOf(profile.vendorPatterns[i].toLowerCase()) >= 0) return "vendor-other";
}
if (/\.min\.js$/.test(name) || /\.min\.css$/.test(name)) return "vendor-other";
return "app";
}
function isVendorLib(lib) {
return lib === "jquery-ui" || lib === "jqgrid" || lib === "select2" || lib === "autoNumeric" ||
lib === "bootstrap" || lib === "jquery-validate" || lib === "datepicker" || lib === "vendor-other";
}
function isMinifiedFile(rel, text) {
if (/\.min\.(js|css)$/i.test(rel)) return true;
if (!text) return false;
const sample = text.slice(0, 20000);
const lines = sample.split("\n");
let maxLen = 0;
for (let i = 0; i < lines.length; i++) if (lines[i].length > maxLen) maxLen = lines[i].length;
return maxLen > 3000;
}
function defaultProfile(rulepack) {
rulepack = rulepack || emptyRulepack();
return {
webContentDir: "WebContent",
webRootCandidates: (rulepack.webRootCandidates || DEFAULT_WEB_ROOT_CANDIDATES).slice(),
webRootSignals: (rulepack.webRootSignals || DEFAULT_WEB_ROOT_SIGNALS).slice(),
pathVariables: Object.assign({}, rulepack.pathVariables || DEFAULT_PATH_VARS),
vendorPatterns: (rulepack.vendorPatterns || DEFAULT_VENDOR_PATTERNS).slice(),
vendorRecommendations: Object.assign({}, rulepack.vendorRecommendations || {}),
appScriptHints: (rulepack.appScriptHints || DEFAULT_APP_HINTS).slice(),
ignoreAttrPatterns: (rulepack.ignoreAttrPatterns || DEFAULT_IGNORE_ATTR_PATTERNS).slice(),
jquery: {
targetVersion: DEFAULT_JQUERY_VERSION,
migrateVersion: DEFAULT_MIGRATE_VERSION,
coreFile: "jquery-" + DEFAULT_JQUERY_VERSION + ".min.js",
migrateFile: "jquery-migrate-" + DEFAULT_MIGRATE_VERSION + ".min.js",
newJquerySrc: "",
newMigrateSrc: "",
migrateTrace: false
},
probe: {
enabled: true,
injectTargetHints: ((rulepack.probe && rulepack.probe.injectTargetHints) || DEFAULT_PROBE_HINTS).slice()
},
serverScan: mergeConfig(jsonClone(DEFAULT_SERVER_SCAN), rulepack.serverScan || {}),
mockDefaults: mergeConfig(jsonClone(DEFAULT_MOCK_DEFAULTS), rulepack.mockDefaults || {}),
rulepackFiles: (rulepack.files || []).slice(),
learnedWrappers: [],
learnedFindings: [],
sensitiveIdentifiers: []
};
}
function loadProfile(opts, sourceRoot) {
const rulepack = loadRulepack(opts, sourceRoot);
const prof = defaultProfile(rulepack);
let profPath = "";
if (opts.profile) profPath = opts.profile;
else if (sourceRoot && exists(path.join(sourceRoot, "project-profile.json"))) profPath = path.join(sourceRoot, "project-profile.json");
else if (exists(path.join(process.cwd(), "project-profile.json"))) profPath = path.join(process.cwd(), "project-profile.json");
if (profPath && exists(profPath)) {
try {
const raw = JSON.parse(readUtf8(profPath).replace(/^\uFEFF/, ""));
if (raw.webContentDir) prof.webContentDir = raw.webContentDir;
if (Array.isArray(raw.webRootCandidates)) prof.webRootCandidates = raw.webRootCandidates;
if (Array.isArray(raw.webRootSignals)) prof.webRootSignals = raw.webRootSignals;
if (raw.pathVariables) Object.assign(prof.pathVariables, raw.pathVariables);
if (Array.isArray(raw.vendorPatterns)) prof.vendorPatterns = raw.vendorPatterns;
if (raw.vendorRecommendations) Object.assign(prof.vendorRecommendations, raw.vendorRecommendations);
if (Array.isArray(raw.appScriptHints)) prof.appScriptHints = raw.appScriptHints;
if (Array.isArray(raw.ignoreAttrPatterns)) prof.ignoreAttrPatterns = raw.ignoreAttrPatterns;
if (raw.jquery) Object.assign(prof.jquery, raw.jquery);
if (raw.probe) Object.assign(prof.probe, raw.probe);
if (raw.serverScan) Object.assign(prof.serverScan, raw.serverScan);
if (raw.mockDefaults) prof.mockDefaults = mergeConfig(prof.mockDefaults, raw.mockDefaults);
if (Array.isArray(raw.learnedWrappers)) prof.learnedWrappers = prof.learnedWrappers.concat(raw.learnedWrappers);
if (Array.isArray(raw.learnedFindings)) prof.learnedFindings = prof.learnedFindings.concat(raw.learnedFindings);
if (Array.isArray(raw.sensitiveIdentifiers)) prof.sensitiveIdentifiers = raw.sensitiveIdentifiers;
log("profile loaded: " + profPath);
} catch (e) {
warn("profile parse failed, using defaults: " + e.message);
}
}
if (opts["jquery-version"]) {
prof.jquery.targetVersion = opts["jquery-version"];
prof.jquery.coreFile = "jquery-" + opts["jquery-version"] + ".min.js";
}
if (opts["migrate-version"]) {
prof.jquery.migrateVersion = opts["migrate-version"];
prof.jquery.migrateFile = "jquery-migrate-" + opts["migrate-version"] + ".min.js";
}
if (opts["migrate-trace"]) prof.jquery.migrateTrace = true;
if (opts["server-source"]) prof.serverScan.sourceOverride = opts["server-source"];
if (opts["no-server-scan"]) prof.serverScan.enabled = false;
prof.appScriptHints = prof.appScriptHints.map(function (s) { return toPosix(s).toLowerCase(); });
prof.webRootCandidates = uniq([prof.webContentDir].concat(prof.webRootCandidates || [])).filter(Boolean);
prof.webRootSignals = uniq(prof.webRootSignals || DEFAULT_WEB_ROOT_SIGNALS);
prof.vendorPatterns = uniq(prof.vendorPatterns || DEFAULT_VENDOR_PATTERNS);
prof.ignoreAttrPatterns = uniq(prof.ignoreAttrPatterns || DEFAULT_IGNORE_ATTR_PATTERNS);
return prof;
}
const BOOL_FLAGS = { "audit-only": 1, "inject-probe": 1, "patch-jquery": 1, "migrate-trace": 1, "no-lab": 1, "no-server-scan": 1, "self-test": 1, "include-snippets": 1, "warn-as-error": 1, "help": 1 };
function parseArgs(argv) {
const opts = { _: [] };
let i = 0;
while (i < argv.length) {
let a = argv[i];
if (a.slice(0, 2) === "--") {
const key = a.slice(2);
const next = argv[i + 1];
if (key === "safe-packet") {
if (next !== undefined && next.slice(0, 2) !== "--") { opts[key] = String(next).toLowerCase() !== "false"; i += 2; }
else { opts[key] = true; i++; }
} else if (BOOL_FLAGS[key]) {
opts[key] = true; i++;
} else if (next !== undefined && next.slice(0, 2) !== "--") {
opts[key] = next; i += 2;
} else {
opts[key] = true; i++;
}
} else {
opts._.push(a); i++;
}
}
if (opts["safe-packet"] === undefined) opts["safe-packet"] = true;
if (opts["max-packet-lines"] === undefined) opts["max-packet-lines"] = "400";
if (opts["ai-packet-chars"] === undefined) opts["ai-packet-chars"] = "1000";
return opts;
}
function helpText() {
return [
TOOL_NAME + " v" + TOOL_VERSION + " - jQuery " + CVE_ID + " remediation kit (offline, Node.js built-in only)",
"",
"Usage:",
" node run-jquery35-v5.js --source [--target ] --report --mode [options]",
"",
"Modes:",
" plan analyze only, write full report (no target write)",
" autofix copy source to target, apply safe auto fixes, write report",
" patch-jquery autofix + replace old jQuery core script tags with 3.x + Migrate",
" probe autofix + generate runtime probe js + inject into layout jsp",
" lab analyze + start local mock lab http server (--port, default 18080)",
" verify-clean pre-release gate: fail on old jQuery / probe leftovers / criticals",
" pr-report generate pr_description.md, bamboo_checklist.md, recommended_commits.txt",
" packet analyze + write assistant_packet.txt / voyager_packet.txt / chat_summary.txt only",
" ai-verdict-packet analyze + write <=1000 char AI verdict packet and evidence ledger",
" review-pack analyze + write ai_review_pack.txt/json: a bounded, redacted",
" questionnaire over the most ambiguous/high-leverage code spots,",
" meant to be copy-pasted to an external AI and iterated on",
" hermes-pack review-pack + local verification plan/matrix/profile templates",
" for air-gapped review without network calls",
" airgap-manifest analyze + write airgap_manifest.json/txt only",
" release-zip create public distribution zip from this tool directory",
" ui start local browser dashboard for running modes",
" self-test build a sample project in temp dir and validate the tool end-to-end",
"",
"Options:",
" --source project root or WebContent dir (auto-detected)",
" --target TO-BE output dir (never writes into source)",
" --report report output dir",
" --mode see modes above (default: plan)",
" --port lab server port (default 18080)",
" ui mode: dashboard port (default 18088)",
" --lab-port ui mode: child lab server port (default 18080)",
" --profile project-profile.json path (also carries learnedWrappers/",
" learnedFindings from a previous review-pack round)",
" --rulepack override public defaults/vendor/mock rules",
" --server-source optional Java/Spring source root for server evidence",
" --no-server-scan skip Java/Spring static evidence extraction",
" --jquery-version default " + DEFAULT_JQUERY_VERSION,
" --migrate-version default " + DEFAULT_MIGRATE_VERSION,
" --inject-probe also inject probe in autofix/patch-jquery mode",
" --patch-jquery also swap jQuery core in autofix mode",
" --migrate-trace patch-jquery: insert jQuery.migrateTrace=true",
" and migrateMute=false directly after Migrate",
" --audit-only alias of --mode plan",
" --safe-packet [true|false] exclude code snippets from assistant_packet (default true)",
" --include-snippets include short snippets in packet (overrides safe-packet)",
" --max-packet-lines packet line cap (default 400)",
" --ai-packet-chars ai-verdict-packet hard char cap (default 1000)",
" --max-review-cases review-pack: max distinct cases per round (default 20)",
" --context-lines review-pack: excerpt lines shown before/after (default 1)",
" --max-review-lines review-pack: ai_review_pack.txt line cap (default 300)",
" --no-lab skip mock_routes.json / mock_data_default.json generation",
" --warn-as-error verify-clean returns exit 1 on WARN",
" --help this help",
""
].join("\n");
}
function detectWebContent(sourceRoot, profile) {
const candidates = uniq([profile.webContentDir].concat(profile.webRootCandidates || DEFAULT_WEB_ROOT_CANDIDATES)).filter(Boolean);
let best = { path: "", score: -1 };
function scoreRoot(abs) {
if (!isDir(abs)) return -1;
let score = 0;
(profile.webRootSignals || DEFAULT_WEB_ROOT_SIGNALS).forEach(function (d) {
if (isDir(path.join(abs, d))) score += d.indexOf("/") >= 0 ? 3 : 1;
});
if (isDir(path.join(abs, "WEB-INF"))) score += 5;
if (exists(path.join(abs, "WEB-INF", "web.xml"))) score += 3;
if (isDir(path.join(abs, "js")) || isDir(path.join(abs, "static"))) score += 1;
return score;
}
candidates.forEach(function (rel) {
const abs = rel === "." ? sourceRoot : path.join(sourceRoot, rel);
const score = scoreRoot(abs);
if (score > best.score) best = { path: abs, score: score };
});
const ownScore = scoreRoot(sourceRoot);
if (ownScore > best.score) best = { path: sourceRoot, score: ownScore };
if (best.path && best.score >= 1) return best.path;
const own = profile.webRootSignals || DEFAULT_WEB_ROOT_SIGNALS;
let hit = 0;
own.forEach(function (d) { if (isDir(path.join(sourceRoot, d))) hit++; });
if (hit >= 1 && isDir(path.join(sourceRoot, "WEB-INF"))) return sourceRoot;
if (hit >= 2) return sourceRoot;
const names = isDir(sourceRoot) ? fs.readdirSync(sourceRoot) : [];
for (let i = 0; i < names.length; i++) {
const c = path.join(sourceRoot, names[i]);
if (isDir(c) && scoreRoot(c) >= 3) return c;
}
return "";
}
function gitInfo(sourceRoot) {
const info = { available: false, root: "", branch: "", changed: [], untracked: [] };
try {
const root = cp.execSync("git rev-parse --show-toplevel", { cwd: sourceRoot, stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }).toString("utf8").trim();
if (!root) return info;
info.available = true;
info.root = root;
try { info.branch = cp.execSync("git rev-parse --abbrev-ref HEAD", { cwd: sourceRoot, stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }).toString("utf8").trim(); } catch (e) { }
try {
const st = cp.execSync("git status --porcelain", { cwd: sourceRoot, stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }).toString("utf8");
st.split(/\r?\n/).forEach(function (ln) {
if (!ln.trim()) return;
const code = ln.slice(0, 2);
const f = ln.slice(3).trim();
if (code === "??") info.untracked.push(f); else info.changed.push(f);
});
} catch (e) { }
} catch (e) { }
return info;
}
function applyPathVars(ref, profile) {
let out = String(ref);
const keys = Object.keys(profile.pathVariables).sort(function (a, b) { return b.length - a.length; });
keys.forEach(function (k) {
while (out.indexOf(k) >= 0) out = out.split(k).join(profile.pathVariables[k]);
});
return out;
}
function normalizeWcPath(p) {
const parts = toPosix(p).split("/");
const out = [];
for (let i = 0; i < parts.length; i++) {
const seg = parts[i];
if (seg === "" || seg === ".") continue;
if (seg === "..") { out.pop(); continue; }
out.push(seg);
}
return out.join("/");
}
function resolveRef(rawRef, pageRel, model) {
const r = { raw: rawRef, resolved: "", exists: false, reason: "" };
let ref = String(rawRef).trim();
if (!ref) { r.reason = "empty"; return r; }
if (/^(https?:)?\/\//i.test(ref)) { r.reason = "external-url"; r.resolved = ref; return r; }
if (/^(javascript:|data:|#|mailto:)/i.test(ref)) { r.reason = "non-file"; return r; }
ref = ref.split(/[?#]/)[0];
let sub = applyPathVars(ref, model.profile);
if (/<%[^%]*%>/.test(sub)) { r.reason = "jsp-expression"; r.resolved = sub; return r; }
if (/\$\{[^}]*\}/.test(sub)) { r.reason = "unknown-el-variable"; r.resolved = sub; return r; }
let wcRel;
if (sub.charAt(0) === "/") wcRel = normalizeWcPath(sub);
else wcRel = normalizeWcPath(toPosix(path.posix.dirname(toPosix(pageRel))) + "/" + sub);
r.resolved = wcRel;
if (model.fileIndex[wcRel.toLowerCase()]) { r.exists = true; }
else r.reason = "file-not-found";
return r;
}
function scriptSrcInfo(tag, tagStart) {
const m = /\bsrc\s*=\s*(["'])([\s\S]*?)\1/i.exec(tag);
if (!m) return null;
const quoteAt = m[0].indexOf(m[1]);
const srcStart = tagStart + m.index + quoteAt + 1;
return { raw: m[2], srcStart: srcStart, srcEnd: srcStart + m[2].length };
}
function blankPreserveLines(s) {
return String(s || "").replace(/[^\r\n]/g, " ");
}
function stripMarkupText(s) {
return String(s || "")
.replace(/<%--[\s\S]*?--%>/g, " ")
.replace(/<%[\s\S]*?%>/g, " ")
.replace(/\$\{[^}]*\}/g, " ")
.replace(/= 0 ? closeIdx : text.length;
const typeM = tag.match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
const type = (typeM ? (typeM[1] || typeM[2] || "") : "").toLowerCase();
const isJs = !type || type.indexOf("javascript") >= 0 || type === "module";
if (isJs && end > tagEnd) refs.inlineRegions.push({ start: tagEnd, end: end });
if (closeIdx >= 0) scriptOpenRe.lastIndex = closeIdx;
}
}
const linkRe = /]*>/gi;
while ((m = linkRe.exec(text)) !== null) {
const tag = m[0];
const hrefM = tag.match(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
if (!hrefM) continue;
const href = hrefM[1] !== undefined ? hrefM[1] : hrefM[2];
const isCss = /rel\s*=\s*["']?stylesheet/i.test(tag) || /\.css(\?|$)/i.test(href);
if (isCss) refs.css.push({ raw: href, idx: m.index });
}
const incRes = [
{ re: /<%@\s*include\s+file\s*=\s*(?:"([^"]*)"|'([^']*)')/gi, type: "static-include" },
{ re: /]*>/gi;
while ((m = tilesRe.exec(text)) !== null) {
refs.includes.push({ raw: m[0].slice(0, 120), idx: m.index, type: "tiles", unresolvable: true });
}
refs.elements = collectUiElements(ctx);
return refs;
}
function scriptRefMeta(rawRef, resolved, model) {
const name = fileNameOf(resolved && resolved.resolved ? resolved.resolved : rawRef);
const lib = classifyLib(resolved && resolved.resolved ? resolved.resolved : rawRef, model.profile);
let ver = versionFromName(name);
const isCore = isJqueryCoreName(name);
const isMig = isMigrateName(name);
if (isCore && !ver && resolved && resolved.exists) {
const abs = model.fileIndex[resolved.resolved.toLowerCase()];
if (abs) ver = sniffJqueryVersion(abs);
}
const isOld = isCore && ver && versionLt(ver, TARGET_JQUERY_FLOOR_VERSION);
return { name: name, lib: isCore ? "jquery-core" : lib, ver: ver, isCore: isCore, isMigrate: isMig, isOld: !!isOld };
}
function addFinding(model, ctx, o) {
const f = {
abs: ctx.abs,
rel: ctx.rel,
projRel: ctx.projRel,
line: o.line || (o.idx !== undefined ? lineOf(ctx.lineStarts, o.idx) : 0),
category: o.category,
pattern: o.pattern || "",
priority: o.priority,
confidence: o.confidence || "Medium",
action: o.action || "ReviewOnly",
before: trunc(o.before || "", 220),
after: trunc(o.after || "", 220),
reason: trunc(o.reason || "", 300),
lib: ctx.lib,
thirdParty: ctx.isVendor ? "Y" : "N",
commitGroup: o.commitGroup || "UNKNOWN",
suggestion: o.suggestion || "",
editStart: o.editStart,
editEnd: o.editEnd,
replacement: o.replacement,
pending: o.pending || null,
idx: o.idx
};
if (ctx.isVendor && f.priority !== "Critical") {
if (f.action === "Changed") {
f.action = "ReviewOnly";
f.editStart = undefined; f.editEnd = undefined; f.replacement = undefined;
}
if (f.priority !== "Ignored") f.priority = "VendorReview";
f.commitGroup = "VENDOR_REVIEW";
f.pending = null;
}
ctx.findings.push(f);
model.findings.push(f);
return f;
}
function isSafeWrapperCall(masked, ctx) {
const model = ctx && ctx.model;
if (!model || !model.wrapperNames || model.wrapperNames.length === 0) return false;
const t = masked.trim();
for (let i = 0; i < model.wrapperNames.length; i++) {
const name = model.wrapperNames[i];
if (!model.safeWrapperNames[name]) continue;
const m = t.match(new RegExp("^" + escapeRe(name) + "\\s*\\("));
if (!m) continue;
const openIdx = m[0].length - 1;
const closeIdx = matchParen(t, openIdx);
if (closeIdx === t.length - 1) return true;
}
return false;
}
function classifySinkArg(origArg, maskedArg, ctx) {
const orig = origArg.trim();
const masked = maskedArg;
if (!orig) return { kind: "empty" };
if (/\$\{[^}]*\}/.test(orig) || /<%=/.test(orig)) return { kind: "xss", why: "server-side EL/JSP expression concatenated into HTML" };
if (isSafeWrapperCall(masked, ctx)) return { kind: "static", why: "wrapped by a learned safe helper function" };
const ids = masked.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [];
const realIds = ids.filter(function (x) { return !/^(true|false|null|undefined|new|function|this|typeof|var|let|const|return|if|else)$/.test(x); });
if (realIds.length === 0) {
if (/^[`'"]/.test(orig) || /^\(/.test(orig) || /^[+\s'"()`\[\]0-9.,-]*$/.test(masked.trim())) return { kind: "static" };
return { kind: "static" };
}
for (let i = 0; i < realIds.length; i++) {
if (ctx.taint && ctx.taint[realIds[i]]) return { kind: "xss", why: "argument uses ajax callback parameter '" + realIds[i] + "'" };
}
for (let i = 0; i < realIds.length; i++) {
if (TAINT_NAMES[realIds[i].toLowerCase()]) return { kind: "xss", why: "identifier '" + realIds[i] + "' looks like server/business data" };
}
const hasConcat = /\+/.test(masked);
const hasHtmlLiteral = /['"`][^'"`]*/g;
function selfClosedHits(str) {
const hits = [];
SELF_CLOSED_RE.lastIndex = 0;
let m;
while ((m = SELF_CLOSED_RE.exec(str)) !== null) {
if (!VOID_TAGS[m[1].toLowerCase()]) hits.push(m[1]);
}
return hits;
}
function expandSelfClosed(str) {
return str.replace(SELF_CLOSED_RE, function (whole, tag) {
return VOID_TAGS[tag.toLowerCase()] ? whole : "<" + tag + ">" + tag + ">";
});
}
function checkSelfClosedArgs(model, ctx, absFn, args, methodLabel) {
args.forEach(function (a) {
const hits = selfClosedHits(a.orig);
if (hits.length === 0) return;
const inner = a.orig.replace(/^['"`]|['"`]$/g, "").trim();
if (/^<[a-zA-Z][a-zA-Z0-9-]*\s*\/>$/.test(inner)) return;
const ids = (a.masked.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || []).filter(function (x) { return !/^(true|false|null|undefined)$/.test(x); });
const pureLiteral = ids.length === 0 && /^["']/.test(a.orig) && !/`/.test(a.orig);
if (pureLiteral) {
addFinding(model, ctx, {
idx: absFn(a.s), category: "self-closed-tag", pattern: methodLabel + " <" + hits[0] + "/>",
priority: "AutoFixed", confidence: "High", action: "Changed",
before: trunc(a.orig, 160), after: trunc(expandSelfClosed(a.orig), 160),
reason: "jQuery 3.5 security fix (" + CVE_ID + ") stopped auto-expanding self-closed tags in HTML strings; explicit closing tags keep identical behavior on both old and new jQuery (Migrate does not restore this by default)",
commitGroup: "AUTO_SAFE",
editStart: absFn(a.s), editEnd: absFn(a.e), replacement: expandSelfClosed(a.rawOrig)
});
} else {
addFinding(model, ctx, {
idx: absFn(a.s), category: "self-closed-tag", pattern: methodLabel + " <" + hits[0] + "/>",
priority: "Review", confidence: "Medium", action: "ReviewOnly",
before: trunc(a.orig, 160),
reason: "self-closed non-void tag <" + hits[0] + "/> inside dynamically built HTML: since jQuery 3.5 it is no longer expanded to <" + hits[0] + ">" + hits[0] + ">, so following siblings become children; rewrite with explicit closing tags",
suggestion: "write <" + hits[0] + ">" + hits[0] + "> explicitly",
commitGroup: "AUTO_SAFE"
});
}
});
}
function collectTaint(ctx, masked, base) {
const res = [
/success\s*:\s*function\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)/g,
/\.\s*(?:done|then|always|fail)\s*\(\s*function\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)/g,
/\$\.(?:get|post|getJSON)\s*\([^()]{0,200}function\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)/g
];
res.forEach(function (re) {
let m;
while ((m = re.exec(masked)) !== null) {
const nm = m[1];
if (nm && nm.length > 1) ctx.taint[nm] = true;
}
});
}
function collectWrapperTaint(model, ctx, masked, base) {
if (!model.wrapperNames || model.wrapperNames.length === 0) return;
model.wrapperNames.forEach(function (name) {
const rule = model.wrapperRules[name];
if (!rule || rule.role !== "ajaxSuccessJson") return;
if (masked.indexOf(name) < 0) return;
const re = rule.callRe;
re.lastIndex = 0;
let m;
while ((m = re.exec(masked)) !== null) {
const parenIdx = m.index + m[0].length - 1;
const closeIdx = matchParen(masked, parenIdx);
if (closeIdx < 0) continue;
const spans = splitTopArgs(masked, parenIdx + 1, closeIdx);
const pIdx = typeof rule.calleeParamIndex === "number" ? rule.calleeParamIndex : spans.length - 1;
if (pIdx < 0 || pIdx >= spans.length) continue;
const argMasked = masked.slice(spans[pIdx].s, spans[pIdx].e).trim();
const fm = argMasked.match(/^function\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)/);
if (fm) ctx.taint[fm[1]] = true;
}
});
}
function collectDefs(model, ctx, masked, base) {
const res = [
/function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^)]*)\)\s*\{/g,
/([A-Za-z_$][A-Za-z0-9_$]*)\s*[:=]\s*function\s*\(([^)]*)\)\s*\{/g
];
res.forEach(function (re, ri) {
let m;
while ((m = re.exec(masked)) !== null) {
const name = m[1];
const params = m[2].split(",").map(function (s) { return s.trim(); }).filter(Boolean);
const braceLocal = masked.indexOf("{", m.index + m[0].length - 1);
if (braceLocal < 0) continue;
const endLocal = matchBrace(masked, braceLocal);
if (endLocal < 0) continue;
const def = {
name: name, params: params, ctx: ctx,
bodyStart: base + braceLocal, bodyEnd: base + endLocal,
defIdx: base + m.index, form: ri === 0 ? "decl" : "assign"
};
if (!model.defs[name]) model.defs[name] = [];
model.defs[name].push(def);
}
});
}
function premaskRegion(model, ctx, start, end) {
const orig = ctx.text.slice(start, end);
if (!orig.trim()) return null;
const masked = maskJs(orig, false);
const region = { start: start, end: end, masked: masked, orig: orig };
ctx.regions.push(region);
collectTaint(ctx, masked, start);
collectWrapperTaint(model, ctx, masked, start);
collectDefs(model, ctx, masked, start);
return region;
}
function scanRegionFindings(model, ctx, region) {
const orig = region.orig;
const masked = region.masked;
const start = region.start;
const O = function (s, e) { return orig.slice(s, e); };
const abs = function (i) { return start + i; };
const callRe = /\.\s*(bind|unbind|delegate|undelegate|size|load|attr|removeAttr|success|error|complete|live|die|html|append|prepend|before|after|replaceWith|andSelf)\s*\(/g;
let m;
while ((m = callRe.exec(masked)) !== null) {
const name = m[1];
const dotIdx = m.index;
const nameIdx = m.index + m[0].indexOf(name);
const parenIdx = m.index + m[0].length - 1;
const closeIdx = matchParen(masked, parenIdx);
if (closeIdx < 0) continue;
const argSpans = splitTopArgs(masked, parenIdx + 1, closeIdx);
const args = argSpans.map(function (sp) {
return { orig: O(sp.s, sp.e).trim(), rawOrig: O(sp.s, sp.e), masked: masked.slice(sp.s, sp.e).trim(), s: sp.s, e: sp.e };
});
const recv = receiverInfo(masked, orig, dotIdx);
const jq = isJqReceiver(recv);
const callText = trunc((recv.text ? recv.text : "") + O(dotIdx, closeIdx + 1), 200);
const fIdx = abs(nameIdx);
if (name === "bind" || name === "unbind") {
if (recv.base === "}" ) continue;
if (args.length >= 1 && (args[0].masked === "this" || /^this\s*,/.test(args[0].masked) || args[0].masked === "null")) continue;
if (args.length === 0 && !jq && name === "bind") continue;
const newName = name === "bind" ? "on" : "off";
if (jq) {
addFinding(model, ctx, {
idx: fIdx, category: name + "-to-" + newName, pattern: "." + name + "(",
priority: "AutoFixed", confidence: "High", action: "Changed",
before: callText, after: "." + newName + "(...)",
reason: "jQuery ." + name + "() deprecated, replaced with ." + newName + "()",
commitGroup: "AUTO_SAFE",
editStart: fIdx, editEnd: fIdx + name.length, replacement: newName
});
} else {
addFinding(model, ctx, {
idx: fIdx, category: name + "-to-" + newName, pattern: "." + name + "(",
priority: "Review", confidence: "Low", action: "ReviewOnly",
before: callText,
reason: "receiver '" + trunc(recv.base || recv.text, 40) + "' not confirmed as jQuery object; Function.prototype.bind must not be changed",
suggestion: "if jQuery object: ." + newName + "(...)",
commitGroup: "AUTO_SAFE"
});
}
continue;
}
if (name === "delegate" || name === "undelegate") {
const newName = name === "delegate" ? "on" : "off";
if (jq && args.length === 3) {
const rep = "." + newName + "(" + args[1].orig + ", " + args[0].orig + ", " + args[2].orig + ")";
addFinding(model, ctx, {
idx: fIdx, category: name + "-to-" + newName, pattern: "." + name + "(",
priority: "AutoFixed", confidence: "High", action: "Changed",
before: callText, after: rep,
reason: "." + name + "(selector,event,handler) rewritten to ." + newName + "(event,selector,handler)",
commitGroup: "AUTO_SAFE",
editStart: fIdx, editEnd: abs(closeIdx) + 1, replacement: rep.slice(1)
});
} else {
addFinding(model, ctx, {
idx: fIdx, category: name + "-to-" + newName, pattern: "." + name + "(",
priority: "Review", confidence: "Low", action: "ReviewOnly",
before: callText,
reason: args.length !== 3 ? "argument count " + args.length + " not a simple 3-arg pattern" : "receiver not confirmed as jQuery object",
suggestion: "." + newName + "(event, selector, handler)",
commitGroup: "AUTO_SAFE"
});
}
continue;
}
if (name === "size") {
if (args.length === 0 && jq) {
addFinding(model, ctx, {
idx: fIdx, category: "size-to-length", pattern: ".size()",
priority: "AutoFixed", confidence: "High", action: "Changed",
before: callText, after: ".length",
reason: ".size() removed in jQuery 3.0",
commitGroup: "AUTO_SAFE",
editStart: fIdx, editEnd: abs(closeIdx) + 1, replacement: "length"
});
} else if (args.length === 0) {
addFinding(model, ctx, {
idx: fIdx, category: "size-to-length", pattern: ".size()",
priority: "Review", confidence: "Low", action: "ReviewOnly",
before: callText, reason: "receiver not confirmed as jQuery object",
suggestion: ".length", commitGroup: "AUTO_SAFE"
});
}
continue;
}
if (name === "load") {
if (isWindowJq(recv) && args.length >= 1 && !/^['"]/.test(args[0].masked)) {
addFinding(model, ctx, {
idx: fIdx, category: "event-shortcut-load", pattern: ".load(",
priority: "AutoFixed", confidence: "High", action: "Changed",
before: callText, after: '.on("load", ...)',
reason: "window load event shortcut removed in jQuery 3.0",
commitGroup: "AUTO_SAFE",
editStart: fIdx, editEnd: abs(parenIdx) + 1, replacement: 'on("load", '
});
} else if (jq && args.length >= 1 && !/^['"]/.test(args[0].masked) && recv.base !== "$" && recv.base !== "jQuery") {
addFinding(model, ctx, {
idx: fIdx, category: "event-shortcut-load", pattern: ".load(",
priority: "Review", confidence: "Low", action: "ReviewOnly",
before: callText,
reason: "possible load event shortcut (not AJAX .load(url)); verify receiver and handler",
suggestion: '.on("load", handler)', commitGroup: "AUTO_SAFE"
});
}
continue;
}
if (name === "attr" || name === "removeAttr") {
handleAttr(model, ctx, { name: name, args: args, fIdx: fIdx, dotIdx: dotIdx, closeIdx: closeIdx, abs: abs, callText: callText, jq: jq, recv: recv });
continue;
}
if (name === "success" || name === "error" || name === "complete") {
if (SKIP_CALLBACK_BASES[recv.base]) continue;
if (name === "error" && args.length === 0) continue;
const rootTxt = String(recv.text || "");
const ajaxCtx = /\$\.(ajax|get|post|getJSON)\s*\(/.test(rootTxt) || /\.(ajax|get|post|getJSON)\s*\(/.test(rootTxt) || /(xhr|jqxhr|ajax|req)/i.test(recv.base || "");
const map = { success: "done", error: "fail", complete: "always" };
let sugg, why;
if (ajaxCtx) {
sugg = "." + map[name] + "(...)";
why = "jqXHR ." + name + "() removed in jQuery 3.0 (AJAX chain detected)";
} else if (name === "error" && jq) {
sugg = '.on("error", handler)';
why = ".error() event shortcut removed in jQuery 3.0 (DOM element context)";
} else {
sugg = "AJAX: ." + map[name] + "(...) / DOM event: .on(\"" + name + "\", fn)";
why = "." + name + "() shorthand removed in jQuery 3.0; context (AJAX vs DOM) must be confirmed";
}
addFinding(model, ctx, {
idx: fIdx, category: "jqxhr-shorthand", pattern: "." + name + "(",
priority: "Manual", confidence: ajaxCtx ? "High" : "Medium", action: "ReviewOnly",
before: callText, reason: why, suggestion: sugg, commitGroup: "UNKNOWN"
});
continue;
}
if (name === "live" || name === "die") {
const sugg = name === "live"
? '$(document).on(event, "SELECTOR", handler)'
: '$(document).off(event, "SELECTOR", handler)';
addFinding(model, ctx, {
idx: fIdx, category: "live-die", pattern: "." + name + "(",
priority: "Manual", confidence: "High", action: "ReviewOnly",
before: callText,
reason: "." + name + "() removed in jQuery 1.9; requires delegation rewrite",
suggestion: sugg, commitGroup: "UNKNOWN"
});
continue;
}
if (name === "andSelf") {
if (args.length === 0) {
addFinding(model, ctx, {
idx: fIdx, category: "andself-to-addback", pattern: ".andSelf()",
priority: "AutoFixed", confidence: "High", action: "Changed",
before: callText, after: ".addBack()",
reason: ".andSelf() removed in jQuery 3.0",
commitGroup: "AUTO_SAFE",
editStart: fIdx, editEnd: abs(closeIdx) + 1, replacement: "addBack()"
});
}
continue;
}
if (name === "html" || name === "append" || name === "prepend" || name === "before" || name === "after" || name === "replaceWith") {
if (args.length === 0) continue;
if ((name === "before" || name === "after") && !jq && !recv.base) continue;
checkSelfClosedArgs(model, ctx, abs, args, "." + name + "(");
let worst = { kind: "static" };
for (let ai = 0; ai < args.length; ai++) {
const cls = classifySinkArg(args[ai].orig, args[ai].masked, ctx);
if (cls.kind === "xss") { worst = cls; break; }
if (cls.kind === "review" && worst.kind !== "xss") worst = cls;
if (cls.kind === "static" && worst.kind === "static" && cls.why && !worst.why) worst = cls;
}
if (worst.kind === "empty") continue;
if (worst.kind === "static") {
addFinding(model, ctx, {
idx: fIdx, category: "dom-sink", pattern: "." + name + "(static)",
priority: "StaticHtmlLow", confidence: "High", action: "Ignored",
before: callText,
reason: worst.why || "static HTML literal only, no dynamic data",
commitGroup: "STATIC_LOW"
});
} else if (worst.kind === "xss") {
addFinding(model, ctx, {
idx: fIdx, category: "dom-sink", pattern: "." + name + "(dynamic)",
priority: "XssHigh", confidence: "High", action: "ReviewOnly",
before: callText,
reason: "DOM XSS candidate: " + worst.why,
suggestion: "plain text: .text(value) / keep structure: escapeHtml(value) / server HTML: verify trust boundary or sanitize",
commitGroup: "DOM_XSS"
});
} else {
addFinding(model, ctx, {
idx: fIdx, category: "dom-sink", pattern: "." + name + "(object)",
priority: "Review", confidence: "Medium", action: "ReviewOnly",
before: callText,
reason: worst.why,
suggestion: "confirm inserted content is built from trusted values; prefer .text() for data",
commitGroup: "DOM_XSS"
});
}
continue;
}
}
if (model.wrapperNames && model.wrapperNames.length > 0) {
model.wrapperNames.forEach(function (wname) {
const rule = model.wrapperRules[wname];
if (!rule || rule.role !== "domSinkArg") return;
if (masked.indexOf(wname) < 0) return;
const wrapRe = rule.callRe;
wrapRe.lastIndex = 0;
let wm;
while ((wm = wrapRe.exec(masked)) !== null) {
const wIdx = abs(wm.index + wm[1].length);
const parenIdx = wm.index + wm[0].length - 1;
const closeIdx = matchParen(masked, parenIdx);
if (closeIdx < 0) continue;
const spans = splitTopArgs(masked, parenIdx + 1, closeIdx);
const pIdx = typeof rule.sinkParamIndex === "number" ? rule.sinkParamIndex : 0;
if (pIdx < 0 || pIdx >= spans.length) continue;
const argOrig = O(spans[pIdx].s, spans[pIdx].e).trim();
const argMasked = masked.slice(spans[pIdx].s, spans[pIdx].e).trim();
if (!argOrig) continue;
const cls = classifySinkArg(argOrig, argMasked, ctx);
const callText = trunc(wname + O(parenIdx, closeIdx + 1), 160);
if (cls.kind === "xss") {
addFinding(model, ctx, {
idx: wIdx, category: "wrapper-dom-sink", pattern: wname + "(dynamic)",
priority: "XssHigh", confidence: "Medium", action: "ReviewOnly",
before: callText,
reason: "learned wrapper '" + wname + "' treated as DOM HTML sink: " + (cls.why || ""),
suggestion: "verify " + wname + "'s internal .html()/.append() usage; prefer .text() for data",
commitGroup: "DOM_XSS"
});
} else if (cls.kind === "review") {
addFinding(model, ctx, {
idx: wIdx, category: "wrapper-dom-sink", pattern: wname + "(object)",
priority: "Review", confidence: "Low", action: "ReviewOnly",
before: callText,
reason: "learned wrapper '" + wname + "' DOM sink argument: " + (cls.why || ""),
commitGroup: "DOM_XSS"
});
}
}
});
}
const utilRe = /(\$|jQuery)\s*\.\s*(trim|parseHTML|browser)\b/g;
while ((m = utilRe.exec(masked)) !== null) {
const util = m[2];
const fIdx = abs(m.index);
if (util === "browser") {
addFinding(model, ctx, {
idx: fIdx, category: "jquery-browser", pattern: "$.browser",
priority: "Manual", confidence: "High", action: "ReviewOnly",
before: trunc(O(m.index, Math.min(m.index + 80, orig.length)), 100),
reason: "$.browser removed in jQuery 1.9; likely dead or migrate-dependent code",
suggestion: "feature detection or navigator.userAgent check",
commitGroup: "UNKNOWN"
});
continue;
}
const parenIdx = masked.indexOf("(", m.index + m[0].length);
if (parenIdx < 0 || masked.slice(m.index + m[0].length, parenIdx).trim() !== "") continue;
const closeIdx = matchParen(masked, parenIdx);
if (closeIdx < 0) continue;
const argOrig = O(parenIdx + 1, closeIdx).trim();
const argMasked = masked.slice(parenIdx + 1, closeIdx).trim();
if (util === "trim") {
addFinding(model, ctx, {
idx: fIdx, category: "trim-deprecated", pattern: "$.trim(",
priority: "StaticHtmlLow", confidence: "High", action: "Ignored",
before: trunc(O(m.index, closeIdx + 1), 120),
reason: "$.trim is still supported on jQuery 3.x; defer until a future jQuery 4 cleanup because native trim has different null/undefined behavior",
suggestion: "Leave unchanged for the 3.5.1 landing; later replace only after confirming null/undefined inputs",
commitGroup: "DEFERRED_4X"
});
} else if (util === "parseHTML") {
checkSelfClosedArgs(model, ctx, abs, [{ orig: argOrig, rawOrig: O(parenIdx + 1, closeIdx), masked: argMasked, s: parenIdx + 1, e: closeIdx }], "$.parseHTML(");
const cls = classifySinkArg(argOrig, argMasked, ctx);
if (cls.kind === "static") {
addFinding(model, ctx, {
idx: fIdx, category: "parse-html", pattern: "$.parseHTML(static)",
priority: "StaticHtmlLow", confidence: "High", action: "Ignored",
before: trunc(O(m.index, closeIdx + 1), 120),
reason: cls.why || "static HTML literal", commitGroup: "STATIC_LOW"
});
} else {
addFinding(model, ctx, {
idx: fIdx, category: "parse-html", pattern: "$.parseHTML(dynamic)",
priority: cls.kind === "xss" ? "XssHigh" : "Review",
confidence: "Medium", action: "ReviewOnly",
before: trunc(O(m.index, closeIdx + 1), 120),
reason: "parseHTML with dynamic input: " + (cls.why || ""),
suggestion: "verify input source; consider sanitizer",
commitGroup: "DOM_XSS"
});
}
}
}
const factoryRe = /(^|[^\w$.])(\$|jQuery)\s*\(/g;
while ((m = factoryRe.exec(masked)) !== null) {
const parenIdx = m.index + m[0].length - 1;
const closeIdx = matchParen(masked, parenIdx);
if (closeIdx < 0) continue;
const spans = splitTopArgs(masked, parenIdx + 1, closeIdx);
if (spans.length === 0) continue;
const a0o = O(spans[0].s, spans[0].e).trim();
const a0m = masked.slice(spans[0].s, spans[0].e).trim();
if (!/^['"`]/.test(a0o) && !/\+/.test(a0m)) continue;
const inner = a0o.replace(/^['"`]|['"`]$/g, "");
if (!/^\s*= 1 ? args[0].masked : "";
const nameArgO = args.length >= 1 ? args[0].orig : "";
const litM = nameArgO.match(/^["']([A-Za-z-]+)["']$/);
if (p.name === "removeAttr") {
if (!litM) return;
const an = litM[1].toLowerCase();
if (an.indexOf("aria-") === 0) {
addFinding(model, ctx, {
idx: p.fIdx, category: "aria-attr", pattern: '.removeAttr("' + an + '")',
priority: "Ignored", confidence: "High", action: "Ignored",
before: p.callText, reason: "aria-* attributes stay as attributes; do not convert to prop",
commitGroup: "UNKNOWN"
});
return;
}
if (!BOOL_ATTRS[an]) return;
const rep = '.prop("' + an + '", false)';
addFinding(model, ctx, {
idx: p.fIdx, category: "bool-attr-removeattr", pattern: '.removeAttr("' + an + '")',
priority: "AutoFixed", confidence: "High", action: "Changed",
before: p.callText, after: rep,
reason: "boolean attribute removal converted to .prop(name, false) for jQuery 3.x consistency",
commitGroup: "AUTO_SAFE",
editStart: p.fIdx, editEnd: p.abs(p.closeIdx) + 1, replacement: rep.slice(1)
});
return;
}
if (args.length === 1 && /^\{/.test(nameArgM)) {
if (/["']?(disabled|readonly|checked|selected)["']?\s*:/.test(nameArgO)) {
addFinding(model, ctx, {
idx: p.fIdx, category: "attr-object-form", pattern: ".attr({...})",
priority: "Review", confidence: "Medium", action: "ReviewOnly",
before: p.callText,
reason: "object-form .attr() contains boolean attribute keys; split boolean keys into .prop()",
suggestion: "move disabled/readonly/checked/selected keys to .prop()",
commitGroup: "MANUAL_BOOL_ATTR"
});
}
return;
}
if (args.length !== 2 || !litM) return;
const an = litM[1].toLowerCase();
if (an.indexOf("aria-") === 0) return;
if (!BOOL_ATTRS[an]) return;
const vO = args[1].orig;
const vM = args[1].masked;
const mkProp = function (valExpr) { return '.prop("' + an + '", ' + valExpr + ")"; };
const mkPropTail = function (valExpr) { return 'prop("' + an + '", ' + valExpr + ")"; };
const autoEdit = function (valExpr, why, prio) {
addFinding(model, ctx, {
idx: p.fIdx, category: prio === "AutoInferred" ? "bool-attr-variable" : "bool-attr-literal",
pattern: '.attr("' + an + '", ...)',
priority: prio || "AutoFixed", confidence: "High", action: "Changed",
before: p.callText, after: mkProp(valExpr),
reason: why, commitGroup: "AUTO_SAFE",
editStart: p.fIdx, editEnd: p.abs(p.closeIdx) + 1, replacement: mkPropTail(valExpr)
});
};
if (/^(true|false)$/.test(vM)) { autoEdit(vM, "boolean literal moved from attr to prop"); return; }
const strLit = vO.match(/^["']([^"']*)["']$/);
if (strLit) {
const sv = strLit[1].toLowerCase();
if (sv === an || sv === "true") { autoEdit("true", 'string value "' + strLit[1] + '" means enabled; converted to prop true'); return; }
if (sv === "false") {
addFinding(model, ctx, {
idx: p.fIdx, category: "bool-attr-literal", pattern: '.attr("' + an + '", "false")',
priority: "Manual", confidence: "Medium", action: "ReviewOnly",
before: p.callText,
reason: 'attr("' + an + '","false") actually ENABLED the attribute in old jQuery (any non-empty value); converting to prop false would flip runtime behavior - confirm original intent first',
suggestion: mkProp("false") + " only if the intent was to clear " + an + ", otherwise " + mkProp("true"),
commitGroup: "MANUAL_BOOL_ATTR"
});
return;
}
addFinding(model, ctx, {
idx: p.fIdx, category: "bool-attr-literal", pattern: '.attr("' + an + '", "' + trunc(strLit[1], 20) + '")',
priority: "Manual", confidence: "Medium", action: "ReviewOnly",
before: p.callText,
reason: 'string value "' + strLit[1] + '" is ambiguous for boolean attribute (any non-empty attr value enables it)',
suggestion: mkProp("/* intended boolean */"),
commitGroup: "MANUAL_BOOL_ATTR"
});
return;
}
if (/^[01]$/.test(vM)) { autoEdit(vM === "1" ? "true" : "false", "numeric 0/1 literal converted to boolean prop"); return; }
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(vM)) {
addFinding(model, ctx, {
idx: p.fIdx, category: "bool-attr-variable", pattern: '.attr("' + an + '", ' + vM + ")",
priority: "Manual", confidence: "Medium", action: "ReviewOnly",
before: p.callText,
reason: "variable value; callsite type inference pending",
suggestion: mkProp(vM + " /* verify type */"),
commitGroup: "MANUAL_BOOL_ATTR",
pending: { type: "boolattr-infer", attrName: an, ident: vM, editStart: p.fIdx, editEnd: p.abs(p.closeIdx) + 1 }
});
return;
}
if (/(===|!==|==|!=|>=|<=)/.test(vM) || /^!/.test(vM.trim())) {
autoEdit(vO.trim(), "comparison expression always yields boolean; safe to move to prop");
return;
}
addFinding(model, ctx, {
idx: p.fIdx, category: "bool-attr-variable", pattern: '.attr("' + an + '", expr)',
priority: "Manual", confidence: "Low", action: "ReviewOnly",
before: p.callText,
reason: "complex expression; truthiness differs between attr and prop (e.g. \"N\" and \"false\" strings are truthy)",
suggestion: mkProp("Boolean(" + trunc(vO.trim(), 40) + ") /* map Y/N explicitly */"),
commitGroup: "MANUAL_BOOL_ATTR"
});
}
function isLikelyBooleanIdent(name) {
if (!name) return false;
if (/^(flag|bool|boolean|blean|checked|selected|disabled|readonly|enabled|visible|hidden|active|valid|invalid)$/i.test(name)) return true;
if (/^(is|has|can|should|use|allow|enable|disable)[A-Z0-9_]/.test(name)) return true;
if (/^(is|has|can|should|use|allow|enable|disable)_/i.test(name)) return true;
if (/^b[A-Z_]/.test(name)) return true;
return false;
}
function literalGroupOf(argMasked, argOrig) {
const t = String(argOrig).trim();
if (/^(true|false)$/.test(t)) return { group: "bool", value: t };
if (/^["']Y["']$/.test(t)) return { group: "yn", value: "Y" };
if (/^["']N["']$/.test(t)) return { group: "yn", value: "N" };
if (/^["']true["']$/.test(t)) return { group: "strtf", value: "true" };
if (/^["']false["']$/.test(t)) return { group: "strtf", value: "false" };
if (/^[01]$/.test(t)) return { group: "num01", value: t };
if (/^["'][01]["']$/.test(t)) return { group: "str01", value: t.replace(/["']/g, "") };
return null;
}
function resolveAutoInferred(model) {
const pend = model.findings.filter(function (f) { return f.pending && f.pending.type === "boolattr-infer"; });
pend.forEach(function (f) {
const ident = f.pending.ident;
const ctx = model.ctxByRel[f.rel];
let encl = null;
const idx = f.pending.editStart;
Object.keys(model.defs).forEach(function (nm) {
model.defs[nm].forEach(function (d) {
if (d.ctx !== ctx) return;
if (idx <= d.bodyStart || idx >= d.bodyEnd) return;
if (d.params.indexOf(ident) < 0) return;
if (!encl || d.bodyStart > encl.bodyStart) encl = d;
});
});
const finalize = function (ok, why, expr, sites) {
if (ok) {
f.priority = "AutoInferred";
f.action = "Changed";
f.confidence = "High";
f.after = '.prop("' + f.pending.attrName + '", ' + expr + ")";
f.reason = trunc("callsite inference: " + why, 300);
f.commitGroup = "AUTO_SAFE";
f.editStart = f.pending.editStart;
f.editEnd = f.pending.editEnd;
f.replacement = 'prop("' + f.pending.attrName + '", ' + expr + ")";
} else {
f.priority = "Manual";
f.action = "ReviewOnly";
f.reason = trunc("AutoInferred not applied: " + why, 300);
f.suggestion = '.prop("' + f.pending.attrName + '", ' + ident + ' === "Y") /* adjust comparison to actual callsite values */';
}
f.pending = null;
};
if (isLikelyBooleanIdent(ident)) {
finalize(true, "identifier naming heuristic: '" + ident + "' looks boolean; keep manual review if it can contain Y/N or string values", ident, []);
return;
}
if (!encl) { finalize(false, "'" + ident + "' is not a parameter of an enclosing named function", null); return; }
const defsForName = model.defs[encl.name] || [];
if (defsForName.length !== 1) { finalize(false, "function '" + encl.name + "' defined " + defsForName.length + " times in project", null); return; }
const pIdx = encl.params.indexOf(ident);
const callRe = new RegExp("(^|[^A-Za-z0-9_$.])" + escapeRe(encl.name) + "\\s*\\(", "g");
const sites = [];
let ambiguous = "";
model.textFiles.forEach(function (c2) {
c2.regions.forEach(function (rg) {
let mm;
callRe.lastIndex = 0;
while ((mm = callRe.exec(rg.masked)) !== null) {
const nameStart = mm.index + mm[1].length;
const before7 = rg.masked.slice(Math.max(0, nameStart - 12), nameStart);
if (/function\s*$/.test(before7)) continue;
const absIdx = rg.start + nameStart;
if (c2 === encl.ctx && absIdx === encl.defIdx) continue;
const prevCh = mm[1];
if (prevCh === ".") { ambiguous = "method-style callsite ." + encl.name + "( found at " + c2.rel; return; }
const openLocal = rg.masked.indexOf("(", nameStart);
if (openLocal < 0) continue;
const closeLocal = matchParen(rg.masked, openLocal);
if (closeLocal < 0) continue;
const spans = splitTopArgs(rg.masked, openLocal + 1, closeLocal);
if (spans.length <= pIdx) { sites.push({ ctx: c2, idx: rg.start + nameStart, group: null, value: "(missing arg)" }); continue; }
const aO = c2.text.slice(rg.start + spans[pIdx].s, rg.start + spans[pIdx].e).trim();
const g = literalGroupOf(null, aO);
sites.push({ ctx: c2, idx: rg.start + nameStart, group: g ? g.group : null, value: aO });
}
});
});
if (ambiguous) { finalize(false, ambiguous, null); return; }
const realSites = sites;
if (realSites.length === 0) { finalize(false, "no callsite found for '" + encl.name + "'", null); return; }
const groups = uniq(realSites.map(function (s) { return s.group || "nonliteral"; }));
const sampleTxt = realSites.slice(0, 3).map(function (s) {
return s.ctx.rel + ":" + lineOf(s.ctx.lineStarts, s.idx) + "=" + trunc(s.value, 20);
}).join(" | ");
if (groups.length !== 1 || groups[0] === "nonliteral") {
finalize(false, "callsite values not a single literal type group [" + groups.join(",") + "] samples: " + sampleTxt, null);
return;
}
const g = groups[0];
let expr = null;
if (g === "bool") expr = ident;
else if (g === "yn") expr = ident + ' === "Y"';
else if (g === "strtf") expr = ident + ' === "true"';
else if (g === "num01") expr = ident + " === 1";
else if (g === "str01") expr = ident + ' === "1"';
if (!expr) { finalize(false, "unsupported literal group " + g, null); return; }
finalize(true, realSites.length + " callsite(s), all '" + g + "' type. samples: " + sampleTxt, expr, realSites);
});
}
function buildModel(opts, mode) {
if (!opts.source) throw new Error("--source is required for mode " + mode);
const sourceRoot = path.resolve(opts.source);
if (!isDir(sourceRoot)) throw new Error("source directory not found: " + sourceRoot);
const profile = loadProfile(opts, sourceRoot);
const webContentRoot = detectWebContent(sourceRoot, profile);
if (!webContentRoot) throw new Error("web root could not be detected under: " + sourceRoot + " (configure webRootCandidates/webRootSignals in project-profile.json or --rulepack)");
const targetRoot = opts.target ? path.resolve(opts.target) : "";
const reportRoot = opts.report ? path.resolve(opts.report) : "";
if (targetRoot) {
if (path.resolve(targetRoot).toLowerCase() === sourceRoot.toLowerCase()) throw new Error("target must not be the same as source (source is never modified)");
}
const model = {
opts: opts, mode: mode, profile: profile,
sourceRoot: sourceRoot, webContentRoot: webContentRoot,
targetRoot: targetRoot, reportRoot: reportRoot,
targetWcRoot: targetRoot ? path.join(targetRoot, path.relative(sourceRoot, webContentRoot)) : "",
allFiles: [], textFiles: [], ctxByRel: Object.create(null), fileIndex: Object.create(null),
findings: [], defs: Object.create(null), pages: [],
pageScriptRows: [], pageCssRows: [], includeRows: [], unresolvedRows: [],
effectiveRows: [], oldCoreRefs: [], jqueryLoadRows: [],
ajaxRows: [], syntaxRows: [], probeInjections: [], patchResults: [],
serverFiles: [], serverEndpointRows: [], serverEvidenceRows: [], ajaxServerRows: [],
uiElementRows: [], selectorElementRows: [], runtimeScenarios: [],
runtimeParityRows: [], ieModePageRows: [],
uiElementsByPage: Object.create(null),
changed: {}, editWarnings: [], git: null, counters: {}, focus: [],
scriptInv: [], pluginInv: [], dirInv: [], completeRows: [], needsRows: [],
wrapperRules: Object.create(null), wrapperNames: [], safeWrapperNames: Object.create(null),
learnedFindingsMap: Object.create(null), reviewCases: [], reviewCasesAll: 0, reviewRound: 1
};
if (targetRoot && isUnderDir(targetRoot, sourceRoot)) warn("target is inside source; it will be excluded from scan");
if (reportRoot && isUnderDir(reportRoot, sourceRoot)) warn("report is inside source; it will be excluded from scan");
return model;
}
function analyze(model) {
buildWrapperRules(model);
buildLearnedFindingsMap(model);
const excl = [];
if (model.targetRoot) excl.push(model.targetRoot);
if (model.reportRoot) excl.push(model.reportRoot);
log("scanning WebContent: " + model.webContentRoot);
const files = walkFiles(model.webContentRoot, excl);
files.forEach(function (f) {
const rel = toPosix(path.relative(model.webContentRoot, f.abs));
model.allFiles.push({ abs: f.abs, rel: rel, size: f.size, ext: path.extname(rel).toLowerCase() });
model.fileIndex[rel.toLowerCase()] = f.abs;
});
log("files under WebContent: " + model.allFiles.length);
model.allFiles.forEach(function (f) {
if (TEXT_EXTS.indexOf(f.ext) < 0) return;
if (f.size > 2 * 1024 * 1024) { warn("skip large file (>2MB): " + f.rel); return; }
let text;
try { text = readLatin1(f.abs); } catch (e) { warn("read failed: " + f.rel); return; }
if (text.indexOf("\u0000") >= 0) { warn("binary-like file skipped: " + f.rel); return; }
const lib = classifyLib(f.rel, model.profile);
const ctx = {
abs: f.abs, rel: f.rel,
projRel: toPosix(path.relative(model.sourceRoot, f.abs)),
ext: f.ext, size: f.size,
isPage: PAGE_EXTS.indexOf(f.ext) >= 0,
isJs: f.ext === ".js", isCss: f.ext === ".css",
text: text, lineStarts: lineStartsOf(text), eol: detectEol(text),
lib: lib, isVendor: lib !== "app" && lib !== "probe",
isMin: isMinifiedFile(f.rel, text),
findings: [], edits: [], regions: [], taint: Object.create(null), refs: null,
model: model
};
if (ctx.isMin && !ctx.isVendor) ctx.isVendor = true;
model.textFiles.push(ctx);
model.ctxByRel[ctx.rel] = ctx;
});
log("text files to analyze: " + model.textFiles.length);
let done = 0;
model.textFiles.forEach(function (ctx) {
if (ctx.isPage) {
ctx.refs = collectPageStructure(ctx);
const regions = ctx.refs.inlineRegions.map(function (rg) { return premaskRegion(model, ctx, rg.start, rg.end); }).filter(Boolean);
regions.forEach(function (region) { scanRegionFindings(model, ctx, region); });
} else if (ctx.isJs) {
const region = premaskRegion(model, ctx, 0, ctx.text.length);
if (region) scanRegionFindings(model, ctx, region);
}
done++;
if (done % 100 === 0) log("scanned " + done + "/" + model.textFiles.length);
});
resolveAutoInferred(model);
annotateGroupKeys(model);
applyLearnedFindingsOverrides(model);
analyzePages(model);
buildUiElementInventory(model);
analyzeAjax(model);
analyzeServerEvidence(model);
analyzeSyntax(model);
buildInventories(model);
dedupFindings(model);
buildQueues(model);
buildSelectorElementMap(model);
buildRuntimeScenarios(model);
buildRuntimeParity(model);
buildReviewCases(model);
model.git = gitInfo(model.sourceRoot);
summarize(model);
}
function buildWrapperRules(model) {
const rules = Object.create(null);
const safe = Object.create(null);
const names = [];
(model.profile.learnedWrappers || []).forEach(function (w) {
if (!w || !w.name) return;
const prev = rules[w.name];
const rule = {
name: w.name,
role: w.role || "unknown",
calleeParamIndex: typeof w.calleeParamIndex === "number" ? w.calleeParamIndex : null,
sinkParamIndex: typeof w.sinkParamIndex === "number" ? w.sinkParamIndex : 0,
notes: w.notes || "",
callRe: new RegExp("(^|[^A-Za-z0-9_$.])" + escapeRe(w.name) + "\\s*\\(", "g")
};
if (prev && prev.role !== rule.role) {
warn("learnedWrappers: '" + w.name + "' role changed " + prev.role + " -> " + rule.role + " (later entry in project-profile.json wins)");
}
rules[w.name] = rule;
if (names.indexOf(w.name) < 0) names.push(w.name);
safe[w.name] = rule.role === "safeWrapper";
});
model.wrapperRules = rules;
model.wrapperNames = names;
model.safeWrapperNames = safe;
}
function buildLearnedFindingsMap(model) {
const map = Object.create(null);
(model.profile.learnedFindings || []).forEach(function (e) {
if (!e || !e.caseId) return;
map[e.caseId] = e;
});
model.learnedFindingsMap = map;
}
function caseIdOf(kind, name) {
let h1 = 0, h2 = 0;
const s = kind + ":" + name;
for (let i = 0; i < s.length; i++) {
h1 = (h1 * 31 + s.charCodeAt(i)) >>> 0;
h2 = (h2 * 131 + s.charCodeAt(i) + 7) >>> 0;
}
return kind + "-" + h1.toString(36).padStart(7, "0") + h2.toString(36).padStart(7, "0");
}
function findEnclosingDefName(model, f) {
if (f.idx === undefined || f.idx === null) return null;
const ctx = model.ctxByRel[f.rel];
if (!ctx) return null;
let best = null;
Object.keys(model.defs).forEach(function (nm) {
model.defs[nm].forEach(function (d) {
if (d.ctx !== ctx) return;
if (f.idx <= d.bodyStart || f.idx >= d.bodyEnd) return;
if (!best || d.bodyStart > best.bodyStart) best = d;
});
});
return best ? best.name : null;
}
function annotateGroupKeys(model) {
model.findings.forEach(function (f) {
if (f._caseId) return;
const encl = findEnclosingDefName(model, f);
f._groupKind = encl ? "FN" : "PT";
f._groupName = encl || (f.category + "@" + f.rel);
f._caseId = caseIdOf(f._groupKind, f._groupName);
});
}
const LEARNED_DECISION_MAP = {
"xss-high": { priority: "XssHigh", action: "ReviewOnly" },
"manual": { priority: "Manual", action: "ReviewOnly" },
"review": { priority: "Review", action: "ReviewOnly" },
"static-safe": { priority: "StaticHtmlLow", action: "Ignored" },
"vendor-review": { priority: "VendorReview", action: "ReviewOnly" },
"ignored": { priority: "Ignored", action: "Ignored" }
};
function applyLearnedFindingsOverrides(model) {
if (!model.learnedFindingsMap || Object.keys(model.learnedFindingsMap).length === 0) return;
const warnedCaseIds = Object.create(null);
model.findings.forEach(function (f) {
if (f.thirdParty === "Y") return;
if (f.action === "Changed") return;
const entry = model.learnedFindingsMap[f._caseId];
if (!entry || !entry.decision) return;
if (entry.name && entry.name !== f._groupName) {
if (!warnedCaseIds[f._caseId]) {
warnedCaseIds[f._caseId] = true;
warn("learnedFindings caseId " + f._caseId + " expected name '" + entry.name + "' but matched '" + f._groupName + "'; skipped as a precaution (possible hash mismatch or stale answer)");
}
return;
}
const dec = LEARNED_DECISION_MAP[entry.decision];
if (!dec) return;
f.priority = dec.priority;
f.action = dec.action;
f.reason = trunc("[learned:" + entry.decision + "] " + (entry.notes || "") + " | " + f.reason, 300);
});
}
function analyzePages(model) {
const pageCtxs = model.textFiles.filter(function (c) { return c.isPage; });
const eventsByPage = {};
pageCtxs.forEach(function (ctx) {
const refs = ctx.refs || { scripts: [], css: [], includes: [], inlineRegions: [] };
const events = [];
refs.scripts.forEach(function (s) {
const r = resolveRef(s.raw, ctx.rel, model);
const meta = scriptRefMeta(s.raw, r, model);
const line = lineOf(ctx.lineStarts, s.idx);
const row = {
page: ctx.rel, line: line, raw: s.raw, resolved: r.resolved, exists: r.exists,
reason: r.reason, meta: meta, tag: s.tag, tagStart: s.idx, tagEnd: s.tagEnd,
srcStart: s.srcStart, srcEnd: s.srcEnd, ctx: ctx
};
model.pageScriptRows.push(row);
events.push({ idx: s.idx, kind: "script", row: row });
if (meta.isCore || meta.isMigrate) model.jqueryLoadRows.push(row);
if (meta.isOld) {
model.oldCoreRefs.push(row);
addFinding(model, ctx, {
idx: s.idx, category: "jquery-core-old", pattern: "script src jquery " + meta.ver,
priority: "Critical", confidence: "High", action: "CriticalOnly",
before: trunc(lineTextAt(ctx.text, ctx.lineStarts, line).trim(), 200),
reason: "jQuery core " + meta.ver + " < " + TARGET_JQUERY_FLOOR_VERSION + " (" + CVE_ID + "); replaced only in patch-jquery mode",
suggestion: "replace with " + model.profile.jquery.coreFile + " + " + model.profile.jquery.migrateFile,
commitGroup: "JQUERY_CORE"
});
} else if (meta.isCore && !meta.ver) {
addFinding(model, ctx, {
idx: s.idx, category: "jquery-core-unknown", pattern: "script src " + trunc(s.raw, 60),
priority: "Review", confidence: "Low", action: "ReviewOnly",
before: trunc(lineTextAt(ctx.text, ctx.lineStarts, line).trim(), 200),
reason: "jQuery core reference with unknown version; verify it is " + TARGET_JQUERY_FLOOR_VERSION + " or higher",
commitGroup: "JQUERY_CORE"
});
}
if (!r.exists && r.reason !== "external-url" && r.reason !== "non-file") {
model.unresolvedRows.push({ page: ctx.rel, type: "script", raw: s.raw, reason: r.reason });
}
});
refs.css.forEach(function (s) {
const r = resolveRef(s.raw, ctx.rel, model);
const line = lineOf(ctx.lineStarts, s.idx);
model.pageCssRows.push({
page: ctx.rel, line: line, raw: s.raw, resolved: r.resolved, exists: r.exists,
lib: classifyLib(r.resolved || s.raw, model.profile), ver: versionFromName(fileNameOf(s.raw))
});
if (!r.exists && r.reason !== "external-url" && r.reason !== "non-file") {
model.unresolvedRows.push({ page: ctx.rel, type: "css", raw: s.raw, reason: r.reason });
}
});
refs.includes.forEach(function (inc) {
let resolved = "", ok = false, reason = "";
if (inc.unresolvable) { reason = "tiles-heuristic-unresolved"; }
else {
const r = resolveRef(inc.raw, ctx.rel, model);
resolved = r.resolved; ok = r.exists; reason = r.reason;
}
model.includeRows.push({ page: ctx.rel, type: inc.type, raw: inc.raw, resolved: resolved, ok: ok, reason: reason });
if (!inc.unresolvable && ok) events.push({ idx: inc.idx, kind: "include", target: resolved });
if (!ok) model.unresolvedRows.push({ page: ctx.rel, type: inc.type, raw: trunc(inc.raw, 120), reason: reason || "unresolved" });
});
events.sort(function (a, b) { return a.idx - b.idx; });
eventsByPage[ctx.rel] = events;
});
const memo = {};
function effectiveOf(rel, stack) {
if (memo[rel]) return memo[rel];
if (stack[rel]) return [];
stack[rel] = true;
const out = [];
const evs = eventsByPage[rel] || [];
evs.forEach(function (ev) {
if (ev.kind === "script") {
out.push({ row: ev.row, srcPage: rel });
} else {
const realRel = model.fileIndex[ev.target.toLowerCase()] ? findCtxRel(model, ev.target) : null;
if (realRel) {
effectiveOf(realRel, stack).forEach(function (e) { out.push({ row: e.row, srcPage: e.srcPage }); });
}
}
});
delete stack[rel];
memo[rel] = out;
return out;
}
pageCtxs.forEach(function (ctx) {
const direct = eventsByPage[ctx.rel].filter(function (e) { return e.kind === "script"; }).length;
const directCss = (ctx.refs ? ctx.refs.css.length : 0);
const eff = effectiveOf(ctx.rel, {});
let coreCount = 0, oldCore = false, hasMigrate = false, firstCore = -1, firstMigrate = -1, coreVer = "";
eff.forEach(function (e, i) {
model.effectiveRows.push({
page: ctx.rel, srcPage: e.srcPage, raw: e.row.raw, resolved: e.row.resolved,
order: i + 1, lib: e.row.meta.lib, ver: e.row.meta.ver,
isCore: e.row.meta.isCore, isOld: e.row.meta.isOld, isMigrate: e.row.meta.isMigrate
});
if (e.row.meta.isCore) { coreCount++; if (firstCore < 0) firstCore = i; if (e.row.meta.isOld) oldCore = true; if (!coreVer) coreVer = e.row.meta.ver; }
if (e.row.meta.isMigrate) { hasMigrate = true; if (firstMigrate < 0) firstMigrate = i; }
});
const effCss = eff.length;
const migrateAfter = hasMigrate && firstCore >= 0 ? (firstMigrate > firstCore ? "Y" : "N") : "";
const coreIs35Plus = coreCount > 0 && coreVer && !versionLt(coreVer, TARGET_JQUERY_FLOOR_VERSION);
model.pages.push({
rel: ctx.rel, ctx: ctx,
directScripts: direct, effectiveScripts: eff.length,
directCss: directCss, effectiveCss: 0,
hasCore: coreCount > 0, coreCount: coreCount, coreVer: coreVer,
oldCore: oldCore, hasMigrate: hasMigrate, migrateAfter: migrateAfter,
riskMultiCore: coreCount > 1, riskOldCore: oldCore,
riskMigrateMissing: coreIs35Plus && !hasMigrate,
riskMigrateBeforeCore: migrateAfter === "N" && hasMigrate && coreCount > 0,
effective: eff
});
});
}
function findCtxRel(model, wcRel) {
const abs = model.fileIndex[wcRel.toLowerCase()];
if (!abs) return null;
const rel = toPosix(path.relative(model.webContentRoot, abs));
return model.ctxByRel[rel] ? rel : null;
}
function analyzeAjax(model) {
model.textFiles.forEach(function (ctx) {
if (!ctx.isJs && !ctx.isPage) return;
if (ctx.isVendor && ctx.lib !== "app") { if (ctx.lib !== "probe") return; }
ctx.regions.forEach(function (rg) {
const orig = ctx.text.slice(rg.start, rg.end);
const code = maskJs(orig, true);
const push = function (idx, method, urlRaw, dyn, conf, mockType) {
model.ajaxRows.push({
rel: ctx.rel, line: lineOf(ctx.lineStarts, rg.start + idx),
method: method, urlRaw: trunc(urlRaw, 160),
urlNorm: normalizeAjaxUrl(urlRaw, model), dynamic: dyn ? "Y" : "N",
confidence: conf, mock: mockType
});
};
let m;
const ajaxRe = /\$\.(ajax|get|post|getJSON)\s*\(/g;
while ((m = ajaxRe.exec(code)) !== null) {
const kind = m[1];
const open = m.index + m[0].length - 1;
const close = matchParen(code, open);
if (close < 0) continue;
const inner = code.slice(open + 1, close);
if (kind === "ajax") {
const um = inner.match(/\burl\s*[:=]\s*(?:"([^"]*)"|'([^']*)')/);
const tm = inner.match(/\b(?:type|method)\s*[:=]\s*["']([A-Za-z]+)["']/);
const dj = /dataType\s*[:=]\s*["']json/i.test(inner);
if (um) push(m.index, tm ? tm[1].toUpperCase() : "GET", um[1] !== undefined ? um[1] : um[2], false, "High", dj ? "json" : "html");
else {
const ud = inner.match(/\burl\s*[:=]\s*([^,\r\n}]{1,120})/);
if (ud) push(m.index, tm ? tm[1].toUpperCase() : "GET", ud[1].trim(), true, "Low", dj ? "json" : "html");
}
} else {
const fm = inner.match(/^\s*(?:"([^"]*)"|'([^']*)')/);
const method = kind === "post" ? "POST" : "GET";
if (fm) push(m.index, method, fm[1] !== undefined ? fm[1] : fm[2], false, "High", kind === "getJSON" ? "json" : "html");
else {
const dm = inner.match(/^\s*([^,\r\n]{1,120})/);
if (dm && dm[1].trim()) push(m.index, method, dm[1].trim(), true, "Low", kind === "getJSON" ? "json" : "html");
}
}
}
const loadRe = /\.load\s*\(\s*(?:"([^"]+)"|'([^']+)')/g;
while ((m = loadRe.exec(code)) !== null) {
push(m.index, "GET", m[1] !== undefined ? m[1] : m[2], false, "Medium", "html");
}
const xhrRe = /\.open\s*\(\s*["'](GET|POST|PUT|DELETE)["']\s*,\s*(?:"([^"]+)"|'([^']+)')/gi;
while ((m = xhrRe.exec(code)) !== null) {
push(m.index, m[1].toUpperCase(), m[2] !== undefined ? m[2] : m[3], false, "High", "json");
}
});
});
}
function normalizeAjaxUrl(u, model) {
let s = applyPathVars(String(u), model.profile);
s = s.replace(/\$\{[^}]*\}/g, "_EL_").replace(/<%=?[^%]*%>/g, "_JSP_");
s = s.split(/[?#]/)[0].trim();
return s;
}
function readTextLoose(abs) {
const buf = fs.readFileSync(abs);
let s = buf.toString("utf8");
if (s.indexOf("\uFFFD") >= 0) s = buf.toString("latin1");
return s;
}
function stripJavaComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, function (m) {
return m.replace(/[^\r\n]/g, " ");
}).replace(/\/\/[^\r\n]*/g, "");
}
function countChar(s, ch) {
let n = 0;
for (let i = 0; i < s.length; i++) if (s[i] === ch) n++;
return n;
}
function annotationName(ann) {
const m = /^\s*@([A-Za-z0-9_$.]+)/.exec(ann || "");
return m ? m[1].split(".").pop() : "";
}
function quotedStrings(s) {
const out = [];
const re = /"([^"]*)"|'([^']*)'/g;
let m;
while ((m = re.exec(s || "")) !== null) out.push(m[1] !== undefined ? m[1] : m[2]);
return out;
}
function mappingPaths(ann) {
const named = [];
const namedRe = /\b(?:value|path)\s*=\s*(?:\{([^}]*)\}|(["'][\s\S]*?["']))/g;
let m;
while ((m = namedRe.exec(ann || "")) !== null) quotedStrings(m[1] || m[2] || "").forEach(function (s) { named.push(s); });
const qs = named.length > 0 ? named : quotedStrings(ann);
const paths = qs.filter(function (s) { return /^\/|\.do$|^[A-Za-z0-9_${]/.test(s || ""); });
return paths.length > 0 ? paths : [""];
}
function mappingMethods(ann) {
const name = annotationName(ann);
const fixed = { GetMapping: "GET", PostMapping: "POST", PutMapping: "PUT", DeleteMapping: "DELETE", PatchMapping: "PATCH" };
if (fixed[name]) return [fixed[name]];
const out = [];
const re = /RequestMethod\s*\.\s*([A-Z]+)/g;
let m;
while ((m = re.exec(ann || "")) !== null) out.push(m[1]);
const methodAttr = /\bmethod\s*=\s*(["'])([A-Za-z]+)\1/.exec(ann || "");
if (methodAttr) out.push(methodAttr[2].toUpperCase());
return out.length > 0 ? uniq(out) : ["ANY"];
}
function parseMappingAnnotations(annotations) {
const out = [];
(annotations || []).forEach(function (ann) {
const name = annotationName(ann);
if (!/^(RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)$/.test(name)) return;
const paths = mappingPaths(ann);
const methods = mappingMethods(ann);
paths.forEach(function (p) {
methods.forEach(function (m) {
out.push({ path: p || "", method: m, annotation: name });
});
});
});
return out;
}
function joinUrlPath(a, b) {
a = String(a || "").trim();
b = String(b || "").trim();
if (!a && !b) return "/";
const s = ("/" + [a, b].filter(Boolean).join("/")).replace(/\/+/g, "/");
return s.length > 1 && s.slice(-1) === "/" ? s.slice(0, -1) : s;
}
function extractJavaParams(signature) {
const params = [];
const re = /@(RequestParam|PathVariable)(?:\s*\(([^)]*)\))?[\s\S]*?\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,|\))/g;
let m;
while ((m = re.exec(signature || "")) !== null) {
const q = quotedStrings(m[2] || "");
params.push((m[1] === "PathVariable" ? "path:" : "query:") + (q[0] || m[3]));
}
return uniq(params);
}
function collectJavaAnnotationsAndMethods(text) {
const lines = text.split(/\r?\n/);
const items = [];
let pending = [];
let ann = null;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const trim = raw.trim();
if (ann) {
ann.text += " " + trim;
ann.depth += countChar(trim, "(") - countChar(trim, ")");
if (ann.depth <= 0) { pending.push(ann.text); ann = null; }
continue;
}
if (/^@[A-Za-z0-9_$.]+/.test(trim)) {
ann = { text: trim, line: i + 1, depth: countChar(trim, "(") - countChar(trim, ")") };
if (ann.depth <= 0) { pending.push(ann.text); ann = null; }
continue;
}
if (!trim) continue;
const classM = /\b(?:class|interface)\s+([A-Za-z_$][A-Za-z0-9_$]*)/.exec(trim);
if (classM) {
items.push({ type: "class", name: classM[1], line: i + 1, annotations: pending.slice() });
pending = [];
continue;
}
const sig = lines.slice(i, Math.min(lines.length, i + 10)).join(" ");
const methM = /(?:^|[\s;])(?:(?:public|protected|private)\s+)?(?:static\s+)?(?:@[A-Za-z0-9_$.]+(?:\([^)]*\))?\s*)*(?:[\w$<>\[\],.?]+\s+)+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([\s\S]*?)\)\s*(?:throws\s+[^{]+)?\{/.exec(sig);
if (methM) {
items.push({ type: "method", name: methM[1], line: i + 1, signature: methM[0], annotations: pending.slice() });
pending = [];
continue;
}
if (trim.charAt(0) !== "@") pending = [];
}
return items;
}
function serverScanRoots(model) {
const cfg = model.profile.serverScan || {};
const roots = [];
if (cfg.sourceOverride) roots.push(path.resolve(cfg.sourceOverride));
(cfg.sourceDirs || DEFAULT_SERVER_SCAN.sourceDirs).forEach(function (rel) {
const abs = path.isAbsolute(rel) ? rel : path.join(model.sourceRoot, rel);
if (isDir(abs)) roots.push(abs);
});
roots.push(model.sourceRoot);
return uniq(roots.map(function (p) { return path.resolve(p); })).filter(isDir);
}
function javaRel(model, abs) {
return toPosix(path.relative(model.sourceRoot, abs));
}
function scanJavaController(model, abs) {
const cfg = model.profile.serverScan || {};
let text;
try { text = stripJavaComments(readTextLoose(abs)); } catch (e) { return; }
if (text.length > (cfg.maxFileBytes || DEFAULT_SERVER_SCAN.maxFileBytes)) return;
const rel = javaRel(model, abs);
model.serverFiles.push({ rel: rel, type: "java", size: text.length });
const pkgM = /\bpackage\s+([A-Za-z0-9_.]+)\s*;/.exec(text);
const pkg = pkgM ? pkgM[1] : "";
const items = collectJavaAnnotationsAndMethods(text);
let className = fileNameOf(rel).replace(/\.java$/i, "");
let classMappings = [{ path: "", method: "ANY" }];
let classAnnotations = [];
items.forEach(function (it) {
if (it.type === "class") {
className = it.name;
classAnnotations = it.annotations || [];
const maps = parseMappingAnnotations(classAnnotations);
classMappings = maps.length > 0 ? maps : [{ path: "", method: "ANY" }];
if (classAnnotations.some(function (a) { return /@(Controller|RestController)\b/.test(a); })) {
model.serverEvidenceRows.push([rel, it.line, "controller-class", pkg ? pkg + "." + className : className, classMappings.map(function (m) { return m.path; }).join("|")]);
}
return;
}
if (it.type !== "method") return;
const methodMappings = parseMappingAnnotations(it.annotations || []);
if (methodMappings.length === 0) return;
const responseBody = classAnnotations.some(function (a) { return /@RestController\b/.test(a); }) ||
(it.annotations || []).some(function (a) { return /@ResponseBody\b/.test(a); });
const params = extractJavaParams(it.signature);
classMappings.forEach(function (cm) {
methodMappings.forEach(function (mm) {
const httpMethod = mm.method !== "ANY" ? mm.method : cm.method;
const route = joinUrlPath(cm.path, mm.path);
model.serverEndpointRows.push({
rel: rel,
line: it.line,
httpMethod: httpMethod || "ANY",
path: route,
className: pkg ? pkg + "." + className : className,
methodName: it.name,
responseBody: responseBody ? "Y" : "N",
params: params.join("|"),
evidence: mm.annotation
});
});
});
});
}
function scanServerXml(model, abs) {
const cfg = model.profile.serverScan || {};
if (cfg.includeXml === false) return;
let text;
try { text = readTextLoose(abs); } catch (e) { return; }
if (text.length > (cfg.maxFileBytes || DEFAULT_SERVER_SCAN.maxFileBytes)) return;
const rel = javaRel(model, abs);
model.serverFiles.push({ rel: rel, type: "xml", size: text.length });
const compRe = /]*\bbase-package\s*=\s*["']([^"']+)["'][^>]*>/g;
let m;
while ((m = compRe.exec(text)) !== null) {
model.serverEvidenceRows.push([rel, lineOf(lineStartsOf(text), m.index), "component-scan", m[1], ""]);
}
if (/]*\bname\s*=\s*["'](prefix|suffix)["'][^>]*\bvalue\s*=\s*["']([^"']*)["'][^>]*>/g;
while ((m = viewRe.exec(text)) !== null) {
model.serverEvidenceRows.push([rel, lineOf(lineStartsOf(text), m.index), "view-resolver-" + m[1], m[2], ""]);
}
}
function serverPathKey(s) {
let out = applyPathVars(String(s || ""), { pathVariables: DEFAULT_PATH_VARS });
out = out.replace(/\$\{[^}]*\}/g, "").replace(/<%=?[^%]*%>/g, "");
out = out.split(/[?#]/)[0].trim();
if (!out) return "";
if (!/^\//.test(out)) out = "/" + out;
out = out.replace(/\/+/g, "/");
if (out.length > 1 && out.slice(-1) === "/") out = out.slice(0, -1);
return out.toLowerCase();
}
function routePatternRe(route) {
const esc = escapeRe(serverPathKey(route)).replace(/\\\{[^}]+\\\}/g, "[^/]+").replace(/\\\*/g, ".*");
return new RegExp("^" + esc + "$", "i");
}
function mapAjaxToServer(model) {
const endpoints = model.serverEndpointRows || [];
model.ajaxRows.forEach(function (a) {
const url = serverPathKey(a.urlNorm);
if (!url || url.indexOf("_el_") >= 0 || url.indexOf("_jsp_") >= 0) return;
let best = null;
endpoints.forEach(function (e) {
const ep = serverPathKey(e.path);
if (!ep) return;
const methodOk = e.httpMethod === "ANY" || e.httpMethod === a.method || a.method === "GET" && e.httpMethod === "ANY";
let score = methodOk ? 20 : 0;
let why = methodOk ? "method" : "method-mismatch";
if (ep === url) { score += 80; why += "+exact"; }
else if (routePatternRe(ep).test(url)) { score += 70; why += "+pattern"; }
else if (url.slice(-ep.length) === ep || ep.slice(-url.length) === url) { score += 45; why += "+suffix"; }
else if (url.replace(/\.do$/i, "") === ep.replace(/\.do$/i, "")) { score += 60; why += "+do"; }
if (!best || score > best.score) best = { endpoint: e, score: score, why: why };
});
if (best && best.score >= 60) {
const e = best.endpoint;
model.ajaxServerRows.push({
rel: a.rel,
line: a.line,
ajaxMethod: a.method,
ajaxUrl: a.urlNorm,
matched: "Y",
serverMethod: e.httpMethod,
serverPath: e.path,
handler: e.className + "#" + e.methodName,
evidenceFile: e.rel,
evidenceLine: e.line,
confidence: best.score >= 90 ? "High" : "Medium",
note: best.why
});
} else {
model.ajaxServerRows.push({
rel: a.rel,
line: a.line,
ajaxMethod: a.method,
ajaxUrl: a.urlNorm,
matched: "N",
serverMethod: "",
serverPath: "",
handler: "",
evidenceFile: "",
evidenceLine: "",
confidence: "Low",
note: "no static Spring mapping matched"
});
}
});
}
function analyzeServerEvidence(model) {
const cfg = model.profile.serverScan || {};
if (cfg.enabled === false || model.opts["no-server-scan"]) return;
const excl = [];
if (model.targetRoot) excl.push(model.targetRoot);
if (model.reportRoot) excl.push(model.reportRoot);
const seen = Object.create(null);
serverScanRoots(model).forEach(function (root) {
walkFiles(root, excl).forEach(function (f) {
const abs = path.resolve(f.abs);
if (seen[abs]) return;
seen[abs] = 1;
const ext = path.extname(f.abs).toLowerCase();
if (ext === ".java") scanJavaController(model, f.abs);
else if (ext === ".xml") scanServerXml(model, f.abs);
});
});
mapAjaxToServer(model);
if (model.serverEndpointRows.length > 0 || model.serverEvidenceRows.length > 0) {
log("server evidence: endpoints=" + model.serverEndpointRows.length + " evidence=" + model.serverEvidenceRows.length + " ajaxMapped=" + model.ajaxServerRows.filter(function (r) { return r.matched === "Y"; }).length);
}
}
function analyzeSyntax(model) {
model.textFiles.forEach(function (ctx) {
if (!ctx.isJs) return;
if (ctx.isVendor || ctx.isMin) {
model.syntaxRows.push({ rel: ctx.rel, result: "SKIPPED", reason: ctx.isMin ? "minified" : "vendor" });
return;
}
if (/<%| 512 * 1024) {
model.syntaxRows.push({ rel: ctx.rel, result: "SKIPPED", reason: "too-large" });
return;
}
try {
new Function(ctx.text);
model.syntaxRows.push({ rel: ctx.rel, result: "OK", reason: "" });
} catch (e) {
model.syntaxRows.push({ rel: ctx.rel, result: "FAIL", reason: trunc(e.message, 160) });
addFinding(model, ctx, {
idx: 0, line: 1, category: "js-syntax", pattern: "new Function check",
priority: "Manual", confidence: "Low", action: "ReviewOnly",
before: "", reason: "syntax check failed under Node parser (may be legacy-IE-only syntax): " + trunc(e.message, 120),
commitGroup: "UNKNOWN"
});
}
});
}
const VENDOR_RECOMMEND = {
"jquery-ui": "jQuery UI <= 1.12.1 has its OWN CVEs (CVE-2021-41182/41183/41184 datepicker XSS, CVE-2022-31160); for modern browsers prefer current 1.14.x, but 1.14 drops IE/Edge Legacy support, so Edge IE mode sites should test carefully and may need the last 1.13.x patch during transition; test datepicker/dialog/button/tabs/autocomplete",
"jqgrid": "do not hand-edit; classic trirand jqGrid 4.x predates jQuery 3 - plan replacement with free-jqGrid 4.15.x/maintained fork or Guriddo jqGrid 5.5.4+ (commercial, jQuery 3.5 support); until swapped, test rendering/paging/sort/search/inline edit/formatter/subgrid under Migrate and watch JQMIGRATE warnings from grid files",
"select2": "select2 3.5.x is legacy; 4.x is the supported jQuery-based line. If already on 4.0.13, treat as compatibility-test target; if on 4.0.5-era builds, watch known jQuery 3 focus/multiselect issues and test placeholder, ajax search, multiple select, initial value binding",
"autoNumeric": "old autoNumeric 1.x is a jQuery plugin and must be runtime-tested; autoNumeric v4 removed the jQuery dependency if replacement is allowed. Test amount input, comma formatting, blur/focus, saved value, readonly/disabled",
"datepicker": "test open/close, locale, min/max date under jQuery 3.x",
"bootstrap": "check bootstrap js version vs jQuery 3.x compatibility",
"jquery-validate": "test form validation trigger/messages under jQuery 3.x",
"jquery-core": "core library file; replaced via patch-jquery mode",
"jquery-migrate": "migrate library; keep during transition, remove after cleanup",
"vendor-other": "external library; verify jQuery 3.x compatibility or replace"
};
function buildInventories(model) {
model.allFiles.forEach(function (f) {
const lib = classifyLib(f.rel, model.profile);
const isScript = f.ext === ".js";
const isStyle = f.ext === ".css";
const isPage = PAGE_EXTS.indexOf(f.ext) >= 0;
model.dirInv.push([f.rel, isPage ? "page" : isScript ? "script" : isStyle ? "style" : "asset", f.ext, f.size, lib, (lib !== "app" && lib !== "probe") ? "Y" : "N", isPage ? "Y" : "N", isScript ? "Y" : "N", isStyle ? "Y" : "N"]);
if (isScript) {
const ctx = model.ctxByRel[f.rel];
let ver = versionFromName(fileNameOf(f.rel));
if (lib === "jquery-core" && !ver) ver = sniffJqueryVersion(f.abs);
const min = ctx ? ctx.isMin : /\.min\.js$/i.test(f.rel);
let risk = "";
if (lib === "jquery-core" && ver && versionLt(ver, TARGET_JQUERY_FLOOR_VERSION)) risk = "OLD_JQUERY_CORE_" + CVE_ID;
else if (lib !== "app") risk = "VENDOR";
else if (min) risk = "MINIFIED";
model.scriptInv.push([f.rel, lib, ver, lib !== "app" ? "Y" : "N", min ? "Y" : "N", risk]);
if (lib !== "app" && lib !== "probe") {
const recs = model.profile.vendorRecommendations || {};
model.pluginInv.push([f.rel, lib, ver, "path/filename pattern", lib === "jquery-core" && risk ? "High" : "Medium", recs[lib] || VENDOR_RECOMMEND[lib] || recs["vendor-other"] || VENDOR_RECOMMEND["vendor-other"]]);
}
}
});
}
function familyOf(cat) {
if (cat === "dom-sink" || cat === "dom-factory" || cat === "parse-html") return "dom";
if (cat.indexOf("bool-attr") === 0) return "boolattr";
return cat;
}
function dedupFindings(model) {
const byKey = {};
const out = [];
model.findings.forEach(function (f) {
const key = f.rel + "|" + f.line + "|" + familyOf(f.category);
const prev = byKey[key];
if (!prev) { byKey[key] = f; out.push(f); return; }
const pr = PRIORITY_RANK[prev.priority] || 0;
const nr = PRIORITY_RANK[f.priority] || 0;
if (nr > pr && !(prev.action === "Changed" && f.action !== "Changed" && nr <= PRIORITY_RANK.Review)) {
const i = out.indexOf(prev);
if (prev.action !== "Changed") { out[i] = f; byKey[key] = f; }
else out.push(f);
} else if (f.action === "Changed" && prev.action !== "Changed") {
out.push(f);
}
});
model.findings = out;
}
function buildQueues(model) {
const focus = model.findings.filter(function (f) {
if (f.thirdParty === "Y") return false;
if (f.priority === "StaticHtmlLow" || f.priority === "Ignored" || f.priority === "VendorReview") return false;
if (f.category === "aria-attr") return false;
return f.priority === "Critical" || f.priority === "Manual" || f.priority === "XssHigh" || f.priority === "Review";
});
focus.sort(function (a, b) {
const d = (PRIORITY_RANK[b.priority] || 0) - (PRIORITY_RANK[a.priority] || 0);
if (d) return d;
if (a.rel !== b.rel) return a.rel < b.rel ? -1 : 1;
return a.line - b.line;
});
model.focus = focus;
const byFile = {};
model.findings.forEach(function (f) {
if (!byFile[f.rel]) byFile[f.rel] = [];
byFile[f.rel].push(f);
});
Object.keys(byFile).sort().forEach(function (rel) {
const fs2 = byFile[rel];
const cnt = {};
fs2.forEach(function (f) { cnt[f.priority] = (cnt[f.priority] || 0) + 1; });
const blockers = (cnt.Critical || 0) + (cnt.Manual || 0) + (cnt.Review || 0) + (cnt.XssHigh || 0) + (cnt.VendorReview || 0);
const result = blockers === 0 ? "AutoFixOnlyCompleteCandidate" : "NeedsReviewOrManual";
model.completeRows.push([
rel, result, fs2.length,
cnt.AutoFixed || 0, cnt.AutoInferred || 0, cnt.StaticHtmlLow || 0,
cnt.Critical || 0, cnt.Manual || 0, cnt.Review || 0, cnt.XssHigh || 0, cnt.VendorReview || 0,
result === "AutoFixOnlyCompleteCandidate" ? "only auto-fixed/low findings; no further change expected from pattern scan" : "remaining items need human review"
]);
if (blockers > 0) {
const cats = uniq(fs2.filter(function (f) { return PRIORITY_RANK[f.priority] >= PRIORITY_RANK.Review || f.priority === "VendorReview"; }).map(function (f) { return f.category; })).slice(0, 6).join(" ");
model.needsRows.push([rel, cnt.Critical || 0, cnt.XssHigh || 0, cnt.Manual || 0, cnt.Review || 0, cnt.VendorReview || 0, cats]);
}
});
}
function buildUiElementInventory(model) {
model.uiElementRows = [];
model.uiElementsByPage = Object.create(null);
model.textFiles.forEach(function (ctx) {
if (!ctx.isPage) return;
const els = ctx.refs && ctx.refs.elements ? ctx.refs.elements : [];
model.uiElementsByPage[ctx.rel] = els;
els.forEach(function (e) {
model.uiElementRows.push(e);
});
});
}
function normalizeSelectorText(s) {
return String(s || "").trim().replace(/\s+/g, " ");
}
function selectorKind(sel) {
const s = normalizeSelectorText(sel);
if (/^#[A-Za-z0-9_\-:.]+$/.test(s)) return "id";
if (/^\.[A-Za-z0-9_\-:.]+$/.test(s)) return "class";
if (/^\[name\s*=/.test(s)) return "name";
if (/^#\*/.test(s) || /\*$/.test(s)) return "dynamic-id";
if (/^[A-Za-z][A-Za-z0-9_-]*$/.test(s)) return "tag";
return "complex";
}
function extractSelectorsFromCode(code) {
const out = [];
const seen = Object.create(null);
const add = function (selector, confidence, source) {
selector = normalizeSelectorText(selector);
if (!selector || selector.length > 160) return;
if (selector.indexOf("<") >= 0) return;
const key = selector + "|" + confidence;
if (seen[key]) return;
seen[key] = 1;
out.push({ selector: selector, kind: selectorKind(selector), confidence: confidence, source: source || "literal" });
};
let m;
const literalRe = /(?:^|[^\w$])(jQuery|\$)\s*\(\s*(["'])([^"']{1,160})\2\s*[\),]/g;
while ((m = literalRe.exec(code || "")) !== null) add(m[3], "High", "literal");
const nameRe = /\[\s*name\s*=\s*(["']?)([A-Za-z0-9_.:\-]+)\1\s*\]/g;
while ((m = nameRe.exec(code || "")) !== null) add("[name=" + m[2] + "]", "High", "attribute");
const dynSuffixRe = /(?:jQuery|\$)\s*\(\s*(["'])#\1\s*\+[\s\S]{0,160}?\+\s*(["'])([#.]?[A-Za-z0-9_.:\-]+)\2\s*\)/g;
while ((m = dynSuffixRe.exec(code || "")) !== null) add("#*" + m[3].replace(/^#/, ""), "Medium", "dynamic-suffix");
const dynPrefixRe = /(?:jQuery|\$)\s*\(\s*(["'])([#.][A-Za-z0-9_.:\-]+)\1\s*\+[\s\S]{0,160}?\)/g;
while ((m = dynPrefixRe.exec(code || "")) !== null) add(m[2] + "*", "Medium", "dynamic-prefix");
return out;
}
function codeWindowForFinding(ctx, f) {
if (!ctx || f.idx === undefined || f.idx === null) return f.before || "";
const idx = parseInt(f.idx, 10);
if (!Number.isFinite(idx) || idx < 0) return f.before || "";
return ctx.text.slice(Math.max(0, idx - 320), Math.min(ctx.text.length, idx + 320));
}
function pagesForFinding(model, f) {
const ctx = model.ctxByRel[f.rel];
const out = [];
const add = function (p) { if (p && out.indexOf(p) < 0) out.push(p); };
if (ctx && ctx.isPage) add(f.rel);
(model.effectiveRows || []).forEach(function (r) {
if (r.resolved === f.rel) add(r.page);
});
(model.pageScriptRows || []).forEach(function (r) {
if (r.resolved === f.rel) add(r.page);
});
(model.includeRows || []).forEach(function (r) {
if (r.ok && r.resolved === f.rel) add(r.page);
});
return out.slice(0, 20);
}
function classTokens(s) {
return String(s || "").split(/\s+/).filter(Boolean);
}
function matchSelectorOnElement(selObj, el) {
const s = normalizeSelectorText(selObj.selector);
const kind = selObj.kind;
if (kind === "id") return el.id && ("#" + el.id) === s ? "High" : "";
if (kind === "class") return classTokens(el.className).indexOf(s.slice(1)) >= 0 ? "High" : "";
if (kind === "name") {
const m = /^\[name\s*=\s*['"]?([^'"\]]+)['"]?\]$/.exec(s);
return m && el.name === m[1] ? "High" : "";
}
if (kind === "tag") return el.tag === s.toLowerCase() ? "Low" : "";
if (kind === "dynamic-id") {
if (!el.id) return "";
if (/^#\*.+/.test(s)) return el.id.slice(-s.slice(2).length) === s.slice(2) ? "Medium" : "";
if (/^#.+\*$/.test(s)) return el.id.indexOf(s.slice(1, -1)) === 0 ? "Medium" : "";
}
return "";
}
function elementDisplayName(el) {
if (!el) return "";
const parts = [el.tag];
if (el.id) parts.push("#" + el.id);
if (el.name) parts.push("[name=" + el.name + "]");
const cls = classTokens(el.className).slice(0, 2);
if (cls.length) parts.push("." + cls.join("."));
const txt = el.text || el.value;
return parts.join("") + (txt ? " (" + trunc(txt, 40) + ")" : "");
}
function buildSelectorElementMap(model) {
const rows = [];
const rankByFinding = new Map();
model.focus.forEach(function (f, i) { rankByFinding.set(f, "F" + (i + 1)); });
model.findings.forEach(function (f, i) { if (!rankByFinding.has(f)) rankByFinding.set(f, String(i + 1)); });
model.findings.forEach(function (f) {
const ctx = model.ctxByRel[f.rel];
if (!ctx) return;
const selectors = extractSelectorsFromCode((f.before || "") + "\n" + codeWindowForFinding(ctx, f));
f.uiSelectors = selectors;
f.uiMatches = [];
if (selectors.length === 0) return;
const pages = pagesForFinding(model, f);
selectors.forEach(function (sel) {
if (pages.length === 0) {
rows.push({
findingRank: rankByFinding.get(f), rel: f.rel, line: f.line, category: f.category, priority: f.priority,
selector: sel.selector, selectorKind: sel.kind, page: "", element: "", role: "", confidence: "Low",
note: "no page candidate for this script/finding"
});
return;
}
let matched = 0;
pages.forEach(function (page) {
const els = model.uiElementsByPage[page] || [];
els.forEach(function (el) {
const conf = matchSelectorOnElement(sel, el);
if (!conf) return;
matched++;
const row = {
findingRank: rankByFinding.get(f), rel: f.rel, line: f.line, category: f.category, priority: f.priority,
selector: sel.selector, selectorKind: sel.kind, page: page, element: elementDisplayName(el), role: el.role,
confidence: conf, note: sel.source + " selector matched " + el.tag + " at line " + el.line,
elementRef: el
};
rows.push(row);
if (f.uiMatches.length < 10) f.uiMatches.push(row);
});
});
if (matched === 0) {
pages.slice(0, 5).forEach(function (page) {
rows.push({
findingRank: rankByFinding.get(f), rel: f.rel, line: f.line, category: f.category, priority: f.priority,
selector: sel.selector, selectorKind: sel.kind, page: page, element: "", role: "", confidence: "Low",
note: "selector not found in static JSP/HTML inventory"
});
});
}
});
});
model.selectorElementRows = rows;
}
function runtimeActionFor(f, match) {
const el = match && match.elementRef ? match.elementRef : null;
const target = el ? elementDisplayName(el) : (match && match.selector ? match.selector : "해당 화면 요소");
if (f.category.indexOf("bool-attr") === 0) {
if (el && el.role === "choice") return target + " 체크/해제 상태가 기존 1.10 화면과 같은지 확인합니다.";
return target + " 활성/비활성 상태를 초기 진입, 필수값 입력 후, 행 선택 후 각각 확인합니다.";
}
if (f.category === "bind-to-on" || f.category === "unbind-to-off" || f.category === "live-die" || f.category.indexOf("event-shortcut") === 0) {
return target + "를 3회 반복 조작하고 이벤트가 0회/중복 실행되지 않는지 확인합니다.";
}
if (f.category === "jqxhr-shorthand") {
return "해당 화면에서 조회/저장/삭제 등 AJAX 동작을 1회 성공, 가능하면 1회 실패 조건으로 실행합니다.";
}
if (f.category === "dom-sink" || f.category === "wrapper-dom-sink" || f.category === "dom-factory" || f.category === "parse-html") {
return "서버/사용자 값이 표시되는 영역에서 깨짐, 원치 않는 HTML 해석, 스크립트 실행 가능성이 없는지 확인합니다.";
}
if (f.priority === "Critical" || f.category.indexOf("jquery-core") === 0) {
return "페이지를 열고 jQuery/Migrate 로드 순서와 JQ35 Probe의 jQuery 버전, JS error, JQMIGRATE 경고를 확인합니다.";
}
return target + "가 포함된 업무 흐름을 기존과 동일하게 1회 이상 수행합니다.";
}
function runtimePassFor(f) {
if (f.category.indexOf("bool-attr") === 0) return "활성/비활성, checked/selected 상태가 기존 화면과 동일하고 JS error/AJAX error가 없습니다.";
if (f.category === "bind-to-on" || f.category === "unbind-to-off" || f.category === "live-die") return "클릭/변경 이벤트가 정확히 1회 실행되고 재진입/재조회 후에도 중복 실행이 없습니다.";
if (f.category === "jqxhr-shorthand") return "성공/실패 메시지와 callback 인자 처리 결과가 기존과 같고 Probe AJAXERROR가 없습니다.";
if (f.priority === "XssHigh") return "업무상 필요한 HTML만 신뢰 경계 안에서 표시되고, 사용자/서버 데이터가 임의 HTML로 실행되지 않습니다.";
return "화면 동작이 기존과 같고 Probe E=0, 필요한 경우 JQMIGRATE 경고가 기록/해소됩니다.";
}
function runtimeFailFor(f) {
if (f.category.indexOf("bool-attr") === 0) return "버튼이 계속 비활성/활성으로 남거나 체크 상태가 반대로 동작합니다.";
if (f.category === "bind-to-on" || f.category === "unbind-to-off" || f.category === "live-die") return "이벤트가 실행되지 않거나 한 번 조작에 두 번 이상 실행됩니다.";
if (f.category === "jqxhr-shorthand") return "조회/저장 AJAX가 실패하거나 성공/실패 메시지가 기존과 달라집니다.";
if (f.priority === "XssHigh") return "입력값/서버값이 HTML로 해석되거나 의도치 않은 태그/스크립트 실행 흔적이 있습니다.";
return "JS error, AJAX error, 화면 깨짐, 기존과 다른 업무 결과가 발생합니다.";
}
function buildRuntimeScenarios(model) {
const candidates = [];
const seen = Object.create(null);
function addFindingCandidate(f) {
const k = f.rel + "|" + f.line + "|" + f.category + "|" + f.pattern;
if (seen[k]) return;
seen[k] = 1;
candidates.push(f);
}
model.focus.slice(0, 200).forEach(addFindingCandidate);
model.findings.filter(isChangedAutoFinding).slice(0, 200).forEach(addFindingCandidate);
model.oldCoreRefs.slice(0, 80).forEach(function (r) {
const f = model.findings.filter(function (x) { return x.rel === r.page && x.line === r.line && x.priority === "Critical"; })[0];
if (f) addFindingCandidate(f);
});
const scenarios = [];
candidates.forEach(function (f) {
const pages = pagesForFinding(model, f);
const matches = f.uiMatches || [];
const primaryMatch = matches[0] || null;
const page = primaryMatch ? primaryMatch.page : (pages[0] || (model.ctxByRel[f.rel] && model.ctxByRel[f.rel].isPage ? f.rel : ""));
const selectors = (f.uiSelectors || []).map(function (s) { return s.selector; }).slice(0, 4).join(" | ");
const confidence = primaryMatch ? primaryMatch.confidence : (page ? "Medium" : "Low");
scenarios.push({
id: "RT-" + String(scenarios.length + 1).padStart(4, "0"),
stage: focusStageKey(f),
page: page,
findingRel: f.rel,
line: f.line,
category: f.category,
priority: f.priority,
confidence: confidence,
selector: selectors,
uiTarget: primaryMatch ? primaryMatch.element : "",
role: primaryMatch ? primaryMatch.role : "",
why: f.reason,
action: runtimeActionFor(f, primaryMatch),
passWhen: runtimePassFor(f),
failWhen: runtimeFailFor(f),
probeCheck: "Chrome smoke 또는 Edge IE mode 샘플링 시 JQ35 배지 E/M 수치, jQuery 버전, JQMIGRATE warning, JSERROR/AJAXERROR 로그를 저장합니다.",
codeOnlyCheck: "1망: 정적 finding, selector/page 매칭, AS-IS/TO-BE split diff, mock route 후보를 확인합니다.",
chromeSmoke: "2망: 실제 Spring/Tomcat 또는 개발계 Chrome에서 화면 렌더링, 주요 클릭/조회/저장, JSERROR/AJAXERROR를 확인합니다.",
ieFinalSample: "최종: IE_MODE_REQUIRED 또는 핵심업무 화면만 Edge IE mode에서 같은 동작을 샘플링합니다."
});
});
model.runtimeScenarios = scenarios;
}
function pagesForRel(model, rel) {
const out = [];
const add = function (p) { if (p && out.indexOf(p) < 0) out.push(p); };
const ctx = model.ctxByRel[rel];
if (ctx && ctx.isPage) add(rel);
(model.effectiveRows || []).forEach(function (r) { if (r.resolved === rel) add(r.page); });
(model.pageScriptRows || []).forEach(function (r) { if (r.resolved === rel) add(r.page); });
return out.slice(0, 50);
}
function pageEffectiveLibs(model, pageRel) {
const libs = Object.create(null);
(model.effectiveRows || []).forEach(function (r) {
if (r.page === pageRel && r.lib) libs[r.lib] = (libs[r.lib] || 0) + 1;
});
return libs;
}
function addUnique(arr, value) {
if (value && arr.indexOf(value) < 0) arr.push(value);
}
function pageRuntimeSignals(model, pageRel) {
const p = model.pages.filter(function (x) { return x.rel === pageRel; })[0];
const ctx = model.ctxByRel[pageRel];
const text = ctx ? ctx.text : "";
const libs = pageEffectiveLibs(model, pageRel);
const signals = [];
const ie = [];
const spring = [];
const vendor = [];
if (p && p.riskOldCore) addUnique(spring, "old jquery core must be checked on the real deployed page");
if (p && p.riskMultiCore) addUnique(spring, "multiple jQuery core loads depend on real include order");
if (p && p.riskMigrateMissing) addUnique(spring, "Migrate missing after core upgrade");
if ((model.unresolvedRows || []).some(function (r) { return r.page === pageRel; })) addUnique(spring, "unresolved JSP/include/resource reference");
Object.keys(libs).forEach(function (lib) {
if (lib === "jqgrid") { addUnique(ie, "jqGrid legacy plugin"); addUnique(vendor, "jqGrid"); }
else if (lib === "jquery-ui") { addUnique(spring, "jQuery UI widget smoke test on the real page"); addUnique(vendor, "jQuery UI"); }
else if (lib === "select2") { addUnique(spring, "select2 widget smoke test on the real page"); addUnique(vendor, "select2"); }
else if (lib === "autoNumeric") { addUnique(spring, "autoNumeric input formatting smoke test on the real page"); addUnique(vendor, "autoNumeric"); }
else if (lib === "vendor-other") { addUnique(spring, "third-party jQuery plugin smoke test on the real page"); addUnique(vendor, "vendor-other"); }
});
if (/ActiveXObject|classid\s*=|codebase\s*=|