"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(//ig, " ") .replace(//ig, " ") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/&/gi, "&") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\s+/g, " ") .trim(); } function decodeTextForDisplay(abs, fallback) { try { const buf = fs.readFileSync(abs); try { return new TextDecoder("utf-8", { fatal: true }).decode(buf); } catch (e) { } try { return new TextDecoder("euc-kr", { fatal: true }).decode(buf); } catch (e2) { } } catch (e3) { } return fallback; } function htmlAttrValue(raw) { if (raw == null) return ""; let s = String(raw).trim(); if ((s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') || (s.charAt(0) === "'" && s.charAt(s.length - 1) === "'")) { s = s.slice(1, -1); } return s.replace(/"/gi, '"').replace(/'/gi, "'").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">"); } function parseHtmlAttrs(attrText) { const attrs = Object.create(null); const re = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g; let m; while ((m = re.exec(attrText || "")) !== null) { attrs[m[1].toLowerCase()] = m[2] === undefined ? "" : htmlAttrValue(m[2]); } return attrs; } function labelTextFor(text, id) { if (!id) return ""; const re = new RegExp("]*\\bfor\\s*=\\s*([\"'])" + escapeRe(id) + "\\1[^>]*>([\\s\\S]{0,240}?)<\\/label\\s*>", "i"); const m = re.exec(text); return m ? trunc(stripMarkupText(m[2]), 80) : ""; } function elementInnerText(text, tag, tagEnd) { if (!/^(button|a|label|option|textarea|th|td)$/i.test(tag)) return ""; const close = new RegExp("<\\/" + escapeRe(tag) + "\\s*>", "i"); const slice = text.slice(tagEnd, Math.min(text.length, tagEnd + 700)); const m = close.exec(slice); if (!m) return ""; return trunc(stripMarkupText(slice.slice(0, m.index)), 100); } function nearbyUiText(text, idx) { const start = Math.max(0, idx - 320); const s = text.slice(start, idx); const cleaned = stripMarkupText(s); if (!cleaned) return ""; const parts = cleaned.split(/\s+/); return trunc(parts.slice(Math.max(0, parts.length - 10)).join(" "), 90); } function uiElementRole(tag, attrs) { const t = String(tag || "").toLowerCase(); const typ = String(attrs.type || "").toLowerCase(); const idc = [attrs.id, attrs.name, attrs["class"]].join(" ").toLowerCase(); if (t === "button" || (t === "input" && /^(button|submit|reset|image)$/.test(typ))) return "button"; if (t === "input" && /^(checkbox|radio)$/.test(typ)) return "choice"; if (t === "select") return "select"; if (t === "textarea" || t === "input") return "input"; if (t === "a") return "link"; if (t === "form") return "form"; if (t === "table" || /\b(grid|jqgrid|list|datatable)\b/.test(idc)) return "grid-or-table"; if (attrs.onclick || attrs.onchange || attrs.onblur || attrs.onfocus) return "event-target"; return "named-container"; } function collectUiElements(ctx) { const text = decodeTextForDisplay(ctx.abs, ctx.text); const lineStarts = lineStartsOf(text); let scan = text .replace(//ig, blankPreserveLines) .replace(//ig, blankPreserveLines); const out = []; const re = /<([A-Za-z][A-Za-z0-9:-]*)\b([^<>]*?)>/g; let m; while ((m = re.exec(scan)) !== null) { const tag = m[1].toLowerCase(); if (/^(script|style|meta|link|br|hr|img|jsp:|c:|fmt:|tiles:)/.test(tag)) continue; if (m[0].slice(0, 2) === "]*>/gi; let m; while ((m = scriptOpenRe.exec(text)) !== null) { const tag = m[0]; const tagStart = m.index; const tagEnd = m.index + tag.length; const srcInfo = scriptSrcInfo(tag, tagStart); if (srcInfo) { refs.scripts.push({ raw: srcInfo.raw, idx: tagStart, tag: tag, tagEnd: tagEnd, srcStart: srcInfo.srcStart, srcEnd: srcInfo.srcEnd }); } else { const closeIdx = text.toLowerCase().indexOf("= 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 + ">"; }); } 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] + ">, so following siblings become children; rewrite with explicit closing tags", suggestion: "write <" + 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*=|]*type\s*=\s*["']?file/i.test(text)) addUnique(ie, "file upload control"); if (/rdviewer|reportviewer|crystal|ocx|cab/i.test(text)) addUnique(ie, "legacy viewer/control keyword"); ie.forEach(function (x) { addUnique(signals, "IE:" + x); }); spring.forEach(function (x) { addUnique(signals, "SPRING:" + x); }); return { page: pageRel, pageInfo: p || null, ie: ie, spring: spring, vendor: vendor, signals: signals, libs: Object.keys(libs).sort() }; } function findingRuntimeSignals(model, f, pageSignals) { const ie = []; const spring = []; const reasons = []; const cat = f.category || ""; const ajaxRows = (model.ajaxRows || []).filter(function (r) { return r.rel === f.rel && Math.abs((r.line || 0) - (f.line || 0)) <= 8; }); if (f.priority === "Critical" || cat.indexOf("jquery-core") === 0) { addUnique(spring, "real JSP include/script path order must be checked after patch-jquery"); } if (f.priority === "XssHigh" || cat === "dom-sink" || cat === "wrapper-dom-sink" || cat === "dom-factory" || cat === "parse-html") { addUnique(spring, "server/user data origin affects DOM sink safety"); } if (cat === "jqxhr-shorthand" || ajaxRows.length > 0) { addUnique(spring, "AJAX callback and server response shape must be exercised"); } if (cat === "live-die" || cat.indexOf("event-shortcut") === 0) { addUnique(ie, "legacy event behavior should be checked in IE mode"); } if (cat === "bind-to-on" || cat === "unbind-to-off") { addUnique(reasons, "event rewrite is usually structurally safe but still needs target-flow smoke test"); } if (cat.indexOf("bool-attr") === 0) { addUnique(reasons, "boolean attr/property rewrite is stable across jQuery 1.x/3.x when value domain is known"); } if (f.priority === "VendorReview" || f.thirdParty === "Y") { addUnique(ie, "vendor jQuery plugin behavior cannot be proven by static scan"); } (pageSignals || []).forEach(function (ps) { ps.ie.forEach(function (x) { addUnique(ie, x); }); ps.spring.forEach(function (x) { addUnique(spring, x); }); }); return { ie: ie, spring: spring, reasons: reasons, ajaxRows: ajaxRows }; } function parityLevel(needSpring, needIe) { if (needIe) return "IE_MODE_REQUIRED"; if (needSpring) return "SPRING_TOMCAT_REQUIRED"; return "LOCAL_LAB_OK"; } function parityConfidence(level, f) { if (level === "LOCAL_LAB_OK") { if (f.action === "Changed" && (f.priority === "AutoFixed" || f.priority === "AutoInferred")) return "High"; if (f.priority === "StaticHtmlLow" || f.priority === "Ignored") return "High"; return "Medium"; } if (level === "SPRING_TOMCAT_REQUIRED") return "Medium"; return "Low"; } function validationLane(level) { if (level === "IE_MODE_REQUIRED") return "IE_FINAL_SAMPLE_REQUIRED"; if (level === "SPRING_TOMCAT_REQUIRED") return "CHROME_SMOKE_REQUIRED"; return "CODE_ONLY_OR_LIGHT_CHROME"; } function codeOnlyCheckForLevel(level) { if (level === "LOCAL_LAB_OK") return "1망 소스-only 산출물로 1차 종료 후보입니다. AS-IS/TO-BE diff와 Local Lab/mock 근거를 확인합니다."; if (level === "SPRING_TOMCAT_REQUIRED") return "1망 소스-only로는 JSP include/AJAX/서버 데이터가 확정되지 않습니다. 정적 근거만 남기고 2망 smoke로 넘깁니다."; return "1망 소스-only에서는 IE/구형벤더 위험 후보로만 표시합니다. Chrome smoke 뒤 Edge IE mode 최종 샘플링으로 넘깁니다."; } function chromeSmokeForLevel(level) { if (level === "LOCAL_LAB_OK") return "선택: 대표 화면만 Chrome에서 짧게 렌더링/클릭 확인하면 충분한 후보입니다."; if (level === "SPRING_TOMCAT_REQUIRED") return "필수: 실제 Spring/Tomcat 또는 개발계 Chrome에서 렌더링, 조회/저장, JSERROR/AJAXERROR, JQMIGRATE 경고를 확인합니다."; return "필수: IE 최종 샘플링 전 Chrome에서 먼저 공통 장애를 제거합니다."; } function ieFinalSampleForLevel(level) { if (level === "IE_MODE_REQUIRED") return "필수 샘플링: Edge IE mode에서 동일 화면/동작을 확인하고 Probe 로그 또는 수동 결과를 남깁니다."; if (level === "SPRING_TOMCAT_REQUIRED") return "조건부: Chrome smoke에서 이상이 없고 IE 전용 신호가 없으면 전수 IE 검증 대상은 아닙니다."; return "보통 제외: 핵심업무 대표 화면에 포함될 때만 짧게 샘플링합니다."; } function parityRecommendation(level) { if (level === "LOCAL_LAB_OK") return "1망 code-only 산출물과 Local Lab/mock으로 1차 종료 후보. 2망에서는 대표 화면 Chrome smoke만 짧게 확인합니다."; if (level === "SPRING_TOMCAT_REQUIRED") return "2망 Chrome smoke 필수. 실제 Spring/JSP/AJAX를 실행하고 Probe 로그 또는 수동 결과를 저장합니다."; return "2망 Chrome smoke 후 Edge IE mode 최종 샘플링 필수. Probe 로그 또는 수동 업무 결과를 남깁니다."; } function buildRuntimeParity(model) { const rows = []; const pageAgg = Object.create(null); const scenarioByFinding = Object.create(null); model.runtimeScenarios.forEach(function (s) { scenarioByFinding[s.findingRel + "|" + s.line + "|" + s.category] = s; }); function pageAggOf(page) { if (!pageAgg[page]) { const sig = pageRuntimeSignals(model, page); pageAgg[page] = { page: page, score: 0, findings: 0, scenarios: 0, ajax: 0, needsSpring: sig.spring.length > 0, needsIe: sig.ie.length > 0, reasons: sig.signals.slice(), vendors: sig.vendor.slice(), libs: sig.libs.slice() }; } return pageAgg[page]; } model.pages.forEach(function (p) { pageAggOf(p.rel); }); model.ajaxRows.forEach(function (a) { pagesForRel(model, a.rel).forEach(function (p) { const ag = pageAggOf(p); ag.ajax++; ag.needsSpring = true; addUnique(ag.reasons, "SPRING:AJAX endpoint " + a.method + " " + a.urlNorm); }); }); model.runtimeScenarios.forEach(function (s) { if (s.page) pageAggOf(s.page).scenarios++; }); const candidates = []; const seen = Object.create(null); function addCandidate(f) { const key = f.rel + "|" + f.line + "|" + f.category + "|" + f.priority; if (seen[key]) return; seen[key] = 1; candidates.push(f); } model.focus.forEach(addCandidate); model.findings.filter(isChangedAutoFinding).forEach(addCandidate); model.findings.filter(function (f) { return f.priority === "VendorReview" || f.priority === "XssHigh"; }).forEach(addCandidate); candidates.forEach(function (f) { const scenario = scenarioByFinding[f.rel + "|" + f.line + "|" + f.category] || null; let pages = scenario && scenario.page ? [scenario.page] : pagesForFinding(model, f); if (pages.length === 0 && model.ctxByRel[f.rel] && model.ctxByRel[f.rel].isPage) pages = [f.rel]; const pageSignals = pages.map(function (p) { return pageRuntimeSignals(model, p); }); const sig = findingRuntimeSignals(model, f, pageSignals); const needSpring = sig.spring.length > 0; const needIe = sig.ie.length > 0; const level = parityLevel(needSpring, needIe); const reasonParts = []; sig.reasons.forEach(function (x) { addUnique(reasonParts, x); }); sig.spring.forEach(function (x) { addUnique(reasonParts, "SPRING:" + x); }); sig.ie.forEach(function (x) { addUnique(reasonParts, "IE:" + x); }); if (reasonParts.length === 0) addUnique(reasonParts, "structural jQuery API compatibility; no server/IE-only signal detected"); rows.push({ id: "RP-" + String(rows.length + 1).padStart(4, "0"), scenarioId: scenario ? scenario.id : "", stage: focusStageKey(f), page: pages[0] || "", findingRel: f.rel, line: f.line, category: f.category, priority: f.priority, level: level, validationLane: validationLane(level), localLabConfidence: parityConfidence(level, f), needsSpringTomcat: needSpring ? "Y" : "N", needsIeMode: needIe ? "Y" : "N", reason: reasonParts.slice(0, 8).join(" | "), codeOnlyCheck: codeOnlyCheckForLevel(level), chromeSmoke: chromeSmokeForLevel(level), ieFinalSample: ieFinalSampleForLevel(level), recommendation: parityRecommendation(level) }); pages.forEach(function (p) { const ag = pageAggOf(p); ag.findings++; if (needSpring) ag.needsSpring = true; if (needIe) ag.needsIe = true; reasonParts.forEach(function (x) { addUnique(ag.reasons, x); }); }); }); Object.keys(pageAgg).forEach(function (p) { const ag = pageAgg[p]; ag.score = ag.findings * 5 + ag.scenarios * 8 + ag.ajax * 12 + (ag.needsSpring ? 25 : 0) + (ag.needsIe ? 45 : 0) + ag.vendors.length * 10; }); model.ieModePageRows = Object.keys(pageAgg).map(function (p) { const ag = pageAgg[p]; const level = parityLevel(ag.needsSpring, ag.needsIe); return { page: p, level: level, validationLane: validationLane(level), localLabConfidence: level === "LOCAL_LAB_OK" ? "High" : level === "SPRING_TOMCAT_REQUIRED" ? "Medium" : "Low", needsSpringTomcat: ag.needsSpring ? "Y" : "N", needsIeMode: ag.needsIe ? "Y" : "N", score: ag.score, findings: ag.findings, scenarios: ag.scenarios, ajax: ag.ajax, vendors: ag.vendors.join(" | "), reasons: ag.reasons.slice(0, 10).join(" | "), chromeSmoke: chromeSmokeForLevel(level), ieFinalSample: ieFinalSampleForLevel(level) }; }).sort(function (a, b) { return b.score - a.score || (a.page < b.page ? -1 : 1); }); model.runtimeParityRows = rows; } function isAmbiguousFinding(f) { if (f.thirdParty === "Y") return false; if (f.priority === "Review") return true; if (f.priority === "Manual") return true; if (f.priority === "XssHigh" && f.confidence !== "High") return true; if (f.category === "jquery-core-unknown") return true; return false; } function countCallSites(model, name) { const re = new RegExp("(^|[^A-Za-z0-9_$.])" + escapeRe(name) + "\\s*\\(", "g"); let n = 0; model.textFiles.forEach(function (c2) { c2.regions.forEach(function (rg) { re.lastIndex = 0; while (re.exec(rg.masked) !== null) n++; }); }); return n; } const REVIEW_QUESTIONS = Object.assign(Object.create(null), { "jqxhr-shorthand": "이 콜백은 AJAX 성공/에러 콜백인가요? 콜백의 첫 인자는 항상 서버에서 온 JSON/데이터인가요? (A: AJAX 성공, B: AJAX 에러, C: DOM 이벤트, D: 모름)", "dom-sink": "이 인자 값은 어디서 오나요? (A: 서버 응답/AJAX 콜백, B: 사용자 입력, C: 내부에서 안전하게 생성된 값, D: 알 수 없음) HTML 태그가 실제로 섞여 들어갈 수 있나요? (Y/N/모름)", "wrapper-dom-sink": "이 래퍼 함수가 내부적으로 jQuery .html()/.append()를 쓰나요? 인자로 들어오는 값이 서버 데이터인가요? (Y/N/모름)", "dom-factory": "이 문자열이 항상 고정 literal인가요, 아니면 조합되나요? (A: 고정, B: 조합, C: 모름)", "parse-html": "parseHTML에 들어가는 문자열이 서버 응답을 포함하나요? (Y/N/모름)", "bool-attr-variable": "이 변수가 실제로 가질 수 있는 값은 무엇인가요? (예: Y/N, true/false, 1/0, 기타 - 적어주세요)", "trim-deprecated": "$.trim 인자가 항상 문자열인가요, 아니면 null/undefined가 올 수 있나요? (A: 항상 문자열, B: null 가능, C: 모름)", "jquery-core-unknown": "이 jQuery 파일의 실제 버전을 알고 있나요? (버전 문자열 또는 '모름')", "live-die": "이 selector가 동적으로 추가되는 요소를 대상으로 하나요? (Y/N/모름)", "js-syntax": "이 파일이 실제로 구형 IE 전용 문법(conditional comments 등)을 쓰나요, 아니면 다른 이유로 파싱이 실패했나요?" }); function questionFor(category) { return REVIEW_QUESTIONS[category] || "이 코드의 역할은 무엇인가요? (자유 설명)"; } const REVIEW_QUESTIONS_SHORT = Object.assign(Object.create(null), { "jqxhr-shorthand": "ajax cb? arg0 server data? A:success B:error C:event D:?", "dom-sink": "sink arg origin? A:server B:user C:safe D:? html possible?", "wrapper-dom-sink": "wrapper uses html/append? arg server data? Y/N/?", "dom-factory": "html string fixed or built? A:fixed B:built C:?", "parse-html": "parseHTML input includes server data? Y/N/?", "bool-attr-variable": "possible values? Y/N true/false 1/0 other?", "trim-deprecated": "$.trim arg always string? A:string B:nullable C:?", "jquery-core-unknown": "jquery version? value or ?", "live-die": "selector targets dynamic elements? Y/N/?", "js-syntax": "legacy IE syntax or real parse issue?" }); function shortQuestionFor(category) { return REVIEW_QUESTIONS_SHORT[category] || "role/intent?"; } function shortReviewPath(rel) { let s = toPosix(rel || ""); s = s.replace(/^WebContent\//i, ""); s = s.replace(/^WEB-INF\/views\//i, "v/"); s = s.replace(/^WEB-INF\/layouts\//i, "l/"); s = s.replace(/^resources\//i, "r/"); const parts = s.split("/").filter(Boolean); if (parts.length > 4) s = ".../" + parts.slice(-4).join("/"); return s; } function compactLocations(locs, max) { const out = []; const seen = Object.create(null); locs.forEach(function (loc) { const m = /^(.+):(\d+)$/.exec(loc); const shortLoc = m ? (shortReviewPath(m[1]) + ":" + m[2]) : shortReviewPath(loc); if (!seen[shortLoc]) { seen[shortLoc] = 1; out.push(shortLoc); } }); if (out.length <= max) return out.join(" "); return out.slice(0, max).join(" ") + " +" + (out.length - max); } function compactExcerptText(excerpt) { const out = []; const seen = Object.create(null); String(excerpt || "").split(/\n/).forEach(function (line) { let s = line.replace(/\r$/, "").replace(/^\s{0,3}(\d+:)/, "$1").replace(/^>>\s*/, ">"); s = s.replace(/[ \t]+/g, " ").trimEnd(); const key = s.replace(/^>\s*/, "").replace(/^\d+:\s*/, ""); if (key && seen[key]) return; if (key) seen[key] = 1; out.push(s); }); return out.join("\n"); } function bucketOf(len) { if (len > 30) return "long"; if (len > 8) return "med"; return "short"; } function redactSourceText(text) { const n = text.length; const out = []; let i = 0; let lastSig = ""; let lastWord = ""; let prevWasWord = false; const REGEX_WORDS = Object.assign(Object.create(null), { "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 = text[i]; const d = i + 1 < n ? text[i + 1] : ""; if (c === "<" && text.slice(i, i + 4) === "<%--") { let j = i + 4; let nl = 0; while (j < n && text.slice(j, j + 3) !== "--%>") { if (text[j] === "\n") nl++; j++; } if (j < n) j += 3; out.push("<%----%>" + "\n".repeat(nl)); i = j; continue; } if (c === "/" && d === "/") { while (i < n && text[i] !== "\n") i++; out.push("//"); continue; } if (c === "/" && d === "*") { let j = i + 2; let nl = 0; while (j < n && !(text[j] === "*" && text[j + 1] === "/")) { if (text[j] === "\n") nl++; j++; } if (j < n) j += 2; out.push("/**/" + "\n".repeat(nl)); i = j; continue; } if (c === '"' || c === "'") { const q = c; i++; let len = 0; while (i < n) { if (text[i] === "\\" && i + 1 < n) { len += 2; i += 2; continue; } if (text[i] === q) { i++; break; } if (text[i] === "\n") break; len++; i++; } out.push(q + "" + q); lastSig = q; prevWasWord = false; continue; } if (c === "`") { i++; let len = 0; let nl = 0; while (i < n) { if (text[i] === "\\" && i + 1 < n) { len += 2; i += 2; continue; } if (text[i] === "`") { i++; break; } if (text[i] === "\n") nl++; len++; i++; } out.push("``" + "\n".repeat(nl)); 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) { i++; let inClass = false; let bailed = false; while (i < n) { if (text[i] === "\\" && i + 1 < n) { i += 2; continue; } if (text[i] === "[") { inClass = true; i++; continue; } if (text[i] === "]") { inClass = false; i++; continue; } if (text[i] === "/" && !inClass) { i++; break; } if (text[i] === "\n") { bailed = true; break; } i++; } if (!bailed) { while (i < n && /[a-z]/i.test(text[i])) i++; out.push("//"); lastSig = "/"; prevWasWord = false; continue; } } } out.push(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 applySensitiveIdentifiers(model, line) { let out = line; (model.profile.sensitiveIdentifiers || []).forEach(function (nm, i) { if (!nm) return; out = out.replace(new RegExp("(?> " : " ") + ln + ": " + applySensitiveIdentifiers(model, raw)); } return out.join("\n"); } function buildReviewCases(model) { const ambiguous = model.findings.filter(isAmbiguousFinding); const groups = Object.create(null); ambiguous.forEach(function (f) { const key = f._caseId; if (!groups[key]) { groups[key] = { caseId: key, kind: f._groupKind, name: f._groupName, findings: [], categories: Object.create(null) }; } groups[key].findings.push(f); groups[key].categories[f.category] = (groups[key].categories[f.category] || 0) + 1; }); let list = Object.keys(groups).map(function (k) { return groups[k]; }); const weight = Object.assign(Object.create(null), { Manual: 3, XssHigh: 3, Review: 2, Critical: 4 }); list.forEach(function (g) { g.weightBase = g.findings.reduce(function (s, f) { return s + (weight[f.priority] || 1); }, 0); }); list.sort(function (a, b) { return b.weightBase - a.weightBase; }); const maxCases = positiveIntOpt(model.opts["max-review-cases"], 20); const preTop = list.slice(0, Math.max(maxCases * 3, maxCases)); preTop.forEach(function (g) { g.fanout = g.kind === "FN" ? countCallSites(model, g.name) : 0; g.score = g.weightBase * (1 + Math.log2(1 + g.fanout)); }); preTop.sort(function (a, b) { return b.score - a.score; }); const top = preTop.slice(0, maxCases); const contextLines = positiveIntOpt(model.opts["context-lines"], 1); top.forEach(function (g) { const rep = g.findings[0]; const ctx = model.ctxByRel[rep.rel]; g.repFile = rep.rel; g.repLine = rep.line; g.excerpt = ctx ? excerptFor(model, ctx, rep.idx || 0, contextLines) : "(no excerpt available)"; g.topCategories = Object.keys(g.categories).sort(function (a, b) { return g.categories[b] - g.categories[a]; }).slice(0, 2); g.question = questionFor(g.topCategories[0]); g.shortQuestion = shortQuestionFor(g.topCategories[0]); g.sampleLocations = uniq(g.findings.map(function (f) { return f.rel + ":" + f.line; })).slice(0, 3); g.sampleLocationsShort = compactLocations(uniq(g.findings.map(function (f) { return f.rel + ":" + f.line; })), 3); g.compactExcerpt = compactExcerptText(g.excerpt); g.count = g.findings.length; }); model.reviewCases = top; model.reviewCasesAll = list.length; } function summarize(model) { const c = {}; const P = ["Critical", "AutoFixed", "AutoInferred", "Review", "Manual", "XssHigh", "VendorReview", "StaticHtmlLow", "Ignored"]; P.forEach(function (p) { c[p] = 0; }); model.findings.forEach(function (f) { if (c[f.priority] !== undefined) c[f.priority]++; }); const libCounts = {}; model.scriptInv.forEach(function (r) { libCounts[r[1]] = (libCounts[r[1]] || 0) + 1; }); const changedCandidates = {}; model.findings.forEach(function (f) { if (f.action === "Changed") changedCandidates[f.rel] = 1; }); model.counters = { SourceRoot: model.sourceRoot, WebContentRoot: model.webContentRoot, TargetRoot: model.targetRoot || "(not set)", ReportRoot: model.reportRoot || "(not set)", Mode: model.mode, JqueryTargetVersion: model.profile.jquery.targetVersion, JqueryFloorVersion: TARGET_JQUERY_FLOOR_VERSION, Gate35Blockers: c.Critical, TotalFiles: model.allFiles.length, TextFiles: model.textFiles.length, PageFiles: model.textFiles.filter(function (x) { return x.isPage; }).length, JsFiles: model.textFiles.filter(function (x) { return x.isJs; }).length, ChangedFiles: Object.keys(model.changed).length || Object.keys(changedCandidates).length, ApiFindings: model.findings.length, Critical: c.Critical, AutoFixed: c.AutoFixed, AutoInferred: c.AutoInferred, Review: c.Review, Manual: c.Manual, XssHigh: c.XssHigh, FocusQueue: model.focus.length, VendorReview: c.VendorReview, StaticHtmlLow: c.StaticHtmlLow, Ignored: c.Ignored, JqueryLoads: model.jqueryLoadRows.length, OldJqueryBelow350: model.oldCoreRefs.length, PageRiskMultipleJqueryCore: model.pages.filter(function (p) { return p.riskMultiCore; }).length, PageRiskOldJqueryCore: model.pages.filter(function (p) { return p.riskOldCore; }).length, PageRiskMigrateMissing: model.pages.filter(function (p) { return p.riskMigrateMissing; }).length, PageRiskMigrateBeforeCore: model.pages.filter(function (p) { return p.riskMigrateBeforeCore; }).length, UnresolvedRefs: model.unresolvedRows.length, AjaxEndpoints: model.ajaxRows.length, ServerFiles: model.serverFiles.length, ServerEndpoints: model.serverEndpointRows.length, AjaxMappedToServer: model.ajaxServerRows.filter(function (r) { return r.matched === "Y"; }).length, UiElements: model.uiElementRows.length, SelectorElementRows: model.selectorElementRows.length, RuntimeScenarios: model.runtimeScenarios.length, RuntimeParityRows: model.runtimeParityRows.length, RuntimeParityLocalOk: model.runtimeParityRows.filter(function (r) { return r.level === "LOCAL_LAB_OK"; }).length, RuntimeParitySpringTomcat: model.runtimeParityRows.filter(function (r) { return r.level === "SPRING_TOMCAT_REQUIRED"; }).length, RuntimeParityIeMode: model.runtimeParityRows.filter(function (r) { return r.level === "IE_MODE_REQUIRED"; }).length, RuntimeChromeSmokeRequired: model.runtimeParityRows.filter(function (r) { return r.validationLane === "CHROME_SMOKE_REQUIRED" || r.validationLane === "IE_FINAL_SAMPLE_REQUIRED"; }).length, RuntimeIeFinalSampleRequired: model.runtimeParityRows.filter(function (r) { return r.validationLane === "IE_FINAL_SAMPLE_REQUIRED"; }).length, IeModeRiskPages: model.ieModePageRows.filter(function (r) { return r.needsIeMode === "Y"; }).length, SpringRuntimePages: model.ieModePageRows.filter(function (r) { return r.needsSpringTomcat === "Y"; }).length, JsSyntaxFail: model.syntaxRows.filter(function (r) { return r.result === "FAIL"; }).length, LibraryCounts: JSON.stringify(libCounts), GitInfo: model.git && model.git.available ? (model.git.branch + " changed=" + model.git.changed.length + " untracked=" + model.git.untracked.length) : "Unavailable", OldJquerySrcs: model.oldCoreRefs.map(function (r) { return r.page + ":" + r.line + ":" + r.raw; }).join(" | "), ReviewCasesTotal: model.reviewCasesAll, ReviewCasesInPack: model.reviewCases.length, LearnedWrapperCount: model.wrapperNames.length, LearnedFindingOverrides: Object.keys(model.learnedFindingsMap || {}).length }; } function applyEditsToText(ctx) { const edits = []; ctx.findings.forEach(function (f) { if (f.action === "Changed" && f.editStart !== undefined && f.editEnd !== undefined && f.replacement !== undefined) { edits.push({ s: f.editStart, e: f.editEnd, r: f.replacement, f: f }); } }); if (edits.length === 0) return null; edits.sort(function (a, b) { return a.s - b.s; }); const applied = []; let lastEnd = -1; edits.forEach(function (ed) { if (ed.s < lastEnd) { ed.f.reason = trunc(ed.f.reason + " [skipped: overlapping edit]", 300); ed.f.action = "ReviewOnly"; return; } applied.push(ed); lastEnd = ed.e; }); let text = ctx.text; for (let i = applied.length - 1; i >= 0; i--) { const ed = applied[i]; text = text.slice(0, ed.s) + ed.r + text.slice(ed.e); } return { text: text, count: applied.length }; } function writeTarget(model, flags) { if (!model.targetRoot) throw new Error("--target is required for mode " + model.mode); log("writing TO-BE tree: " + model.targetRoot); ensureDir(model.targetRoot); const excl = [model.targetRoot]; if (model.reportRoot) excl.push(model.reportRoot); const all = walkFiles(model.sourceRoot, excl); const patchedByProj = {}; model.textFiles.forEach(function (ctx) { const res = applyEditsToText(ctx); if (res) patchedByProj[ctx.projRel] = { ctx: ctx, text: res.text, count: res.count }; }); let copied = 0; all.forEach(function (f) { const projRel = toPosix(path.relative(model.sourceRoot, f.abs)); const dest = path.join(model.targetRoot, projRel.split("/").join(path.sep)); ensureDir(path.dirname(dest)); const patched = patchedByProj[projRel]; if (patched) { writeLatin1(dest, patched.text); model.changed[patched.ctx.rel] = { projRel: projRel, edits: patched.count, kind: "autofix" }; } else { fs.copyFileSync(f.abs, dest); } copied++; }); log("copied " + copied + " files, auto-fixed " + Object.keys(patchedByProj).length + " files"); if (flags.patch) patchJqueryCore(model); if (flags.probe) injectProbe(model); model.counters.ChangedFiles = Object.keys(model.changed).length; } function targetPathOf(model, ctx) { return path.join(model.targetRoot, ctx.projRel.split("/").join(path.sep)); } function bundledJqueryAssetPath(fileName) { const base = path.basename(String(fileName || "")); if (!base || base !== String(fileName || "")) return ""; const abs = path.join(__dirname, "assets", "jquery", base); return exists(abs) ? abs : ""; } function installBundledJqueryAsset(model, fileName, destAbs, label) { if (!destAbs || exists(destAbs)) return false; if (!model.targetWcRoot || !isUnderDir(destAbs, model.targetWcRoot)) return false; const asset = bundledJqueryAssetPath(fileName); if (!asset) return false; ensureDir(path.dirname(destAbs)); fs.copyFileSync(asset, destAbs); const rel = normalizeWcPath(path.relative(model.targetWcRoot, destAbs)); model.patchResults.push([rel, fileName, "BUNDLED", "copied bundled " + label + " asset into TO-BE"]); return true; } function findScriptSrcSpan(text, refRow) { if (refRow.srcStart !== undefined && refRow.srcEnd !== undefined && text.slice(refRow.srcStart, refRow.srcEnd) === refRow.raw) { return { start: refRow.srcStart, end: refRow.srcEnd, tagStart: refRow.tagStart !== undefined ? refRow.tagStart : refRow.idx }; } const scriptOpenRe = /]*>/gi; let m; let best = null; const wantedIdx = refRow.tagStart !== undefined ? refRow.tagStart : 0; while ((m = scriptOpenRe.exec(text)) !== null) { const info = scriptSrcInfo(m[0], m.index); if (!info || info.raw !== refRow.raw) continue; const distance = Math.abs(m.index - wantedIdx); if (!best || distance < best.distance) { best = { start: info.srcStart, end: info.srcEnd, tagStart: m.index, distance: distance }; } } return best; } function findScriptTagStartForSrc(text, src) { const scriptOpenRe = /]*>/gi; let m; while ((m = scriptOpenRe.exec(text)) !== null) { const info = scriptSrcInfo(m[0], m.index); if (info && info.raw === src) return m.index; } return -1; } function migrateTraceSnippet(indent, eol) { return indent + ""; } function ensureMigrateTraceAfterMigrate(text, ctx) { if (!ctx.model.profile.jquery.migrateTrace) return { text: text, changed: false, reason: "disabled" }; if (/jQuery\s*\.\s*migrateTrace\b/.test(text) || /jQuery\s*\.\s*migrateMute\b/.test(text)) { return { text: text, changed: false, reason: "already present" }; } const re = /]*\bsrc\s*=\s*(["'])[^"']*jquery[-.]migrate[^"']*\1[^>]*>\s*<\/script>/ig; const m = re.exec(text); if (!m) return { text: text, changed: false, reason: "migrate script tag not found" }; const lineStart = text.lastIndexOf("\n", m.index) + 1; const indent = (text.slice(lineStart, m.index).match(/^[ \t]*/) || [""])[0]; const eol = ctx.eol || "\n"; const insertAt = m.index + m[0].length; return { text: text.slice(0, insertAt) + eol + migrateTraceSnippet(indent, eol) + text.slice(insertAt), changed: true, reason: "inserted after Migrate" }; } function targetOldJqueryAbsForPatchResult(model, result) { const rel = result[0]; const raw = result[1]; if (!raw || /^(https?:)?\/\//i.test(raw)) return ""; const ctx = model.ctxByRel[rel]; if (!ctx) return ""; const rr = resolveRef(raw, rel, model); if (!rr.resolved) return ""; const name = fileNameOf(rr.resolved); if (!isJqueryCoreName(name)) return ""; let ver = versionFromName(name); const root = path.resolve(model.targetWcRoot); const abs = path.resolve(model.targetWcRoot, rr.resolved.split("/").join(path.sep)); if (abs !== root && abs.indexOf(root + path.sep) !== 0) return ""; if (!exists(abs)) return ""; if (!ver) ver = sniffJqueryVersion(abs); if (!ver || !versionLt(ver, TARGET_JQUERY_FLOOR_VERSION)) return ""; return abs; } function cleanupReplacedOldJqueryFiles(model) { const byAbs = Object.create(null); model.patchResults.forEach(function (r) { if (r[2] !== "REPLACED" && r[2] !== "SKIP" && r[2] !== "MANUAL") return; const abs = targetOldJqueryAbsForPatchResult(model, r); if (!abs) return; if (!byAbs[abs]) byAbs[abs] = { replaced: 0, blocked: 0 }; if (r[2] === "REPLACED") byAbs[abs].replaced++; else byAbs[abs].blocked++; }); let removed = 0; Object.keys(byAbs).forEach(function (abs) { const info = byAbs[abs]; if (info.replaced <= 0 || info.blocked > 0) return; try { fs.unlinkSync(abs); removed++; model.patchResults.push([ toPosix(path.relative(model.targetWcRoot, abs)), path.basename(abs).toLowerCase(), "REMOVED_OLD_FILE", "removed replaced old jQuery core file from TO-BE target" ]); } catch (e) { model.patchResults.push([ toPosix(path.relative(model.targetWcRoot, abs)), path.basename(abs).toLowerCase(), "SKIP", "old jQuery core file cleanup failed: " + e.message ]); } }); return removed; } function patchJqueryCore(model) { const jq = model.profile.jquery; if (model.oldCoreRefs.length === 0) { log("patch-jquery: no old jQuery core references found"); return; } const byCtx = {}; model.oldCoreRefs.forEach(function (r) { if (!byCtx[r.ctx.rel]) byCtx[r.ctx.rel] = []; byCtx[r.ctx.rel].push(r); }); Object.keys(byCtx).forEach(function (rel) { const ctx = model.ctxByRel[rel]; const tPath = targetPathOf(model, ctx); if (!exists(tPath)) { model.patchResults.push([rel, "", "SKIP", "target file missing"]); return; } let text = readLatin1(tPath); let changed = false; let firstNewSrc = ""; byCtx[rel].forEach(function (r) { if (/^(https?:)?\/\//i.test(r.raw)) { model.patchResults.push([rel, r.raw, "MANUAL", "external/CDN url not auto-replaced"]); return; } const slash = r.raw.lastIndexOf("/"); const prefix = slash >= 0 ? r.raw.slice(0, slash + 1) : ""; const newSrc = jq.newJquerySrc || (prefix + jq.coreFile); const migSrc = jq.newMigrateSrc || (prefix + jq.migrateFile); if (!jq.newJquerySrc) { const chk = resolveRef(newSrc, rel, model); const tAbs = chk.resolved ? path.join(model.targetWcRoot, chk.resolved.split("/").join(path.sep)) : ""; if (tAbs && !exists(tAbs)) installBundledJqueryAsset(model, jq.coreFile, tAbs, "jQuery core"); if (!tAbs || !exists(tAbs)) { model.patchResults.push([rel, r.raw, "SKIP", "new core file not found in target or bundled assets: " + (chk.resolved || newSrc) + " (put " + jq.coreFile + " under WebContent/js or assets/jquery first)"]); return; } const chk2 = resolveRef(migSrc, rel, model); const tAbs2 = chk2.resolved ? path.join(model.targetWcRoot, chk2.resolved.split("/").join(path.sep)) : ""; if (tAbs2 && !exists(tAbs2)) installBundledJqueryAsset(model, jq.migrateFile, tAbs2, "jQuery Migrate"); if (!tAbs2 || !exists(tAbs2)) { model.patchResults.push([rel, r.raw, "SKIP", "migrate file not found in target or bundled assets: " + (chk2.resolved || migSrc)]); return; } } const span = findScriptSrcSpan(text, r); if (!span) { model.patchResults.push([rel, r.raw, "SKIP", "script src span not found in target text (already changed?)"]); return; } text = text.slice(0, span.start) + newSrc + text.slice(span.end); changed = true; if (!firstNewSrc) firstNewSrc = newSrc; model.patchResults.push([rel, r.raw, "REPLACED", newSrc]); addFinding(model, ctx, { idx: 0, line: r.line, category: "jquery-core-patched", pattern: "patch-jquery", priority: "AutoFixed", confidence: "High", action: "Changed", before: r.raw, after: newSrc + " (+ migrate)", reason: "old jQuery core replaced in TO-BE by patch-jquery mode", commitGroup: "JQUERY_CORE" }); }); if (changed) { if (!/jquery[-.]migrate/i.test(text) && firstNewSrc) { const lines = text.split("\n"); const tagStart = findScriptTagStartForSrc(text, firstNewSrc); if (tagStart >= 0) { const lineIdx = text.slice(0, tagStart).split("\n").length - 1; const indent = (lines[lineIdx].match(/^[ \t]*/) || [""])[0]; const slash2 = firstNewSrc.lastIndexOf("/"); const migSrc2 = jq.newMigrateSrc || (firstNewSrc.slice(0, slash2 + 1) + jq.migrateFile); const eol = ctx.eol === "\r\n" && lines[lineIdx].slice(-1) === "\r" ? "\r" : ""; lines.splice(lineIdx + 1, 0, indent + '' + eol); } text = lines.join("\n"); } const trace = ensureMigrateTraceAfterMigrate(text, ctx); if (trace.changed) { text = trace.text; model.patchResults.push([rel, "jQuery.migrateTrace", "TRACING", trace.reason]); } else if (jq.migrateTrace && trace.reason !== "already present") { model.patchResults.push([rel, "jQuery.migrateTrace", "SKIP", trace.reason]); } writeLatin1(tPath, text); model.changed[rel] = { projRel: ctx.projRel, edits: (model.changed[rel] ? model.changed[rel].edits : 0) + 1, kind: "patch-jquery" }; } }); const oldFilesRemoved = cleanupReplacedOldJqueryFiles(model); const replaced = model.patchResults.filter(function (r) { return r[2] === "REPLACED"; }).length; const skipped = model.patchResults.filter(function (r) { return r[2] === "SKIP"; }).length; log("patch-jquery: replaced=" + replaced + " skipped=" + skipped + " manual=" + model.patchResults.filter(function (r) { return r[2] === "MANUAL"; }).length + " oldFilesRemoved=" + oldFilesRemoved); if (skipped > 0) warn("some references were skipped; see patch_jquery_result.txt in report"); } function chooseProbeTargets(model) { const hints = (model.profile.probe.injectTargetHints || []).map(function (h) { return toPosix(h).toLowerCase(); }); let targets = model.pages.filter(function (p) { const rl = p.rel.toLowerCase(); return hints.some(function (h) { return rl === h || rl.slice(-h.length) === h; }); }); if (targets.length === 0) { targets = model.pages.filter(function (p) { return p.rel.toLowerCase().indexOf("web-inf/layouts/") === 0 && p.ctx.refs && p.ctx.refs.scripts.some(function (s) { return isJqueryCoreName(fileNameOf(s.raw)); }); }); } if (targets.length === 0) { targets = model.pages.filter(function (p) { return p.ctx.refs && p.ctx.refs.scripts.some(function (s) { return isJqueryCoreName(fileNameOf(s.raw)); }); }).slice(0, 5); } return targets; } function injectProbe(model) { const probeAbs = path.join(model.targetWcRoot, "js", PROBE_FILE_NAME); writeLatin1(probeAbs, genProbeJs()); log("probe written: " + probeAbs); const targets = chooseProbeTargets(model); if (targets.length === 0) { warn("probe: no injection target page found; add probe.injectTargetHints to project-profile.json"); return; } targets.forEach(function (p) { const ctx = p.ctx; const tPath = targetPathOf(model, ctx); if (!exists(tPath)) { model.probeInjections.push([p.rel, "SKIP", "target file missing"]); return; } let text = readLatin1(tPath); if (text.indexOf(PROBE_FILE_NAME) >= 0) { model.probeInjections.push([p.rel, "SKIP", "already injected"]); return; } let prefix = ""; if (ctx.refs) { const core = ctx.refs.scripts.filter(function (s) { return isJqueryCoreName(fileNameOf(s.raw)) && !/^(https?:)?\/\//i.test(s.raw); })[0]; const anyJs = core || ctx.refs.scripts.filter(function (s) { return !/^(https?:)?\/\//i.test(s.raw); })[0]; if (anyJs) { const slash = anyJs.raw.lastIndexOf("/"); prefix = slash >= 0 ? anyJs.raw.slice(0, slash + 1) : ""; } } if (!prefix) prefix = "${pageContext.request.contextPath}/js/"; const tag = ''; const bodyClose = text.search(/<\/body\s*>/i); if (bodyClose >= 0) text = text.slice(0, bodyClose) + tag + ctx.eol + text.slice(bodyClose); else text = text + ctx.eol + tag + ctx.eol; writeLatin1(tPath, text); model.changed[p.rel] = { projRel: ctx.projRel, edits: (model.changed[p.rel] ? model.changed[p.rel].edits : 0) + 1, kind: "probe" }; model.probeInjections.push([p.rel, "INJECTED", prefix + PROBE_FILE_NAME]); addFinding(model, ctx, { idx: 0, line: 0, category: "probe-injected", pattern: PROBE_FILE_NAME, priority: "Ignored", confidence: "High", action: "Changed", before: "", after: tag, reason: "runtime probe injected for verification; must be removed before production (verify-clean checks this)", commitGroup: "PROBE_ONLY" }); }); log("probe injected into " + model.probeInjections.filter(function (r) { return r[1] === "INJECTED"; }).length + " page(s)"); } function genProbeJs() { const L = []; L.push("(function(){"); L.push("if (window.__JQ35_PROBE__) { return; }"); L.push("window.__JQ35_PROBE__ = true;"); L.push("var MARKER = '" + PROBE_MARKER + "';"); L.push("var logs = [];"); L.push("var t0 = new Date().getTime();"); L.push("function stamp(){ return String(new Date().getTime() - t0); }"); L.push("function push(level, msg){ try { logs.push('[' + stamp() + 'ms][' + level + '] ' + msg); if (logs.length > 800) { logs.shift(); } refreshSoon(); } catch(e){} }"); L.push("function fmt(args){ var out = []; var i; for (i = 0; i < args.length; i++) { var a = args[i]; if (a === null) { out.push('null'); } else if (typeof a === 'undefined') { out.push('undefined'); } else if (typeof a === 'object') { try { out.push(JSON.stringify(a)); } catch(e) { out.push(String(a)); } } else { out.push(String(a)); } } return out.join(' '); }"); L.push("var origWarn = window.console && console.warn ? console.warn : null;"); L.push("var origError = window.console && console.error ? console.error : null;"); L.push("var origLog = window.console && console.log ? console.log : null;"); L.push("if (!window.console) { window.console = {}; }"); L.push("console.warn = function(){ var m = fmt(arguments); push(m.indexOf('JQMIGRATE') === 0 ? 'JQMIGRATE' : 'WARN', m); if (origWarn) { try { origWarn.apply(console, arguments); } catch(e){} } };"); L.push("console.error = function(){ push('ERROR', fmt(arguments)); if (origError) { try { origError.apply(console, arguments); } catch(e){} } };"); L.push("console.log = function(){ var m = fmt(arguments); if (m.indexOf('JQMIGRATE') === 0) { push('JQMIGRATE', m); } if (origLog) { try { origLog.apply(console, arguments); } catch(e){} } };"); L.push("var prevOnError = window.onerror;"); L.push("window.onerror = function(msg, src, line, col, err){ push('JSERROR', msg + ' @ ' + src + ':' + line + (col ? ':' + col : '')); if (prevOnError) { try { return prevOnError.apply(window, arguments); } catch(e){} } return false; };"); L.push("function jqInfo(){ var o = { jquery: '(none)', migrate: '(none)', ui: '(none)', jqgrid: 'N', select2: 'N', autoNumeric: 'N' }; try { var jq = window.jQuery; if (jq) { o.jquery = jq.fn && jq.fn.jquery ? jq.fn.jquery : 'unknown'; if (jq.migrateVersion) { o.migrate = jq.migrateVersion; } if (jq.ui && jq.ui.version) { o.ui = jq.ui.version; } if (jq.fn && jq.fn.jqGrid) { o.jqgrid = 'Y'; } if (jq.jgrid) { o.jqgrid = 'Y'; } if (jq.fn && jq.fn.select2) { o.select2 = 'Y'; } if (jq.fn && jq.fn.autoNumeric) { o.autoNumeric = 'Y'; } } if (window.AutoNumeric) { o.autoNumeric = 'Y'; } } catch(e){} return o; }"); L.push("function scriptList(){ var out = []; try { var ss = document.getElementsByTagName('script'); var i; for (i = 0; i < ss.length; i++) { if (ss[i].src) { out.push(ss[i].src); } } } catch(e){} return out; }"); L.push("function buildText(){ var o = jqInfo(); var lines = []; lines.push(MARKER); lines.push('URL=' + window.location.href); lines.push('time=' + new Date().toString()); lines.push('jQuery=' + o.jquery); lines.push('Migrate=' + o.migrate); lines.push('jQueryUI=' + o.ui); lines.push('jqGrid detected=' + o.jqgrid); lines.push('select2 detected=' + o.select2); lines.push('autoNumeric detected=' + o.autoNumeric); lines.push(''); lines.push('[logs ' + logs.length + ']'); var i; for (i = 0; i < logs.length; i++) { lines.push(logs[i]); } lines.push(''); lines.push('[scripts]'); var sc = scriptList(); for (i = 0; i < sc.length; i++) { lines.push(sc[i]); } return lines.join('\\r\\n'); }"); L.push("var panel = null; var ta = null; var badge = null; var timer = null;"); L.push("function refreshSoon(){ if (timer) { return; } timer = window.setTimeout(function(){ timer = null; refresh(); }, 400); }"); L.push("function refresh(){ if (ta) { ta.value = buildText(); } if (badge) { var errs = 0; var migs = 0; var i; for (i = 0; i < logs.length; i++) { if (logs[i].indexOf('[JSERROR]') >= 0 || logs[i].indexOf('[ERROR]') >= 0 || logs[i].indexOf('[AJAXERROR]') >= 0) { errs++; } if (logs[i].indexOf('[JQMIGRATE]') >= 0) { migs++; } } badge.innerHTML = 'JQ35 E:' + errs + ' M:' + migs; badge.style.background = errs > 0 ? '#b00020' : (migs > 0 ? '#b26a00' : '#1b5e20'); } }"); L.push("function sendLog(){ try { var xhr = new XMLHttpRequest(); xhr.open('POST', '/__probe/log', true); xhr.setRequestHeader('Content-Type', 'text/plain'); xhr.onreadystatechange = function(){ if (xhr.readyState === 4) { push('PROBE', 'send status=' + xhr.status); } }; xhr.send(buildText()); } catch(e) { push('PROBE', 'send failed: ' + e.message); } }"); L.push("function copyLog(){ try { ta.focus(); ta.select(); var ok = document.execCommand('copy'); push('PROBE', 'copy=' + ok); } catch(e) { push('PROBE', 'copy failed, select manually'); } }"); L.push("function buildPanel(){ if (panel) { return; } if (!document.body) { return; }"); L.push("badge = document.createElement('div');"); L.push("badge.style.cssText = 'position:fixed;right:8px;bottom:8px;z-index:999999;background:#1b5e20;color:#fff;font:12px/1.6 monospace;padding:3px 10px;cursor:pointer;border-radius:3px;';"); L.push("badge.innerHTML = 'JQ35';"); L.push("panel = document.createElement('div');"); L.push("panel.style.cssText = 'position:fixed;right:8px;bottom:36px;z-index:999999;width:560px;max-width:95%;background:#111;color:#eee;border:1px solid #555;display:none;font:12px monospace;padding:6px;';"); L.push("var bar = document.createElement('div');"); L.push("function mkBtn(txt, fn){ var b = document.createElement('button'); b.innerHTML = txt; b.style.cssText = 'margin:0 4px 4px 0;font:12px monospace;padding:2px 8px;'; if (b.attachEvent) { b.attachEvent('onclick', fn); } else { b.addEventListener('click', fn, false); } return b; }"); L.push("bar.appendChild(mkBtn('Refresh', refresh));"); L.push("bar.appendChild(mkBtn('Copy', copyLog));"); L.push("bar.appendChild(mkBtn('Send', sendLog));"); L.push("bar.appendChild(mkBtn('Clear', function(){ logs = []; refresh(); }));"); L.push("panel.appendChild(bar);"); L.push("ta = document.createElement('textarea');"); L.push("ta.readOnly = true;"); L.push("ta.style.cssText = 'width:100%;height:320px;background:#000;color:#0f0;font:11px monospace;border:1px solid #444;';"); L.push("panel.appendChild(ta);"); L.push("document.body.appendChild(panel);"); L.push("document.body.appendChild(badge);"); L.push("var toggle = function(){ panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; refresh(); };"); L.push("if (badge.attachEvent) { badge.attachEvent('onclick', toggle); } else { badge.addEventListener('click', toggle, false); }"); L.push("refresh(); }"); L.push("function hookAjax(){ try { if (window.jQuery && window.jQuery.fn) { jQuery(document).ajaxError(function(ev, xhr, settings, err){ push('AJAXERROR', (settings ? settings.url : '?') + ' status=' + (xhr ? xhr.status : '?') + ' ' + (err || '')); }); push('PROBE', 'jQuery=' + jQuery.fn.jquery + ' migrate=' + (jQuery.migrateVersion || 'none')); } else { push('PROBE', 'jQuery not present'); } } catch(e) { push('PROBE', 'ajax hook failed: ' + e.message); } }"); L.push("function onReady(){ buildPanel(); hookAjax(); refresh(); }"); L.push("if (document.readyState === 'complete' || document.readyState === 'interactive') { window.setTimeout(onReady, 200); } else if (window.addEventListener) { window.addEventListener('load', onReady, false); } else if (window.attachEvent) { window.attachEvent('onload', onReady); }"); L.push("push('PROBE', MARKER + ' loaded');"); L.push("})();"); return L.join("\n"); } function findingRow(f) { return [f.abs, f.rel, fileNameOf(f.rel), f.line, f.category, f.pattern, f.priority, f.confidence, f.action, f.before, f.after || f.suggestion, f.reason, f.lib, f.thirdParty, f.commitGroup]; } const FINDING_HEADER = ["FilePath", "RelativePath", "FileName", "LineNumber", "Category", "Pattern", "Priority", "Confidence", "Action", "Before", "After", "Reason", "LibraryGuess", "ThirdParty", "CommitGroup"]; function incCount(obj, key, by) { obj[key] = (obj[key] || 0) + (by || 1); } function findingCategorySummary(model) { const by = Object.create(null); model.findings.forEach(function (f) { const k = f.category || "unknown"; if (!by[k]) by[k] = { category: k, total: 0, critical: 0, xss: 0, manual: 0, review: 0, auto: 0, vendor: 0, staticLow: 0, files: Object.create(null) }; const r = by[k]; r.total++; r.files[f.rel] = 1; if (f.priority === "Critical") r.critical++; else if (f.priority === "XssHigh") r.xss++; else if (f.priority === "Manual") r.manual++; else if (f.priority === "Review") r.review++; else if (f.priority === "AutoFixed" || f.priority === "AutoInferred") r.auto++; else if (f.priority === "VendorReview") r.vendor++; else if (f.priority === "StaticHtmlLow") r.staticLow++; }); return Object.keys(by).map(function (k) { const r = by[k]; r.fileCount = Object.keys(r.files).length; r.risk = r.critical * 100 + r.xss * 80 + r.manual * 60 + r.review * 40 + r.vendor * 25 + r.auto * 10; return r; }).sort(function (a, b) { return b.risk - a.risk || b.total - a.total || (a.category < b.category ? -1 : 1); }); } function dirPrefixOf(rel, depth) { const parts = toPosix(rel).split("/").filter(Boolean); if (parts.length <= 1) return "(root)"; const dirs = parts.slice(0, Math.min(depth, parts.length - 1)); return dirs.length ? dirs.join("/") : "(root)"; } function directoryRiskSummary(model) { const by = Object.create(null); [1, 2, 3, 4].forEach(function (depth) { model.findings.forEach(function (f) { const prefix = dirPrefixOf(f.rel, depth); const k = depth + "|" + prefix; if (!by[k]) by[k] = { depth: depth, prefix: prefix, total: 0, blockers: 0, critical: 0, xss: 0, manual: 0, review: 0, auto: 0, vendor: 0, files: Object.create(null), categories: Object.create(null) }; const r = by[k]; r.total++; r.files[f.rel] = 1; incCount(r.categories, f.category || "unknown", 1); if (f.priority === "Critical") { r.critical++; r.blockers++; } else if (f.priority === "XssHigh") { r.xss++; r.blockers++; } else if (f.priority === "Manual") { r.manual++; r.blockers++; } else if (f.priority === "Review") { r.review++; r.blockers++; } else if (f.priority === "AutoFixed" || f.priority === "AutoInferred") r.auto++; else if (f.priority === "VendorReview") { r.vendor++; r.blockers++; } }); }); return Object.keys(by).map(function (k) { const r = by[k]; r.fileCount = Object.keys(r.files).length; r.topCategories = Object.keys(r.categories).sort(function (a, b) { return r.categories[b] - r.categories[a] || (a < b ? -1 : 1); }).slice(0, 5).map(function (c) { return c + ":" + r.categories[c]; }).join(" | "); r.risk = r.critical * 100 + r.xss * 80 + r.manual * 60 + r.review * 40 + r.vendor * 25 + r.auto * 10; return r; }).sort(function (a, b) { return b.risk - a.risk || b.blockers - a.blockers || b.total - a.total || a.depth - b.depth || (a.prefix < b.prefix ? -1 : 1); }); } function writeCsvReports(model) { const R = model.reportRoot; ensureDir(R); const cnt = model.counters; writeCsv(path.join(R, "summary.csv"), ["Key", "Value"], Object.keys(cnt).map(function (k) { return [k, cnt[k]]; })); writeCsv(path.join(R, "apiFindings.csv"), FINDING_HEADER, model.findings.map(findingRow)); writeCsv(path.join(R, "findingCategorySummary.csv"), ["Category", "Total", "Critical", "XssHigh", "Manual", "Review", "Auto", "VendorReview", "StaticHtmlLow", "FileCount"], findingCategorySummary(model).map(function (r) { return [r.category, r.total, r.critical, r.xss, r.manual, r.review, r.auto, r.vendor, r.staticLow, r.fileCount]; })); writeCsv(path.join(R, "directoryRiskSummary.csv"), ["Depth", "DirectoryPrefix", "TotalFindings", "Blockers", "Critical", "XssHigh", "Manual", "Review", "Auto", "VendorReview", "FileCount", "TopCategories"], directoryRiskSummary(model).map(function (r) { return [r.depth, r.prefix, r.total, r.blockers, r.critical, r.xss, r.manual, r.review, r.auto, r.vendor, r.fileCount, r.topCategories]; })); writeCsv(path.join(R, "critical.csv"), ["FilePath", "RelativePath", "LineNumber", "OldSrc", "Version", "Recommendation"], model.oldCoreRefs.map(function (r) { return [r.ctx.abs, r.page, r.line, r.raw, r.meta.ver, "replace with " + model.profile.jquery.coreFile + " + " + model.profile.jquery.migrateFile + " (patch-jquery mode)"]; })); writeCsv(path.join(R, "focusQueue.csv"), ["Rank", "RelativePath", "LineNumber", "Category", "Priority", "Confidence", "Pattern", "Reason", "Suggestion"], model.focus.map(function (f, i) { return [i + 1, f.rel, f.line, f.category, f.priority, f.confidence, f.pattern, f.reason, f.suggestion || f.after]; })); writeCsv(path.join(R, "manualQueue.csv"), FINDING_HEADER, model.findings.filter(function (f) { return (f.priority === "Manual" || f.priority === "Review") && f.thirdParty !== "Y"; }).map(findingRow)); writeCsv(path.join(R, "autoFixed.csv"), FINDING_HEADER, model.findings.filter(function (f) { return (f.priority === "AutoFixed" || f.priority === "AutoInferred") && f.action === "Changed"; }).map(findingRow)); writeCsv(path.join(R, "vendorReview.csv"), FINDING_HEADER, model.findings.filter(function (f) { return f.priority === "VendorReview"; }).map(findingRow)); writeCsv(path.join(R, "xssHigh.csv"), FINDING_HEADER, model.findings.filter(function (f) { return f.priority === "XssHigh"; }).map(findingRow)); writeCsv(path.join(R, "staticHtmlLow.csv"), FINDING_HEADER, model.findings.filter(function (f) { return f.priority === "StaticHtmlLow"; }).map(findingRow)); writeCsv(path.join(R, "jqueryLoads.csv"), ["PagePath", "LineNumber", "ScriptSrcRaw", "ScriptSrcResolved", "Library", "Version", "IsOldBelow350", "Resolved"], model.jqueryLoadRows.map(function (r) { return [r.page, r.line, r.raw, r.resolved, r.meta.isMigrate ? "jquery-migrate" : "jquery-core", r.meta.ver, r.meta.isOld ? "Y" : "N", r.exists ? "Y" : "N"]; })); writeCsv(path.join(R, "scriptInventory.csv"), ["RelativePath", "LibraryGuess", "VersionGuess", "IsVendor", "IsMinified", "RiskNote"], model.scriptInv); writeCsv(path.join(R, "pluginInventory.csv"), ["RelativePath", "LibraryGuess", "VersionGuess", "Evidence", "RiskLevel", "Recommendation"], model.pluginInv); writeCsv(path.join(R, "directoryInventory.csv"), ["RelativePath", "Type", "Extension", "Size", "LibraryGuess", "IsVendor", "IsPage", "IsScript", "IsStyle"], model.dirInv); writeCsv(path.join(R, "jspPages.csv"), ["PagePath", "DirectScriptCount", "EffectiveScriptCount", "DirectCssCount", "EffectiveCssCount", "HasJqueryCore", "JqueryCoreCount", "JqueryCoreVersion", "HasOldJqueryBelow350", "HasMigrate", "MigrateAfterJquery", "RiskMultipleJquery", "RiskOldJquery", "RiskMigrateMissing"], model.pages.map(function (p) { return [p.rel, p.directScripts, p.effectiveScripts, p.directCss, p.effectiveCss, p.hasCore ? "Y" : "N", p.coreCount, p.coreVer, p.oldCore ? "Y" : "N", p.hasMigrate ? "Y" : "N", p.migrateAfter, p.riskMultiCore ? "Y" : "N", p.riskOldCore ? "Y" : "N", p.riskMigrateMissing ? "Y" : "N"]; })); writeCsv(path.join(R, "jspIncludes.csv"), ["ParentPage", "IncludeType", "IncludeTargetRaw", "IncludeTargetResolved", "Resolved", "Reason"], model.includeRows.map(function (r) { return [r.page, r.type, r.raw, r.resolved, r.ok ? "Y" : "N", r.reason]; })); writeCsv(path.join(R, "pageScriptMap.csv"), ["PagePath", "LineNumber", "ScriptSrcRaw", "ScriptSrcResolved", "LibraryGuess", "VersionGuess", "IsJqueryCore", "IsOldJqueryBelow350", "IsMigrate", "Resolved"], model.pageScriptRows.map(function (r) { return [r.page, r.line, r.raw, r.resolved, r.meta.lib, r.meta.ver, r.meta.isCore ? "Y" : "N", r.meta.isOld ? "Y" : "N", r.meta.isMigrate ? "Y" : "N", r.exists ? "Y" : "N"]; })); writeCsv(path.join(R, "pageScriptEffective.csv"), ["PagePath", "SourcePage", "ScriptSrcRaw", "ScriptSrcResolved", "EffectiveOrder", "LibraryGuess", "VersionGuess", "IsJqueryCore", "IsOldJqueryBelow350", "IsMigrate"], model.effectiveRows.map(function (r) { return [r.page, r.srcPage, r.raw, r.resolved, r.order, r.lib, r.ver, r.isCore ? "Y" : "N", r.isOld ? "Y" : "N", r.isMigrate ? "Y" : "N"]; })); writeCsv(path.join(R, "pageCssMap.csv"), ["PagePath", "LineNumber", "CssHrefRaw", "CssHrefResolved", "LibraryGuess", "VersionGuess", "Resolved"], model.pageCssRows.map(function (r) { return [r.page, r.line, r.raw, r.resolved, r.lib, r.ver, r.exists ? "Y" : "N"]; })); writeCsv(path.join(R, "unresolvedRefs.csv"), ["PagePath", "RefType", "RawRef", "Reason"], model.unresolvedRows.map(function (r) { return [r.page, r.type, r.raw, r.reason]; })); writeCsv(path.join(R, "ajaxEndpoints.csv"), ["RelativePath", "LineNumber", "MethodGuess", "UrlRaw", "UrlNormalized", "Dynamic", "Confidence", "MockRecommendation"], model.ajaxRows.map(function (r) { return [r.rel, r.line, r.method, r.urlRaw, r.urlNorm, r.dynamic, r.confidence, r.mock]; })); writeCsv(path.join(R, "serverEndpoints.csv"), ["RelativePath", "LineNumber", "HttpMethod", "RoutePath", "ControllerClass", "ControllerMethod", "ResponseBody", "Params", "Evidence"], model.serverEndpointRows.map(function (r) { return [r.rel, r.line, r.httpMethod, r.path, r.className, r.methodName, r.responseBody, r.params, r.evidence]; })); writeCsv(path.join(R, "serverEvidence.csv"), ["RelativePath", "LineNumber", "EvidenceType", "Value", "Detail"], model.serverEvidenceRows); writeCsv(path.join(R, "ajaxToServerMap.csv"), ["RelativePath", "LineNumber", "AjaxMethod", "AjaxUrl", "Matched", "ServerMethod", "ServerPath", "Handler", "EvidenceFile", "EvidenceLine", "Confidence", "Note"], model.ajaxServerRows.map(function (r) { return [r.rel, r.line, r.ajaxMethod, r.ajaxUrl, r.matched, r.serverMethod, r.serverPath, r.handler, r.evidenceFile, r.evidenceLine, r.confidence, r.note]; })); writeCsv(path.join(R, "uiElementInventory.csv"), ["PagePath", "LineNumber", "Tag", "Id", "Name", "Class", "Type", "Text", "Value", "OnClick", "OnChange", "Role"], model.uiElementRows.map(function (e) { return [e.page, e.line, e.tag, e.id, e.name, e.className, e.type, e.text, e.value, e.onclick, e.onchange, e.role]; })); writeCsv(path.join(R, "selectorElementMap.csv"), ["FindingRank", "RelativePath", "LineNumber", "Category", "Priority", "Selector", "SelectorKind", "PagePath", "MatchedElement", "Role", "Confidence", "Note"], model.selectorElementRows.map(function (r) { return [r.findingRank, r.rel, r.line, r.category, r.priority, r.selector, r.selectorKind, r.page, r.element, r.role, r.confidence, r.note]; })); writeCsv(path.join(R, "runtimeScenarios.csv"), ["ScenarioId", "Stage", "PagePath", "FindingPath", "LineNumber", "Category", "Priority", "Confidence", "Selector", "UiTarget", "Role", "Why", "Action", "PassWhen", "FailWhen", "ProbeCheck", "CodeOnlyCheck", "ChromeSmoke", "IeFinalSample"], model.runtimeScenarios.map(function (r) { return [r.id, r.stage, r.page, r.findingRel, r.line, r.category, r.priority, r.confidence, r.selector, r.uiTarget, r.role, r.why, r.action, r.passWhen, r.failWhen, r.probeCheck, r.codeOnlyCheck, r.chromeSmoke, r.ieFinalSample]; })); writeCsv(path.join(R, "runtimeParity.csv"), ["RuntimeParityId", "ScenarioId", "Stage", "PagePath", "FindingPath", "LineNumber", "Category", "Priority", "ParityLevel", "ValidationLane", "LocalLabConfidence", "NeedsSpringTomcat", "NeedsIeMode", "Reason", "CodeOnlyCheck", "ChromeSmoke", "IeFinalSample", "RecommendedCheck"], model.runtimeParityRows.map(function (r) { return [r.id, r.scenarioId, r.stage, r.page, r.findingRel, r.line, r.category, r.priority, r.level, r.validationLane, r.localLabConfidence, r.needsSpringTomcat, r.needsIeMode, r.reason, r.codeOnlyCheck, r.chromeSmoke, r.ieFinalSample, r.recommendation]; })); writeCsv(path.join(R, "ieModeRisk.csv"), ["PagePath", "ParityLevel", "ValidationLane", "LocalLabConfidence", "NeedsSpringTomcat", "NeedsIeMode", "RiskScore", "MappedFindings", "RuntimeScenarios", "AjaxCount", "VendorSignals", "Reasons", "ChromeSmoke", "IeFinalSample"], model.ieModePageRows.map(function (r) { return [r.page, r.level, r.validationLane, r.localLabConfidence, r.needsSpringTomcat, r.needsIeMode, r.score, r.findings, r.scenarios, r.ajax, r.vendors, r.reasons, r.chromeSmoke, r.ieFinalSample]; })); writeUtf8(path.join(R, "runtime_scenarios.json"), JSON.stringify({ tool: TOOL_NAME, version: TOOL_VERSION, generated: true, validationLanes: RUNTIME_VALIDATION_LANES, uiElementCount: model.uiElementRows.length, selectorElementRows: model.selectorElementRows.map(function (r) { return { findingRank: r.findingRank, rel: r.rel, line: r.line, category: r.category, priority: r.priority, selector: r.selector, selectorKind: r.selectorKind, page: r.page, element: r.element, role: r.role, confidence: r.confidence, note: r.note }; }), scenarios: model.runtimeScenarios }, null, 2) + "\n", false); writeRuntimeScenariosHtml(model); writeRuntimeParityHtml(model); writeUtf8(path.join(R, "hermes_server_evidence.json"), JSON.stringify({ generated: true, serverFiles: model.serverFiles, endpoints: model.serverEndpointRows, evidence: model.serverEvidenceRows, ajaxToServer: model.ajaxServerRows }, null, 2) + "\n", false); writeCsv(path.join(R, "jsSyntax.csv"), ["RelativePath", "Result", "Reason"], model.syntaxRows.map(function (r) { return [r.rel, r.result, r.reason]; })); writeCsv(path.join(R, "completeByAutoFix.csv"), ["RelativePath", "Result", "TotalFindings", "AutoFixed", "AutoInferred", "StaticHtmlLow", "Critical", "Manual", "Review", "XssHigh", "VendorReview", "Reason"], model.completeRows); writeCsv(path.join(R, "needsWorkByFile.csv"), ["RelativePath", "Critical", "XssHigh", "Manual", "Review", "VendorReview", "Categories"], model.needsRows); writeCsv(path.join(R, "changedFiles.csv"), ["RelativePath", "ProjectRelativePath", "EditCount", "Kind"], Object.keys(model.changed).sort().map(function (rel) { const c = model.changed[rel]; return [rel, c.projRel, c.edits, c.kind]; })); if (model.probeInjections.length > 0) { writeCsv(path.join(R, "probe_injection_map.csv"), ["PagePath", "Result", "Detail"], model.probeInjections); } if (model.patchResults.length > 0) { writeUtf8(path.join(R, "patch_jquery_result.txt"), model.patchResults.map(function (r) { return r[2] + "\t" + r[0] + "\t" + r[1] + "\t" + r[3]; }).join("\r\n") + "\r\n", true); } } function xlsCell(v) { const s = String(v == null ? "" : v); const isNum = /^-?\d+(\.\d+)?$/.test(s) && s.length < 15; return '' + xmlEsc(s) + ""; } function xlsSheet(name, header, rows) { const out = ['']; out.push("" + header.map(function (h) { return '' + xmlEsc(h) + ""; }).join("") + ""); rows.forEach(function (r) { out.push("" + r.map(xlsCell).join("") + ""); }); out.push("
"); return out.join("\n"); } function writeXls(model) { const cnt = model.counters; const sheets = []; sheets.push(xlsSheet("Summary", ["Key", "Value"], Object.keys(cnt).map(function (k) { return [k, cnt[k]]; }))); sheets.push(xlsSheet("FocusQueue", ["Rank", "RelativePath", "Line", "Category", "Priority", "Reason", "Suggestion"], model.focus.slice(0, 2000).map(function (f, i) { return [i + 1, f.rel, f.line, f.category, f.priority, f.reason, f.suggestion || f.after]; }))); sheets.push(xlsSheet("Critical", ["RelativePath", "Line", "OldSrc", "Version"], model.oldCoreRefs.map(function (r) { return [r.page, r.line, r.raw, r.meta.ver]; }))); sheets.push(xlsSheet("ApiFindings", FINDING_HEADER.slice(1), model.findings.slice(0, 5000).map(function (f) { return findingRow(f).slice(1); }))); sheets.push(xlsSheet("CategorySummary", ["Category", "Total", "Critical", "XssHigh", "Manual", "Review", "Auto", "VendorReview", "Files"], findingCategorySummary(model).slice(0, 2000).map(function (r) { return [r.category, r.total, r.critical, r.xss, r.manual, r.review, r.auto, r.vendor, r.fileCount]; }))); sheets.push(xlsSheet("DirectoryRisk", ["Depth", "DirectoryPrefix", "Total", "Blockers", "Critical", "XssHigh", "Manual", "Review", "Auto", "Vendor", "Files", "TopCategories"], directoryRiskSummary(model).slice(0, 2000).map(function (r) { return [r.depth, r.prefix, r.total, r.blockers, r.critical, r.xss, r.manual, r.review, r.auto, r.vendor, r.fileCount, r.topCategories]; }))); sheets.push(xlsSheet("JspPages", ["PagePath", "EffScripts", "CoreCount", "CoreVer", "OldCore", "Migrate", "MigrateAfter"], model.pages.slice(0, 2000).map(function (p) { return [p.rel, p.effectiveScripts, p.coreCount, p.coreVer, p.oldCore ? "Y" : "N", p.hasMigrate ? "Y" : "N", p.migrateAfter]; }))); sheets.push(xlsSheet("PluginInventory", ["RelativePath", "Library", "Version", "RiskLevel", "Recommendation"], model.pluginInv.slice(0, 1000).map(function (r) { return [r[0], r[1], r[2], r[4], r[5]]; }))); sheets.push(xlsSheet("ServerEndpoints", ["RelativePath", "Line", "Method", "Route", "Handler", "ResponseBody"], model.serverEndpointRows.slice(0, 2000).map(function (r) { return [r.rel, r.line, r.httpMethod, r.path, r.className + "#" + r.methodName, r.responseBody]; }))); sheets.push(xlsSheet("AjaxToServer", ["RelativePath", "Line", "Ajax", "Matched", "Handler", "Confidence"], model.ajaxServerRows.slice(0, 2000).map(function (r) { return [r.rel, r.line, r.ajaxMethod + " " + r.ajaxUrl, r.matched, r.handler, r.confidence]; }))); sheets.push(xlsSheet("RuntimeScenarios", ["ScenarioId", "Stage", "Page", "Finding", "Category", "Priority", "Target", "Action", "PassWhen", "ChromeSmoke", "IeFinalSample"], model.runtimeScenarios.slice(0, 2000).map(function (r) { return [r.id, stageLabel(r.stage), r.page, r.findingRel + ":" + r.line, r.category, r.priority, r.uiTarget || r.selector, r.action, r.passWhen, r.chromeSmoke, r.ieFinalSample]; }))); sheets.push(xlsSheet("RuntimeParity", ["Id", "Scenario", "Stage", "Page", "Finding", "Category", "Priority", "Level", "ValidationLane", "LocalConfidence", "Spring", "IE", "Reason"], model.runtimeParityRows.slice(0, 2000).map(function (r) { return [r.id, r.scenarioId, stageLabel(r.stage), r.page, r.findingRel + ":" + r.line, r.category, r.priority, r.level, r.validationLane, r.localLabConfidence, r.needsSpringTomcat, r.needsIeMode, r.reason]; }))); sheets.push(xlsSheet("IeModeRisk", ["Page", "Level", "ValidationLane", "LocalConfidence", "Spring", "IE", "Score", "Findings", "Scenarios", "Ajax", "Vendors", "Reasons"], model.ieModePageRows.slice(0, 2000).map(function (r) { return [r.page, r.level, r.validationLane, r.localLabConfidence, r.needsSpringTomcat, r.needsIeMode, r.score, r.findings, r.scenarios, r.ajax, r.vendors, r.reasons]; }))); sheets.push(xlsSheet("SelectorMap", ["Finding", "Selector", "Page", "Element", "Role", "Confidence"], model.selectorElementRows.slice(0, 2000).map(function (r) { return [r.rel + ":" + r.line, r.selector, r.page, r.element, r.role, r.confidence]; }))); const doc = ['', '', '', '', sheets.join("\n"), ""].join("\n"); writeUtf8(path.join(model.reportRoot, "jquery35_report.xls"), doc, false); } function kpiCard(label, value, color) { return '
' + htmlEsc(value) + '
' + htmlEsc(label) + "
"; } function reportLink(file, label) { return '' + htmlEsc(label || file) + ""; } function tableHtml(header, rows, rawCells) { let h = '
'; header.forEach(function (x) { h += ""; }); h += ""; rows.forEach(function (r) { h += ""; r.forEach(function (c) { const raw = String(c); const allowed = rawCells && (/^
/.test(raw) || /^" + (allowed ? raw : htmlEsc(c)) + ""; }); h += "
"; }); return h + "
" + htmlEsc(x) + "
"; } function stageLabel(key) { if (key === "min") return "1차 최소"; if (key === "compat") return "2차 안정화"; if (key === "max") return "3차 최대/후속"; return key || ""; } function writeRuntimeScenariosHtml(model) { const parts = []; parts.push("Runtime validation scenarios
"); parts.push("
" + htmlEsc(TOOL_NAME + " v" + TOOL_VERSION) + "

Runtime 검증 시나리오

정적 스캔 후보를 JSP/HTML 요소와 연결해 1망 code-only, 2망 Chrome smoke, Edge IE mode final sample 확인 동작으로 나눈 목록입니다.
scenarios " + htmlEsc(model.runtimeScenarios.length) + "ui elements " + htmlEsc(model.uiElementRows.length) + "selector rows " + htmlEsc(model.selectorElementRows.length) + "
"); parts.push("
" + RUNTIME_VALIDATION_LANES.map(function (x) { return htmlEsc(x.label + ": " + x.description); }).join("
") + "
"); parts.push("
"); model.runtimeScenarios.slice(0, 300).forEach(function (s) { parts.push("
" + htmlEsc(s.id) + " " + htmlEsc(stageLabel(s.stage)) + "
" + htmlEsc(s.page || s.findingRel) + "
" + htmlEsc(s.priority) + "
" + htmlEsc(s.category) + "
" + htmlEsc(s.confidence) + "
"); parts.push("
근거" + htmlEsc(s.findingRel + ":" + s.line) + "selector" + htmlEsc(s.selector || "-") + "대상" + htmlEsc(s.uiTarget || "-") + "역할" + htmlEsc(s.role || "-") + "
"); parts.push("
동작
" + htmlEsc(s.action) + "
"); parts.push("
통과" + htmlEsc(s.passWhen) + "실패" + htmlEsc(s.failWhen) + "코드-only" + htmlEsc(s.codeOnlyCheck) + "Chrome" + htmlEsc(s.chromeSmoke) + "IE final" + htmlEsc(s.ieFinalSample) + "Probe" + htmlEsc(s.probeCheck) + "
"); }); parts.push("

복붙용 압축 텍스트

"); model.runtimeScenarios.slice(0, 120).forEach(function (s) { parts.push(htmlEsc([s.id, stageLabel(s.stage), s.page || s.findingRel, s.category, s.priority, "DO=" + s.action, "PASS=" + s.passWhen, "CHROME=" + s.chromeSmoke, "IE=" + s.ieFinalSample].join(" | "))); }); parts.push("
"); writeUtf8(path.join(model.reportRoot, "runtime_scenarios.html"), parts.join("\n"), false); } function parityTone(level) { if (level === "IE_MODE_REQUIRED") return "bad"; if (level === "SPRING_TOMCAT_REQUIRED") return "warn"; return "ok"; } function writeRuntimeParityHtml(model) { const c = model.counters; const parts = []; parts.push("Runtime parity analyzer
"); parts.push("
" + htmlEsc(TOOL_NAME + " v" + TOOL_VERSION) + "

Runtime Parity Analyzer

1망 code-only/static 산출물, 2망 Chrome smoke, Edge IE mode final sample이 각각 어디까지 필요한지 분리합니다. 이 판정은 취약점 자동수정 여부를 바꾸지 않고 검증 범위만 줄이는 보조 기준입니다.
rows " + htmlEsc(c.RuntimeParityRows) + "pages " + htmlEsc(model.ieModePageRows.length) + "
"); parts.push('
' + htmlEsc(c.RuntimeParityLocalOk) + 'code-only/Local Lab 종료 후보
' + htmlEsc(c.RuntimeChromeSmokeRequired) + 'Chrome smoke 필요
' + htmlEsc(c.RuntimeIeFinalSampleRequired) + 'IE final sample 필요
' + htmlEsc(c.IeModeRiskPages) + 'IE final 후보 페이지
'); parts.push('
기준: 구조적 jQuery API 전환(.on/.off/.prop/.length 등)은 1망 code-only와 Local Lab 신뢰도가 높습니다. AJAX 응답, JSP include/Tiles, 서버 데이터 기반 DOM sink는 2망 Chrome smoke가 필요합니다. jqGrid/legacy plugin, ActiveX/object, iframe, popup, file upload, IE 분기 코드는 Chrome smoke 후 Edge IE mode 최종 샘플링 대상으로 올립니다.
'); parts.push("

페이지별 환경 리스크

"); parts.push(tableHtml(["페이지", "판정", "검증레인", "Local 신뢰", "Spring", "IE", "점수", "시나리오", "AJAX", "벤더/신호", "사유", "Chrome", "IE final"], model.ieModePageRows.slice(0, 80).map(function (r) { return [ r.page, '' + htmlEsc(r.level) + "", r.validationLane, r.localLabConfidence, r.needsSpringTomcat, r.needsIeMode, r.score, r.scenarios, r.ajax, r.vendors || "-", r.reasons || "-", r.chromeSmoke, r.ieFinalSample ]; }), true)); parts.push("

항목별 parity 판정

"); parts.push(tableHtml(["ID", "시나리오", "단계", "페이지", "근거", "유형", "판정", "검증레인", "Spring", "IE", "사유", "권장 확인"], model.runtimeParityRows.slice(0, 200).map(function (r) { return [ r.id, r.scenarioId || "-", stageLabel(r.stage), r.page || "-", r.findingRel + ":" + r.line, r.category + " / " + r.priority, '' + htmlEsc(r.level) + "", r.validationLane, r.needsSpringTomcat, r.needsIeMode, r.reason, r.recommendation ]; }), true)); parts.push("
"); writeUtf8(path.join(model.reportRoot, "runtime_parity.html"), parts.join("\n"), false); } function scriptJson(v) { return JSON.stringify(v).replace(//g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); } function snippetRows(text, line, contextLines) { const lines = String(text || "").split("\n"); let center = parseInt(line, 10); if (!Number.isFinite(center) || center < 1) center = 1; const lo = Math.max(1, center - contextLines); const hi = Math.min(lines.length, center + contextLines); const out = []; for (let ln = lo; ln <= hi; ln++) { out.push({ n: ln, hit: ln === center, text: trunc(String(lines[ln - 1] || "").replace(/\r$/, ""), 500) }); } return out; } function snippetFromRoot(root, rel, line, contextLines, missingNote) { if (!root) return { available: false, note: missingNote || "not available", rows: [] }; const abs = path.join(root, toPosix(rel).split("/").join(path.sep)); if (!exists(abs)) return { available: false, note: "file not found: " + rel, rows: [] }; return snippetFromText(readLatin1(abs), line, contextLines, ""); } function snippetFromText(text, line, contextLines, note) { return { available: true, note: note || "", line: line, rows: snippetRows(text, line, contextLines) }; } function appliedEditsForCtx(ctx) { const edits = []; (ctx.findings || []).forEach(function (f) { if (f.action === "Changed" && f.editStart !== undefined && f.editEnd !== undefined && f.replacement !== undefined) { edits.push({ s: f.editStart, e: f.editEnd, r: String(f.replacement), f: f }); } }); edits.sort(function (a, b) { return a.s - b.s; }); const applied = []; let lastEnd = -1; edits.forEach(function (ed) { if (ed.s < lastEnd) return; applied.push(ed); lastEnd = ed.e; }); return applied; } function mapSourceIndexToTarget(ctx, idx) { if (!ctx || idx === undefined || idx === null) return { idx: -1, reason: "no source index" }; let pos = parseInt(idx, 10); if (!Number.isFinite(pos) || pos < 0) return { idx: -1, reason: "invalid source index" }; let delta = 0; const edits = appliedEditsForCtx(ctx); for (let i = 0; i < edits.length; i++) { const ed = edits[i]; const oldLen = ed.e - ed.s; const newLen = ed.r.length; if (pos < ed.s) break; if (pos >= ed.s && pos <= ed.e) { return { idx: ed.s + delta, reason: "inside auto edit span" }; } delta += newLen - oldLen; } return { idx: pos + delta, reason: delta === 0 ? "same line map" : "line adjusted by previous auto edits" }; } function findingSourceLine(ctx, f) { if (ctx && f && f.idx !== undefined && f.idx !== null && !(f.category === "jquery-core-patched")) { const n = parseInt(f.idx, 10); if (Number.isFinite(n) && n >= 0) return lineOf(ctx.lineStarts, n); } return f.line || 1; } function targetSnippetForFinding(model, ctx, f, sourceLine, contextLines, missingNote) { if (!(model.targetWcRoot && isDir(model.targetWcRoot))) { return { available: false, note: missingNote || "TO-BE not generated in this mode", rows: [] }; } const abs = path.join(model.targetWcRoot, toPosix(f.rel).split("/").join(path.sep)); if (!exists(abs)) return { available: false, note: "file not found: " + f.rel, rows: [] }; const text = readLatin1(abs); const starts = lineStartsOf(text); let targetLine = sourceLine; let note = "same source line"; if (ctx && f.idx !== undefined && f.idx !== null) { const mapped = mapSourceIndexToTarget(ctx, f.idx); if (mapped.idx >= 0) { targetLine = lineOf(starts, Math.min(mapped.idx, Math.max(0, text.length))); note = mapped.reason; } } return snippetFromText(text, targetLine, contextLines, note); } function verificationHint(model, f) { if (f.priority === "Critical" || f.category === "jquery-core-old") return "jQuery " + model.profile.jquery.targetVersion + "과 Migrate 파일 존재, core -> migrate 로드 순서, 중복 core 로드 여부, 주요 화면 JQMIGRATE/JS error를 확인하세요."; if (f.priority === "XssHigh" || f.category === "dom-sink" || f.category === "wrapper-dom-sink") return "값 출처가 서버/사용자 입력인지 확인하고, HTML이 필요 없으면 .text(), 필요하면 escape/sanitizer와 신뢰 경계를 확인하세요."; if (f.category.indexOf("bool-attr") === 0) return "변수 값 도메인(Y/N, true/false, 1/0 등)과 disabled/checked/readonly 동작이 기존 화면과 같은지 확인하세요."; if (f.category === "jqxhr-shorthand") return "이 호출이 AJAX 콜백인지 DOM/다른 객체 메서드인지 확인하고, 성공/실패 콜백 인자의 출처를 확인하세요."; if (f.category === "live-die") return "동적으로 추가되는 요소 이벤트라면 .on(event, selector, handler) 위임 방식으로 바꾼 뒤 이벤트가 계속 동작하는지 확인하세요."; if (f.category === "jquery-core-unknown") return "파일 배너/실제 배포 파일에서 jQuery 버전을 확인하고 3.5 미만이면 patch-jquery 대상에 포함하세요."; return "AS-IS/TO-BE 차이를 확인하고 해당 화면에서 기존 동작, JS error, JQMIGRATE warning 여부를 확인하세요."; } function changePlanText(model, f) { if (f.action === "Changed") return "자동수정 적용/예정: " + trunc(f.before || f.pattern, 120) + " -> " + trunc(f.after || f.suggestion || "", 160); if (f.suggestion || f.after) return "권장 조치: " + trunc(f.suggestion || f.after, 220); if (f.priority === "Critical") return "patch-jquery 모드에서 jQuery core script src를 " + model.profile.jquery.targetVersion + " + Migrate 조합으로 교체합니다."; if (f.priority === "XssHigh") return "자동수정 금지. 값 출처와 HTML 필요 여부를 확인한 뒤 .text()/escape/sanitizer 중 하나로 수동 조치하세요."; return "자동 변경하지 않습니다. 코드 의도를 확인한 뒤 수동 조치 또는 project-profile 학습 규칙으로 분류를 보정하세요."; } const FOCUS_STAGE_ORDER = [ { key: "min", title: "1차 최소", badge: "취약점 통과", tone: "danger", desc: "이 단계는 먼저 봅니다. 구버전 jQuery 참조와 버전 불명 core를 정리해 3.5.1 안착 기준을 맞춥니다." }, { key: "compat", title: "2차 안정화", badge: "깨짐 방지", tone: "warn", desc: "3.5.1에서 화면 오류로 이어질 가능성이 큰 업무 코드입니다. 주요 화면 테스트와 같이 봅니다." }, { key: "max", title: "3차 최대/후속", badge: "장기 정리", tone: "calm", desc: "이번 배포 필수 범위 밖의 보안/유지보수 부채입니다. 일정이 있을 때 확장합니다." } ]; function focusStageKey(f) { if (f.priority === "Critical" || f.category === "jquery-core-old" || f.category === "jquery-core-unknown" || f.category === "jquery-core-patched") return "min"; if (f.priority === "XssHigh") return "max"; if (f.category === "dom-sink" || f.category === "dom-factory" || f.category === "parse-html" || f.category === "wrapper-dom-sink" || f.category === "trim-deprecated") return "max"; return "compat"; } function snippetComparable(sn) { if (!sn || !sn.available) return ""; const hitRows = sn.rows.filter(function (r) { return r.hit; }); const rows = hitRows.length ? hitRows : sn.rows; return rows.map(function (r) { return r.text; }).join("\n"); } function toBeStatus(asIs, toBe) { if (!toBe || !toBe.available) return "TO-BE 없음"; if (!asIs || !asIs.available) return "TO-BE 있음"; return snippetComparable(asIs) === snippetComparable(toBe) ? "변경없음" : "변경됨"; } function statusClass(d) { if (d.toBeStatus === "변경됨") return "changed"; if (d.toBeStatus === "변경없음") return "same"; return "missing"; } function findingDetail(model, f, rank, modalIndex, sourceKind) { const ctx = model.ctxByRel[f.rel]; const line = ctx ? findingSourceLine(ctx, f) : (f.line || 1); const asIs = ctx ? snippetFromText(ctx.text, line, 5, "source scan span") : snippetFromRoot(model.webContentRoot, f.rel, line, 5, "source not available"); const toBe = targetSnippetForFinding(model, ctx, f, line, 5, "TO-BE not generated in this mode"); return { rank: rank, modalIndex: modalIndex, sourceKind: sourceKind, stage: focusStageKey(f), rel: f.rel, line: line, category: f.category, priority: f.priority, confidence: f.confidence, action: f.action, pattern: f.pattern, reason: f.reason, change: changePlanText(model, f), verify: verificationHint(model, f), toBeStatus: toBeStatus(asIs, toBe), asIs: asIs, toBe: toBe }; } function buildFocusDetails(model) { return model.focus.slice(0, 100).map(function (f, i) { return findingDetail(model, f, i + 1, i, "FocusQueue"); }); } function buildAutoFixDetails(model, startIndex) { if (!(model.targetWcRoot && isDir(model.targetWcRoot))) return []; return model.findings.filter(function (f) { return f.action === "Changed" && (f.priority === "AutoFixed" || f.priority === "AutoInferred"); }).slice(0, 100).map(function (f, i) { return findingDetail(model, f, "A" + (i + 1), startIndex + i, "AutoFixed"); }); } function focusQueueHtml(model, details, autoDetails) { let h = ""; buildScopeRows(model).forEach(function (stage) { const group = details.filter(function (d) { return d.stage === stage.key; }); const autoGroup = autoDetails.filter(function (d) { return d.stage === stage.key; }); const autoLabel = autoGroup.length < stage.autoCount ? "상위 " + autoGroup.length + "/" + stage.autoCount + "건" : stage.autoCount + "건"; const queueLabel = group.length < stage.queueCount ? "상위 " + group.length + "/" + stage.queueCount + "건" : stage.queueCount + "건"; h += '
'; h += '
' + htmlEsc(stage.title) + '' + htmlEsc(stage.badge) + '
'; h += '
' + htmlEsc(stage.count) + '전체
' + htmlEsc(stage.autoCount) + '자동
' + htmlEsc(stage.queueCount) + '
'; h += '
'; h += '
목표
' + htmlEsc(stage.goal) + '
'; h += '
할 일
' + htmlEsc(stage.doText) + '
'; h += '
멈춤 기준
' + htmlEsc(stage.stop) + '
'; h += '
상세 ' + stage.files.join(" / ") + '
'; if (autoGroup.length > 0) { h += '
자동수정 미리보기TO-BE에서 실제 바뀐 항목 ' + htmlEsc(autoLabel) + '
'; h += '
'; autoGroup.forEach(function (d) { h += ""; }); h += "
순번파일라인유형TO-BE
" + htmlEsc(d.rank) + '" + htmlEsc(d.line) + "" + htmlEsc(d.category) + '' + htmlEsc(d.toBeStatus) + "
"; } h += '
검토 큐자동수정 대상이 아닌 수동 확인 항목 ' + htmlEsc(queueLabel) + '
'; if (group.length === 0) { h += '
이 단계에 표시할 FocusQueue 항목이 없습니다.
'; return; } h += '
'; group.forEach(function (d) { h += ""; }); h += "
순위파일라인유형우선순위TO-BE사유
" + d.rank + '" + htmlEsc(d.line) + "" + htmlEsc(d.category) + "" + htmlEsc(d.priority) + '' + htmlEsc(d.toBeStatus) + "" + htmlEsc(d.reason) + "
"; }); return h; } function findCount(model, fn) { let n = 0; model.findings.forEach(function (f) { if (fn(f)) n++; }); return n; } function isChangedAutoFinding(f) { return f.action === "Changed" && (f.priority === "AutoFixed" || f.priority === "AutoInferred"); } function countStageAuto(model, key) { return findCount(model, function (f) { return isChangedAutoFinding(f) && focusStageKey(f) === key; }); } function countStageQueue(model, key) { let n = 0; model.focus.forEach(function (f) { if (focusStageKey(f) === key) n++; }); return n; } function isCompatMinimalFinding(f) { if (f.thirdParty === "Y") return false; if (f.priority === "Critical") return false; if (f.category === "jqxhr-shorthand" || f.category === "live-die" || f.category === "jquery-browser") return true; if (f.category === "event-shortcut-load" || f.category === "size-to-length" || f.category === "andself-to-addback") return true; if (f.category.indexOf("bool-attr") === 0) return true; return false; } function isDeferredMaxFinding(f) { if (f.thirdParty === "Y") return false; if (f.priority === "XssHigh") return true; if (f.category === "trim-deprecated" || f.category === "parse-html" || f.category === "dom-sink" || f.category === "dom-factory" || f.category === "wrapper-dom-sink") return true; return false; } function buildScopeRows(model) { const c = model.counters; const coreUnknown = findCount(model, function (f) { return f.category === "jquery-core-unknown"; }); const minAuto = countStageAuto(model, "min"); const minQueue = countStageQueue(model, "min"); const compatAuto = countStageAuto(model, "compat"); const compatQueue = countStageQueue(model, "compat"); const maxAuto = countStageAuto(model, "max"); const maxQueue = countStageQueue(model, "max"); const minCount = Math.max(minQueue + minAuto, c.Gate35Blockers + coreUnknown + c.PageRiskMultipleJqueryCore + c.PageRiskMigrateMissing + c.PageRiskMigrateBeforeCore); const compatCount = compatAuto + compatQueue; const maxCount = Math.max(maxQueue + maxAuto, findCount(model, isDeferredMaxFinding) + c.VendorReview); const rows = [ { key: "min", title: "1차 최소", badge: "취약점 통과", tone: "danger", count: minCount, autoCount: minAuto, queueCount: minQueue, goal: "jQuery core를 " + c.JqueryTargetVersion + "로 교체하고 " + c.JqueryFloorVersion + " 미만 참조를 0건으로 만듭니다.", doText: "patch-jquery, Migrate 로드 순서, 중복 core, verify-clean FAIL 제거", stop: "verify-clean에서 old-jquery-refs / critical-findings / probe-leftover FAIL이 0건이면 1차 목표는 충족", files: [reportLink("critical.csv", "critical.csv"), reportLink("jqueryLoads.csv", "jqueryLoads.csv"), reportLink("jspPages.csv", "jspPages.csv"), reportLink("pageScriptEffective.csv", "pageScriptEffective.csv")] }, { key: "compat", title: "2차 안정화", badge: "깨짐 방지", tone: "warn", count: compatCount, autoCount: compatAuto, queueCount: compatQueue, goal: "3.5.1에서 실제 오류가 나기 쉬운 업무 코드만 우선 정리합니다.", doText: ".size(), .load(), jqXHR success/error/complete, live/die, boolean attr, $.browser 후보 확인", stop: "주요 화면 JS error 0건이고 업무 플로우가 깨지지 않으면 다음 업무로 넘어가도 됩니다.", files: [reportLink("autoFixed.csv", "autoFixed.csv"), reportLink("manualQueue.csv", "manualQueue.csv"), reportLink("focusQueue.csv", "focusQueue.csv"), reportLink("runtime_test_checklist.txt", "runtime_test_checklist.txt")] }, { key: "max", title: "3차 최대/후속", badge: "장기 정리", tone: "calm", count: maxCount, autoCount: maxAuto, queueCount: maxQueue, goal: "이번 배포 필수는 아니지만 보안·유지보수 부채를 줄이는 범위입니다.", doText: "DOM XSS 후보, 벤더 라이브러리 교체, Migrate warning 0건, jQuery 4 대비 deprecated 정리", stop: "Migrate 제거 또는 jQuery 4 대비까지 목표일 때만 이 단계까지 확장", files: [reportLink("xssHigh.csv", "xssHigh.csv"), reportLink("vendorReview.csv", "vendorReview.csv"), reportLink("staticHtmlLow.csv", "staticHtmlLow.csv"), reportLink("pluginInventory.csv", "pluginInventory.csv")] } ]; return rows; } function barCell(n, max) { const v = parseInt(n, 10) || 0; const pct = max > 0 ? Math.max(2, Math.round(v * 100 / max)) : 0; return '
' + htmlEsc(v) + "
"; } function categorySummaryHtml(model) { const rows = findingCategorySummary(model).slice(0, 18); const max = rows.reduce(function (m, r) { return Math.max(m, r.total); }, 0); return tableHtml(["유형", "총건", "Critical", "XSS", "수동", "검토", "자동", "파일"], rows.map(function (r) { return [r.category, barCell(r.total, max), r.critical, r.xss, r.manual, r.review, r.auto, r.fileCount]; }), true); } function directorySummaryHtml(model) { const rows = directoryRiskSummary(model).slice(0, 30); const max = rows.reduce(function (m, r) { return Math.max(m, r.total); }, 0); return tableHtml(["Depth", "경로 prefix", "총건", "차단", "Critical", "XSS", "수동/검토", "자동", "파일", "상위 유형"], rows.map(function (r) { return [r.depth, r.prefix, barCell(r.total, max), r.blockers, r.critical, r.xss, r.manual + r.review, r.auto, r.fileCount, r.topCategories]; }), true); } function writeIndexHtml(model) { const c = model.counters; const focusDetails = buildFocusDetails(model); const autoDetails = buildAutoFixDetails(model, focusDetails.length); const modalDetails = focusDetails.concat(autoDetails); const parts = []; parts.push("jQuery 3.5 조치 보고서
"); parts.push('
' + htmlEsc(TOOL_NAME + " v" + TOOL_VERSION) + '

jQuery ' + htmlEsc(CVE_ID) + ' 조치 보고서

Source: ' + htmlEsc(c.SourceRoot) + " / WebContent: " + htmlEsc(c.WebContentRoot) + " / Target: " + htmlEsc(c.TargetRoot) + '
Mode ' + htmlEsc(c.Mode) + 'Target ' + htmlEsc(c.JqueryTargetVersion) + 'Pass ' + htmlEsc(c.JqueryFloorVersion) + '+
'); parts.push('

요약

'); parts.push(kpiCard("3.5 게이트", c.Gate35Blockers, "#b00020")); parts.push(kpiCard("자동수정", c.AutoFixed + c.AutoInferred, "#1b5e20")); parts.push(kpiCard("FocusQueue", c.FocusQueue, "#6a1b9a")); parts.push(kpiCard("수동/검토", c.Manual + c.Review, "#b26a00")); parts.push(kpiCard("XSS 고위험", c.XssHigh, "#b00020")); parts.push(kpiCard("벤더 검토", c.VendorReview, "#546e7a")); parts.push("
"); parts.push("

유형/경로 분포

유형별 총건. 예: event-shortcut-load, bool-attr-variable 등
" + categorySummaryHtml(model) + '
디렉토리 depth별 위험 분포. 어느 경로에 몰려 있는지 먼저 봅니다.
' + directorySummaryHtml(model) + "
"); parts.push("

단계별 조치 큐 (로드맵 + FocusQueue 상위 100건)

"); parts.push('
1차 최소부터 확인하세요. 각 단계 안에서 목표와 멈춤 기준을 보고, 파일명을 클릭하면 AS-IS/TO-BE 주변 코드와 확인 포인트가 모달로 열립니다.
'); parts.push(focusQueueHtml(model, focusDetails, autoDetails)); if (model.runtimeScenarios.length > 0) { parts.push("

Runtime 검증 시나리오 (Chrome smoke / IE final sample)

"); parts.push('
1망에서는 code-only/static 근거를 만들고, 2망에서는 Chrome smoke를 먼저 수행합니다. IE_MODE_REQUIRED 또는 핵심업무 화면만 Edge IE mode 최종 샘플링 대상으로 올립니다. 상세: ' + reportLink("runtime_scenarios.html", "runtime_scenarios.html") + " / " + reportLink("runtimeScenarios.csv", "runtimeScenarios.csv") + " / " + reportLink("selectorElementMap.csv", "selectorElementMap.csv") + "
"); parts.push(tableHtml(["ID", "단계", "화면/파일", "대상", "유형", "동작", "통과 기준", "Chrome", "IE final"], model.runtimeScenarios.slice(0, 20).map(function (s) { return [s.id, stageLabel(s.stage), s.page || s.findingRel, s.uiTarget || s.selector || "-", s.category, s.action, s.passWhen, s.chromeSmoke, s.ieFinalSample]; }))); } if (model.runtimeParityRows.length > 0) { parts.push("

Runtime Parity 분석

"); parts.push('
code-only/Local Lab으로 충분한 항목, Chrome smoke가 필요한 항목, Edge IE mode final sample이 필요한 항목을 분리합니다. 상세: ' + reportLink("runtime_parity.html", "runtime_parity.html") + " / " + reportLink("runtimeParity.csv", "runtimeParity.csv") + " / " + reportLink("ieModeRisk.csv", "ieModeRisk.csv") + "
"); parts.push(tableHtml(["판정", "건수", "의미"], [ ['LOCAL_LAB_OK', c.RuntimeParityLocalOk, "구조적 jQuery API 변경 위주. 1망 code-only/정적 diff 신뢰 높음"], ['CHROME_SMOKE_REQUIRED', c.RuntimeChromeSmokeRequired, "2망 실제 Spring/Tomcat 또는 개발계 Chrome에서 smoke 필요"], ['IE_FINAL_SAMPLE_REQUIRED', c.RuntimeIeFinalSampleRequired, "legacy plugin/ActiveX/iframe/popup/file upload/IE 분기 등. Edge IE mode 최종 샘플링 대상"] ], true)); } parts.push("
상세 표 펼치기
"); parts.push("

1차 상세: " + TARGET_JQUERY_FLOOR_VERSION + " 미만 jQuery core 호출부 (" + model.oldCoreRefs.length + "건)

"); parts.push(tableHtml(["페이지", "라인", "src", "버전"], model.oldCoreRefs.map(function (r) { return [r.page, r.line, r.raw, r.meta.ver]; }))); parts.push('
이 항목은 plan/autofix에서는 자동 변경되지 않습니다. 기본 ' + htmlEsc(model.profile.jquery.coreFile) + " / " + htmlEsc(model.profile.jquery.migrateFile) + ' 파일은 번들에서 TO-BE WebContent/js로 자동 배치되며, patch-jquery 모드로 호출부를 교체하세요.
'); parts.push("

페이지 리스크

"); const riskPages = model.pages.filter(function (p) { return p.riskMultiCore || p.riskOldCore || p.riskMigrateMissing || p.riskMigrateBeforeCore; }); parts.push(tableHtml(["페이지", "core 수", "버전", "구버전", "Migrate", "Migrate 순서"], riskPages.map(function (p) { return [p.rel, p.coreCount, p.coreVer, p.oldCore ? "Y" : "", p.hasMigrate ? "Y" : "누락", p.migrateAfter]; }))); if (model.serverEndpointRows.length > 0 || model.ajaxServerRows.length > 0) { parts.push("

서버 정적 증거

"); parts.push(tableHtml(["AJAX", "매핑", "핸들러", "근거"], model.ajaxServerRows.slice(0, 30).map(function (r) { return [r.ajaxMethod + " " + r.ajaxUrl, r.matched, r.handler || "-", r.evidenceFile ? (r.evidenceFile + ":" + r.evidenceLine) : r.note]; }))); parts.push('
Java/Spring을 실행하지 않고 어노테이션/XML만 읽은 보조 근거입니다. 상세: ' + reportLink("serverEndpoints.csv", "serverEndpoints.csv") + " / " + reportLink("ajaxToServerMap.csv", "ajaxToServerMap.csv") + " / " + reportLink("hermes_server_evidence.json", "hermes_server_evidence.json") + "
"); } parts.push("

라이브러리 분포

"); let lc = {}; try { lc = JSON.parse(c.LibraryCounts); } catch (e) { } parts.push(tableHtml(["라이브러리", "JS 파일 수"], Object.keys(lc).map(function (k) { return [k, lc[k]]; }))); if (model.reviewCasesAll > 0) { parts.push("

AI 리뷰팩 (애매한 코드 " + model.reviewCasesAll + "그룹 중 상위 " + model.reviewCases.length + "건)

"); parts.push(tableHtml(["CaseId", "종류", "이름", "호출부수", "건수", "현재분류", "질문"], model.reviewCases.slice(0, 20).map(function (g) { return [g.caseId, g.kind === "FN" ? "함수" : "패턴", g.name, g.fanout, g.count, g.findings[0].priority, trunc(g.question, 60)]; }))); parts.push('
--mode review-pack 또는 hermes-pack 실행 시 ai_review_pack.txt/json과 ' + reportLink("hermes_test_plan.md", "hermes_test_plan.md") + " / " + reportLink("hermes_review_matrix.csv", "hermes_review_matrix.csv") + " / " + reportLink("hermes_testbench.html", "hermes_testbench.html") + '가 생성됩니다. 코드 원문 없이 함수명/앞뒤 몇 줄만 담겨 외부 AI에게 전달 가능하며, 로컬 검수 결과를 project-profile.json에 병합하면 다음 라운드에 분류가 반영됩니다.
'); } parts.push("

다음 액션

    "); recommendedActions(model).forEach(function (a) { parts.push("
  1. " + htmlEsc(a) + "
  2. "); }); parts.push("
"); parts.push('
복붙 패킷: ' + reportLink("voyager_packet.txt", "voyager_packet.txt") + ' / ' + reportLink("ai_verdict_packet.txt", "ai_verdict_packet.txt") + ' / 상세 데이터: verdict_evidence.html / apiFindings.csv / findingCategorySummary.csv / directoryRiskSummary.csv / runtimeParity.csv / ieModeRisk.csv / focusQueue.csv / jspPages.csv / pageScriptEffective.csv / ajaxToServerMap.csv / airgap_manifest.txt / jquery35_report.xls
'); parts.push("
"); parts.push('
'); parts.push(""); parts.push(""); writeUtf8(path.join(model.reportRoot, "index.html"), parts.join("\n"), false); } function recommendedActions(model) { const c = model.counters; const out = []; if (c.Critical > 0) out.push("jQuery " + model.profile.jquery.targetVersion + " + Migrate " + model.profile.jquery.migrateVersion + " 파일을 WebContent/js에 배치한 뒤 patch-jquery 모드로 " + c.Critical + "개 호출부를 교체 (CVE-2020-11023 핵심 조치)"); if (c.AutoFixed + c.AutoInferred > 0) out.push("autofix 결과는 report/index.html에서 파일명을 클릭해 AS-IS/TO-BE split view로 확인한 뒤 안전 자동수정 " + (c.AutoFixed + c.AutoInferred) + "건을 브랜치에 반영"); if (c.Manual > 0) out.push("manualQueue.csv의 수동 조치 " + c.Manual + "건 처리 (.success/.error/.complete 전환, boolean attr 변수 타입 확인)"); if (c.XssHigh > 0) out.push("XssHigh " + c.XssHigh + "건 DOM XSS 검토: .text()/escapeHtml 적용 또는 신뢰 경계 확인 (jQuery 업그레이드만으로는 미해결)"); if (c.VendorReview > 0) out.push("벤더 라이브러리(jqGrid/jquery-ui/select2/autoNumeric)는 직접 수정하지 말고 Migrate 상태에서 화면 테스트 및 호환 버전 검토"); if (c.PageRiskMigrateMissing > 0) out.push("Migrate 누락 페이지 " + c.PageRiskMigrateMissing + "건에 jquery-migrate 추가"); if (c.Manual + c.Review > 5) out.push("review-pack 모드로 애매한 코드 지점(현재 " + c.ReviewCasesTotal + "그룹) 질문지를 뽑아 외부 AI와 반복 학습 (learnedWrappers/learnedFindings로 project-profile.json에 누적)"); if (c.RuntimeParitySpringTomcat + c.RuntimeParityIeMode > 0) out.push("runtime_parity.html에서 1망 code-only 종료 후보, 2망 Chrome smoke 필요 항목, Edge IE mode final sample 항목을 분리해 검증 범위를 줄이세요"); out.push("probe 모드로 Runtime Probe를 삽입해 2망 Chrome smoke 결과를 먼저 수집하고, IE_MODE_REQUIRED 항목만 Edge IE mode에서 최종 샘플링"); out.push("운영 반영 전 verify-clean 모드 실행 (probe 잔존/구버전 jQuery 잔존 시 FAIL)"); return out; } function packetLines(model) { const c = model.counters; const includeSnippets = model.opts["include-snippets"] === true || model.opts["safe-packet"] === false; const L = []; L.push("JQUERY35_LOCAL_AGENT_PACKET v" + TOOL_VERSION); ["SourceRoot", "WebContentRoot", "TargetRoot", "ReportRoot", "Mode", "TotalFiles", "TextFiles", "PageFiles", "JsFiles", "JqueryTargetVersion", "JqueryFloorVersion", "Gate35Blockers", "ChangedFiles", "ApiFindings", "Critical", "AutoFixed", "AutoInferred", "Review", "Manual", "XssHigh", "FocusQueue", "VendorReview", "StaticHtmlLow", "JqueryLoads", "OldJqueryBelow350", "PageRiskMultipleJqueryCore", "PageRiskOldJqueryCore", "PageRiskMigrateMissing", "PageRiskMigrateBeforeCore", "UnresolvedRefs", "AjaxEndpoints", "JsSyntaxFail", "ServerFiles", "ServerEndpoints", "AjaxMappedToServer", "UiElements", "SelectorElementRows", "RuntimeScenarios", "RuntimeParityRows", "RuntimeParityLocalOk", "RuntimeParitySpringTomcat", "RuntimeParityIeMode", "RuntimeChromeSmokeRequired", "RuntimeIeFinalSampleRequired", "IeModeRiskPages", "SpringRuntimePages", "ReviewCasesTotal", "ReviewCasesInPack", "LearnedWrapperCount", "LearnedFindingOverrides", "LibraryCounts", "GitInfo", "OldJquerySrcs"].forEach(function (k) { L.push(k + "=" + c[k]); }); const fmtF = function (f) { let s = f.rel + ":" + f.line + ":" + f.category + ":" + f.priority; if (includeSnippets && f.before) s += " :: " + trunc(f.before, 100); return s; }; L.push(""); L.push("TopFocusQueue (" + Math.min(100, model.focus.length) + "/" + model.focus.length + "):"); model.focus.slice(0, 100).forEach(function (f) { L.push(" " + fmtF(f)); }); L.push(""); L.push("TopCritical:"); model.oldCoreRefs.slice(0, 20).forEach(function (r) { L.push(" " + r.page + ":" + r.line + ":" + r.raw + " (v" + r.meta.ver + ")"); }); L.push(""); L.push("TopManual:"); model.findings.filter(function (f) { return f.priority === "Manual" && f.thirdParty !== "Y"; }).slice(0, 30).forEach(function (f) { L.push(" " + fmtF(f)); }); L.push(""); L.push("TopXssHigh:"); model.findings.filter(function (f) { return f.priority === "XssHigh" && f.thirdParty !== "Y"; }).slice(0, 30).forEach(function (f) { L.push(" " + fmtF(f)); }); L.push(""); L.push("TopUnresolvedRefs:"); model.unresolvedRows.slice(0, 15).forEach(function (r) { L.push(" " + r.page + ":" + r.type + ":" + r.raw + " (" + r.reason + ")"); }); L.push(""); L.push("TopAjaxEndpoints:"); uniq(model.ajaxRows.map(function (r) { return r.method + " " + r.urlNorm; })).slice(0, 30).forEach(function (u) { L.push(" " + u); }); L.push(""); L.push("TopServerEndpointEvidence:"); model.ajaxServerRows.filter(function (r) { return r.matched === "Y"; }).slice(0, 30).forEach(function (r) { L.push(" " + r.ajaxMethod + " " + r.ajaxUrl + " -> " + r.handler + " (" + r.evidenceFile + ":" + r.evidenceLine + ")"); }); L.push(""); L.push("TopPluginInventory:"); uniq(model.pluginInv.map(function (r) { return r[1] + (r[2] ? " v" + r[2] : ""); })).slice(0, 20).forEach(function (p) { L.push(" " + p); }); L.push(""); L.push("TopRuntimeScenarios:"); model.runtimeScenarios.slice(0, 20).forEach(function (r) { L.push(" " + r.id + ":" + stageLabel(r.stage) + ":" + (r.page || r.findingRel) + ":" + r.category + " -> " + r.action); }); L.push(""); L.push("RecommendedNextActions:"); recommendedActions(model).forEach(function (a, i) { L.push(" " + (i + 1) + ". " + a); }); const cap = positiveIntOpt(model.opts["max-packet-lines"], 400); if (L.length > cap) { const kept = L.slice(0, cap - 1); kept.push("...(truncated " + (L.length - cap + 1) + " lines, adjust with --max-packet-lines)"); return kept; } return L; } function capPacketLines(lines, cap) { if (lines.length > cap) { const kept = lines.slice(0, Math.max(1, cap - 2)); kept.push("TRUNCATED|kept=" + kept.length + "|total=" + lines.length + "|hint=rerun with --max-packet-lines"); kept.push("END|JQ35_VOYAGER_PACKET"); return kept; } return lines; } function packetField(v, n) { return trunc(String(v == null ? "" : v).replace(/[\r\n\t|]+/g, " ").replace(/\s+/g, " ").trim(), n || 180); } function voyagerPacketLines(model) { const c = model.counters; const L = []; L.push("JQ35_VOYAGER_PACKET|v=" + TOOL_VERSION + "|copyPasteOnly=Y|csvRequired=N"); L.push("ASK|csvFilesCannotMove=Y|readThisOnly=Y|task=judge jquery351 migration risk, next actions, and manual IE-mode test plan"); L.push("SUMMARY|source=" + packetField(c.SourceRoot, 120) + "|web=" + packetField(c.WebContentRoot, 120) + "|mode=" + packetField(c.Mode, 20) + "|api=" + c.ApiFindings + "|critical=" + c.Critical + "|auto=" + (c.AutoFixed + c.AutoInferred) + "|manual=" + c.Manual + "|review=" + c.Review + "|xss=" + c.XssHigh + "|focus=" + c.FocusQueue + "|runtime=" + c.RuntimeScenarios + "|parityLocal=" + c.RuntimeParityLocalOk + "|paritySpring=" + c.RuntimeParitySpringTomcat + "|parityIe=" + c.RuntimeParityIeMode + "|chromeReq=" + c.RuntimeChromeSmokeRequired + "|ieFinalReq=" + c.RuntimeIeFinalSampleRequired + "|ui=" + c.UiElements + "|selectorRows=" + c.SelectorElementRows); L.push("COUNTS|oldJq=" + c.OldJqueryBelow350 + "|jqLoads=" + c.JqueryLoads + "|multiCorePages=" + c.PageRiskMultipleJqueryCore + "|migrateMissing=" + c.PageRiskMigrateMissing + "|migrateBeforeCore=" + c.PageRiskMigrateBeforeCore + "|ajax=" + c.AjaxEndpoints + "|serverEndpoints=" + c.ServerEndpoints + "|ajaxMapped=" + c.AjaxMappedToServer + "|vendor=" + c.VendorReview + "|springPages=" + c.SpringRuntimePages + "|iePages=" + c.IeModeRiskPages); buildScopeRows(model).forEach(function (s) { L.push("STAGE|" + packetField(stageLabel(s.key), 20) + "|total=" + s.count + "|auto=" + s.autoCount + "|queue=" + s.queueCount + "|goal=" + packetField(s.goal, 160) + "|stop=" + packetField(s.stop, 160)); }); findingCategorySummary(model).slice(0, 15).forEach(function (r) { L.push("CAT|" + packetField(r.category, 50) + "|total=" + r.total + "|crit=" + r.critical + "|xss=" + r.xss + "|manual=" + r.manual + "|review=" + r.review + "|auto=" + r.auto + "|files=" + r.fileCount); }); directoryRiskSummary(model).slice(0, 15).forEach(function (r) { L.push("DIR|d=" + r.depth + "|path=" + packetField(r.prefix, 90) + "|total=" + r.total + "|blockers=" + r.blockers + "|top=" + packetField(r.topCategories, 140)); }); model.oldCoreRefs.slice(0, 30).forEach(function (r) { L.push("OLDJQ|" + packetField(shortReviewPath(r.page) + ":" + r.line, 90) + "|v=" + packetField(r.meta.ver || "?", 20) + "|src=" + packetField(r.raw, 120)); }); model.focus.slice(0, 40).forEach(function (f, i) { L.push("FOCUS|" + (i + 1) + "|" + packetField(shortReviewPath(f.rel) + ":" + f.line, 100) + "|cat=" + packetField(f.category, 45) + "|pri=" + packetField(f.priority, 20) + "|why=" + packetField(f.reason, 180) + "|fix=" + packetField(f.suggestion || f.after || "", 160)); }); model.runtimeScenarios.slice(0, 80).forEach(function (s) { L.push("RT|" + s.id + "|stage=" + packetField(stageLabel(s.stage), 20) + "|page=" + packetField(shortReviewPath(s.page || s.findingRel), 100) + "|find=" + packetField(shortReviewPath(s.findingRel) + ":" + s.line, 100) + "|cat=" + packetField(s.category, 45) + "|pri=" + packetField(s.priority, 20) + "|target=" + packetField(s.uiTarget || s.selector || "-", 140) + "|do=" + packetField(s.action, 220) + "|pass=" + packetField(s.passWhen, 180) + "|chrome=" + packetField(s.chromeSmoke, 160) + "|ieFinal=" + packetField(s.ieFinalSample, 160)); }); model.ieModePageRows.slice(0, 40).forEach(function (r) { L.push("PARITY_PAGE|" + packetField(shortReviewPath(r.page), 100) + "|level=" + packetField(r.level, 30) + "|lane=" + packetField(r.validationLane, 35) + "|spring=" + r.needsSpringTomcat + "|ie=" + r.needsIeMode + "|score=" + r.score + "|why=" + packetField(r.reasons || "-", 180)); }); model.runtimeParityRows.slice(0, 60).forEach(function (r) { L.push("PARITY|" + packetField(r.id, 12) + "|rt=" + packetField(r.scenarioId || "-", 12) + "|level=" + packetField(r.level, 30) + "|lane=" + packetField(r.validationLane, 35) + "|page=" + packetField(shortReviewPath(r.page || r.findingRel), 100) + "|find=" + packetField(shortReviewPath(r.findingRel) + ":" + r.line, 100) + "|cat=" + packetField(r.category, 45) + "|why=" + packetField(r.reason, 180)); }); model.selectorElementRows.filter(function (r) { return r.element; }).slice(0, 60).forEach(function (r) { L.push("SEL|" + packetField(shortReviewPath(r.rel) + ":" + r.line, 100) + "|selector=" + packetField(r.selector, 80) + "|page=" + packetField(shortReviewPath(r.page), 100) + "|element=" + packetField(r.element, 120) + "|conf=" + packetField(r.confidence, 20)); }); model.ajaxServerRows.slice(0, 50).forEach(function (r) { L.push("AJAX|" + packetField(shortReviewPath(r.rel) + ":" + r.line, 100) + "|" + packetField(r.ajaxMethod + " " + r.ajaxUrl, 130) + "|matched=" + r.matched + "|handler=" + packetField(r.handler || "-", 130) + "|conf=" + packetField(r.confidence, 20) + "|note=" + packetField(r.note, 100)); }); const libs = {}; model.scriptInv.forEach(function (r) { libs[r[1]] = (libs[r[1]] || 0) + 1; }); L.push("LIBS|" + Object.keys(libs).sort().map(function (k) { return packetField(k, 40) + "=" + libs[k]; }).join(",")); recommendedActions(model).forEach(function (a, i) { L.push("NEXT|" + (i + 1) + "|" + packetField(a, 220)); }); L.push("END|JQ35_VOYAGER_PACKET"); return capPacketLines(L, positiveIntOpt(model.opts["max-packet-lines"], 400)); } function writePacket(model) { writeUtf8(path.join(model.reportRoot, "assistant_packet.txt"), packetLines(model).join("\r\n") + "\r\n", true); writeUtf8(path.join(model.reportRoot, "voyager_packet.txt"), voyagerPacketLines(model).join("\r\n") + "\r\n", true); } function joinPacketChars(lines) { return lines.join("\r\n") + "\r\n"; } function topCompactPairs(rows, keyFn, valueFn, max, itemMaxLen) { const out = []; const seen = Object.create(null); rows.forEach(function (r) { if (out.length >= max) return; const k = packetField(keyFn(r), itemMaxLen || 24); if (!k || seen[k]) return; seen[k] = 1; out.push(k + "=" + packetField(valueFn(r), itemMaxLen || 24)); }); return out.join(","); } function aiVerdictRiskHint(model) { const c = model.counters; if (c.Critical > 0) return "PATCH_CORE_FIRST"; if (c.RuntimeParityIeMode > 0 || c.IeModeRiskPages > 0) return "IE_MODE_EVIDENCE_NEEDED"; if (c.RuntimeParitySpringTomcat > 0 || c.XssHigh > 0 || c.AjaxEndpoints > c.AjaxMappedToServer) return "MORE_RUNTIME_EVIDENCE"; if (c.FocusQueue > 0 || c.Manual + c.Review > 0) return "HUMAN_REVIEW_QUEUE"; return "HUMAN_REVIEW_OK_CANDIDATE"; } function verdictEvidence(model) { const c = model.counters; return { tool: TOOL_NAME, version: TOOL_VERSION, generatedAt: new Date().toISOString(), sourceFingerprint: sourceFingerprint(model), environmentAssumption: { sourceOnlyNetwork: "1망: Node + Bitbucket source only, no Spring/Tomcat runtime", chromeSmokeNetwork: "2망: Node 승인 후 실제 Spring/Tomcat 또는 개발계 화면을 Chrome으로 smoke", ieFinalRuntime: "Microsoft Edge 143 IE mode final sampling for IE_MODE_REQUIRED/high-value screens", local: "Spring 3.2.5 / Tomcat 7.0.74", develop: "WebtoB + JEUS / HTTPS", production: "WebtoB + JEUS / HTTP, HTTPS planned", note: "Chrome smoke reduces common runtime risk but is not final IE compatibility proof." }, staticCounts: { apiFindings: c.ApiFindings, criticalOldJqueryRefs: c.Critical, autoSafe: c.AutoFixed + c.AutoInferred, manual: c.Manual, review: c.Review, xssHigh: c.XssHigh, focusQueue: c.FocusQueue, vendorReview: c.VendorReview, jsSyntaxFail: c.JsSyntaxFail }, runtimePlan: { scenariosPlanned: c.RuntimeScenarios, parityRows: c.RuntimeParityRows, localLabOk: c.RuntimeParityLocalOk, springTomcatRequired: c.RuntimeParitySpringTomcat, ieModeRequired: c.RuntimeParityIeMode, chromeSmokeRequired: c.RuntimeChromeSmokeRequired, ieFinalSampleRequired: c.RuntimeIeFinalSampleRequired, ieModeRiskPages: c.IeModeRiskPages, springRuntimePages: c.SpringRuntimePages, sourceOnlyStatic: true, chromeSmokeResult: "not-ingested", ieFinalSampleResult: "not-ingested", probeResult: "not-ingested", validationLanes: RUNTIME_VALIDATION_LANES }, serverEvidence: { serverFiles: c.ServerFiles, serverEndpoints: c.ServerEndpoints, ajaxEndpoints: c.AjaxEndpoints, ajaxMappedToServer: c.AjaxMappedToServer }, topCategories: findingCategorySummary(model).slice(0, 8).map(function (r) { return { category: r.category, total: r.total, blockers: r.critical + r.xss + r.manual + r.review }; }), topDirectories: directoryRiskSummary(model).slice(0, 8).map(function (r) { return { depth: r.depth, prefix: r.prefix, total: r.total, blockers: r.blockers }; }), suggestedRiskHint: aiVerdictRiskHint(model), answerSchema: { verdict: ["HUMAN_REVIEW_OK", "NEED_MORE_RUNTIME", "NEED_IEDRIVER", "BLOCKED"], fields: ["WHY", "NEXT", "CONF"] } }; } function aiVerdictPacketLines(model) { const c = model.counters; const cap = positiveIntOpt(model.opts["ai-packet-chars"], 1000); const cats = findingCategorySummary(model); const dirs = directoryRiskSummary(model); const mandatory = [ "JQ35_AI_VERDICT|v=" + TOOL_VERSION + "|cap=" + cap + "|csv=N|src=redacted", "S|api=" + c.ApiFindings + "|crit=" + c.Critical + "|oldJq=" + c.OldJqueryBelow350 + "|auto=" + (c.AutoFixed + c.AutoInferred) + "|manual=" + c.Manual + "|review=" + c.Review + "|xss=" + c.XssHigh + "|focus=" + c.FocusQueue + "|vendor=" + c.VendorReview, "R|rtPlan=" + c.RuntimeScenarios + "|localOk=" + c.RuntimeParityLocalOk + "|spring=" + c.RuntimeParitySpringTomcat + "|ie=" + c.RuntimeParityIeMode + "|chromeReq=" + c.RuntimeChromeSmokeRequired + "|ieFinalReq=" + c.RuntimeIeFinalSampleRequired + "|ajaxMap=" + c.AjaxMappedToServer + "/" + c.AjaxEndpoints, "VAL|sourceOnly=Y|chromeSmoke=not_done|ieFinal=not_done|probe=not_ingested", "ENV|1net=codeOnly|2net=ChromeSmoke|final=EdgeIE_sample|server=SpringTomcat_or_dev", "HINT|" + aiVerdictRiskHint(model), "ASK|judge if scenario+Probe human review is enough, or more runtime evidence/IEDriver review is needed", "VERDICT|one=HUMAN_REVIEW_OK|NEED_MORE_RUNTIME|NEED_IEDRIVER|BLOCKED", "WHY|", "NEXT|", "CONF|0-100" ]; const optional = [ "CAT|" + topCompactPairs(cats, function (r) { return r.category; }, function (r) { return r.total; }, 6, 18), "DIR|" + topCompactPairs(dirs, function (r) { return shortReviewPath(r.prefix || "."); }, function (r) { return r.blockers + "/" + r.total; }, 4, 22), "GAP|sourceOnly=Y|chromeSmoke=not_done|ieFinal=not_done|done=0|pass=0|fail=0|blocked=0", "FILES|evidence=verdict_evidence.json|detail=index.html,runtime_scenarios.html,runtime_parity.html" ]; let lines = mandatory.slice(0, 5).concat(optional).concat(mandatory.slice(5)); while (joinPacketChars(lines).length > cap && optional.length > 0) { optional.pop(); lines = mandatory.slice(0, 5).concat(optional).concat(mandatory.slice(5)); } if (joinPacketChars(lines).length > cap) { lines[5] = "ASK|judge migration evidence sufficiency"; } if (joinPacketChars(lines).length > cap) { lines = lines.filter(function (line) { return line.indexOf("ENV|") !== 0; }); } if (joinPacketChars(lines).length > cap) { lines[0] = "JQ35_AI_VERDICT|v=" + TOOL_VERSION + "|cap=" + cap; } return lines; } function writeVerdictEvidenceHtml(model, evidence) { const parts = []; const sc = evidence.staticCounts; const rt = evidence.runtimePlan; parts.push("AI verdict evidence
"); parts.push("
" + htmlEsc(TOOL_NAME + " v" + TOOL_VERSION) + "

AI Verdict Evidence

외부 AI에는 아래 초압축 패킷만 넘기고, 사람은 이 상세 증거장을 확인합니다. 이 판정은 자동수정 트리거가 아니라 검증 범위 판단용입니다.
"); parts.push("
" + htmlEsc(sc.criticalOldJqueryRefs) + "old jQuery refs
" + htmlEsc(sc.autoSafe) + "safe auto fixes
" + htmlEsc(sc.manual + sc.review) + "manual/review
" + htmlEsc(sc.xssHigh) + "XssHigh
"); parts.push("

Runtime Evidence Plan

" + tableHtml(["항목", "값"], [ ["scenariosPlanned", rt.scenariosPlanned], ["localLabOk", rt.localLabOk], ["springTomcatRequired", rt.springTomcatRequired], ["ieModeRequired", rt.ieModeRequired], ["chromeSmokeRequired", rt.chromeSmokeRequired], ["ieFinalSampleRequired", rt.ieFinalSampleRequired], ["sourceOnlyStatic", rt.sourceOnlyStatic ? "Y" : "N"], ["chromeSmokeResult", rt.chromeSmokeResult], ["ieFinalSampleResult", rt.ieFinalSampleResult], ["ieModeRiskPages", rt.ieModeRiskPages], ["probeResult", rt.probeResult], ["suggestedRiskHint", evidence.suggestedRiskHint] ]) + "
"); parts.push("

AI Copy Packet

길이 " + htmlEsc(joinPacketChars(aiVerdictPacketLines(model)).length) + "자. 그대로 복사해서 AI에게 물어보면 됩니다.
" + htmlEsc(joinPacketChars(aiVerdictPacketLines(model))) + "
"); parts.push("

Top Categories

" + tableHtml(["유형", "총건", "차단/검토"], evidence.topCategories.map(function (r) { return [r.category, r.total, r.blockers]; })) + "
"); parts.push("
"); writeUtf8(path.join(model.reportRoot, "verdict_evidence.html"), parts.join("\n"), false); } function writeAiVerdictPack(model) { const evidence = verdictEvidence(model); const packet = joinPacketChars(aiVerdictPacketLines(model)); writeUtf8(path.join(model.reportRoot, "ai_verdict_packet.txt"), packet, false); writeUtf8(path.join(model.reportRoot, "verdict_evidence.json"), JSON.stringify(evidence, null, 2) + "\n", false); writeVerdictEvidenceHtml(model, evidence); } function writeChatSummary(model) { const c = model.counters; const L = []; L.push("jQuery CVE-2020-11023 조치 현황 요약 (" + TOOL_NAME + " v" + TOOL_VERSION + ", mode=" + c.Mode + ")"); L.push(""); L.push("대상: " + c.WebContentRoot); L.push("전체 " + c.TotalFiles + "개 파일 / 페이지 " + c.PageFiles + " / JS " + c.JsFiles); L.push(""); L.push("핵심 수치"); L.push("- 3.5 게이트(" + c.JqueryFloorVersion + " 미만 jQuery core 호출부): " + c.Gate35Blockers + "건 -> patch-jquery 모드로 " + c.JqueryTargetVersion + " 교체 (자동수정 아님)"); L.push("- 안전 자동수정(AutoFixed): " + c.AutoFixed + "건 / 콜사이트 추론 자동수정(AutoInferred): " + c.AutoInferred + "건"); L.push("- 수동 조치(Manual): " + c.Manual + "건 / 검토(Review): " + c.Review + "건"); L.push("- DOM XSS 고위험(XssHigh): " + c.XssHigh + "건 (jQuery 업그레이드와 별개로 조치 필요)"); L.push("- 벤더 검토(VendorReview): " + c.VendorReview + "건 (jqGrid/jquery-ui/select2/autoNumeric 등, 직접 수정 금지)"); L.push("- 정적 HTML 저위험: " + c.StaticHtmlLow + "건 (조치 불필요 후보)"); L.push("- 사람이 봐야 할 FocusQueue: " + c.FocusQueue + "건"); L.push("- 서버 정적 증거: Java/XML " + c.ServerFiles + "개 파일, Controller endpoint " + c.ServerEndpoints + "건, AJAX 매핑 " + c.AjaxMappedToServer + "/" + c.AjaxEndpoints + "건"); L.push("- Runtime parity: code-only/LocalLab " + c.RuntimeParityLocalOk + "건 / Chrome smoke 필요 " + c.RuntimeChromeSmokeRequired + "건 / IE final sample 필요 " + c.RuntimeIeFinalSampleRequired + "건"); L.push("- 페이지 검증범위: Spring/Tomcat 필요 " + c.SpringRuntimePages + "개 / IE mode 리스크 " + c.IeModeRiskPages + "개"); L.push(""); L.push("페이지 리스크"); L.push("- jQuery core 중복 로드 페이지: " + c.PageRiskMultipleJqueryCore); L.push("- 구버전 core 사용 페이지: " + c.PageRiskOldJqueryCore); L.push("- Migrate 누락 페이지(" + c.JqueryFloorVersion + "+ 기준): " + c.PageRiskMigrateMissing); L.push("- Migrate 선로드 페이지: " + c.PageRiskMigrateBeforeCore); L.push("- 미해석 참조: " + c.UnresolvedRefs); L.push(""); L.push("구버전 jQuery 호출부"); model.oldCoreRefs.forEach(function (r) { L.push("- " + r.page + ":" + r.line + " " + r.raw); }); L.push(""); L.push("다음 액션"); recommendedActions(model).forEach(function (a, i) { L.push((i + 1) + ". " + a); }); L.push(""); L.push("XSS 대응용 공통 이스케이프 함수 예시 (필요 시 공통 JS에 추가):"); L.push("function escapeHtml(v){ return String(v == null ? '' : v).replace(/&/g,'&').replace(//g,'>').replace(/\"/g,'"').replace(/'/g,'''); }"); writeUtf8(path.join(model.reportRoot, "chat_summary.txt"), L.join("\r\n") + "\r\n", true); } function writeMockFiles(model) { const routes = {}; const serverByAjax = {}; (model.ajaxServerRows || []).forEach(function (r) { if (r.matched === "Y") serverByAjax[r.ajaxMethod + " " + r.ajaxUrl] = r; }); model.ajaxRows.forEach(function (r) { if (!r.urlNorm || r.urlNorm.indexOf("_EL_") >= 0 || r.urlNorm.indexOf("_JSP_") >= 0) return; const key = r.urlNorm; if (!routes[key]) routes[key] = { url: key, method: r.method, type: r.mock, hits: 0 }; routes[key].hits++; if (r.mock === "json") routes[key].type = "json"; const ev = serverByAjax[r.method + " " + r.urlNorm]; if (ev) { routes[key].serverMatched = true; routes[key].handler = ev.handler; routes[key].serverPath = ev.serverPath; routes[key].evidence = ev.evidenceFile + ":" + ev.evidenceLine; routes[key].confidence = ev.confidence; } }); const routeArr = Object.keys(routes).sort().map(function (k) { return routes[k]; }); writeUtf8(path.join(model.reportRoot, "mock_routes.json"), JSON.stringify({ generated: true, routes: routeArr }, null, 2), false); const sample = mergeConfig(jsonClone(DEFAULT_MOCK_DEFAULTS), model.profile.mockDefaults || {}); writeUtf8(path.join(model.reportRoot, "mock_data_default.json"), JSON.stringify(sample, null, 2), false); } function writeRecommendedCommits(model) { const groups = { AUTO_SAFE: { title: "safe auto fixes (.on/.off/.prop/.length etc)", files: {} }, JQUERY_CORE: { title: "jQuery core 1.10.2 -> " + model.profile.jquery.targetVersion + " + Migrate " + model.profile.jquery.migrateVersion, files: {} }, MANUAL_BOOL_ATTR: { title: "manual boolean attr variable fixes", files: {} }, DOM_XSS: { title: "html/append/replaceWith DOM XSS candidates", files: {} }, PROBE_ONLY: { title: "temporary runtime probe for verification (must not reach production)", files: {} }, VENDOR_REVIEW: { title: "vendor compatibility verification (jqGrid/jquery-ui/select2/autoNumeric)", files: {} } }; model.findings.forEach(function (f) { if (groups[f.commitGroup]) groups[f.commitGroup].files[f.rel] = 1; }); const L = ["RECOMMENDED COMMIT GROUPS (" + TOOL_NAME + " v" + TOOL_VERSION + ")", ""]; let n = 0; Object.keys(groups).forEach(function (g) { n++; const files = Object.keys(groups[g].files).sort(); L.push(n + ". " + g + " - " + groups[g].title + " (" + files.length + " files)"); files.slice(0, 200).forEach(function (f) { L.push(" " + f); }); if (files.length > 200) L.push(" ...(" + (files.length - 200) + " more)"); L.push(""); }); writeUtf8(path.join(model.reportRoot, "recommended_commits.txt"), L.join("\r\n") + "\r\n", true); } function writePrReport(model) { const c = model.counters; const L = []; L.push("# jQuery " + CVE_ID + " 보안 조치"); L.push(""); L.push("## 목적"); L.push("- " + CVE_ID + " (jQuery htmlPrefilter XSS) 대응: jQuery core를 " + TARGET_JQUERY_FLOOR_VERSION + " 이상으로 상향"); L.push("- 적용 조합: jQuery " + model.profile.jquery.targetVersion + " + jQuery Migrate " + model.profile.jquery.migrateVersion); L.push("- Migrate는 구버전 API 호환 유지 및 경고 수집 목적 (안정화 후 제거 검토)"); L.push(""); L.push("## 변경 요약"); L.push("| 항목 | 건수 |"); L.push("|---|---|"); L.push("| 구버전 jQuery core 호출부(Critical) | " + c.Critical + " |"); L.push("| 안전 자동수정(AutoFixed) | " + c.AutoFixed + " |"); L.push("| 콜사이트 추론 자동수정(AutoInferred) | " + c.AutoInferred + " |"); L.push("| 수동 조치(Manual) | " + c.Manual + " |"); L.push("| DOM XSS 검토(XssHigh) | " + c.XssHigh + " |"); L.push("| 벤더 호환성 검토(VendorReview) | " + c.VendorReview + " |"); L.push("| FocusQueue(잔여 검토 대상) | " + c.FocusQueue + " |"); L.push(""); L.push("## 벤더 영향범위"); const libs = {}; model.pluginInv.forEach(function (r) { libs[r[1]] = r[5]; }); Object.keys(libs).forEach(function (k) { L.push("- **" + k + "**: " + libs[k]); }); L.push(""); L.push("## 테스트 계획"); L.push("- [ ] 주요 화면 렌더링/조회/저장 동작"); L.push("- [ ] jqGrid: 렌더링, 페이징, 정렬, 검색, 인라인 편집, formatter, subgrid"); L.push("- [ ] jquery-ui: datepicker, dialog, button, tabs, autocomplete"); L.push("- [ ] select2: placeholder, ajax 검색, 다중 선택, 초기값"); L.push("- [ ] autoNumeric: 금액 입력, 콤마, blur/focus, 저장값, readonly/disabled"); L.push("- [ ] 2망 Chrome smoke에서 Runtime Probe로 JQMIGRATE warning / JS error 0건 확인"); L.push("- [ ] IE_MODE_REQUIRED/핵심업무 화면만 Edge IE mode 최종 샘플링"); L.push(""); L.push("## 운영 반영 전 체크"); L.push("- [ ] Runtime Probe script 제거 (verify-clean 모드 FAIL 항목)"); L.push("- [ ] verify-clean 모드 통과 (구버전 jQuery 잔존 0건)"); L.push("- [ ] CI branch build 성공"); L.push(""); L.push("생성: " + TOOL_NAME + " v" + TOOL_VERSION); writeUtf8(path.join(model.reportRoot, "pr_description.md"), L.join("\n") + "\n", true); const B = []; B.push("# CI / 배포 체크리스트"); B.push(""); B.push("- [ ] fix/jquery-cve-2020-11023 브랜치에서 branch build 성공"); B.push("- [ ] 배포 대상 환경 확인 (개발 -> 검증 -> 운영 순서)"); B.push("- [ ] 주요 화면 " + Math.min(model.pages.length, 10) + "개 이상 수동 테스트 완료"); B.push("- [ ] 2망 Chrome smoke 완료, IE_MODE_REQUIRED/핵심업무 화면은 Edge IE mode 최종 샘플링"); B.push("- [ ] Runtime Probe 제거 확인 (verify-clean 통과)"); B.push("- [ ] jquery-1.10.2.min.js 파일 삭제 또는 참조 0건 확인"); B.push("- [ ] Migrate 콘솔 경고 잔존 여부 기록"); B.push("- [ ] 롤백 계획: 이전 커밋 revert + 캐시 무효화"); writeUtf8(path.join(model.reportRoot, "ci_checklist.md"), B.join("\n") + "\n", true); writeUtf8(path.join(model.reportRoot, "bamboo_checklist.md"), B.join("\n") + "\n", true); } function writeRuntimeChecklist(model) { const L = []; L.push("RUNTIME TEST CHECKLIST (" + TOOL_NAME + " v" + TOOL_VERSION + ")"); L.push(""); L.push("[공통]"); L.push("1. 1망에서는 code-only/static 보고서와 AS-IS/TO-BE diff를 확인"); L.push("2. 2망에서 Eclipse/Tomcat 또는 개발계 화면을 Chrome으로 열고 아래 페이지 접속"); L.push("3. 화면 우측 하단 JQ35 배지 클릭 -> 패널에서 E(에러)/M(마이그레이트 경고) 수치 확인"); L.push("4. Copy 버튼으로 로그 복사 후 기록"); L.push("5. runtime_scenarios.html의 시나리오를 1차 최소 -> 2차 안정화 -> 3차 최대/후속 순서로 수행"); L.push("6. IE_MODE_REQUIRED 또는 핵심업무 대표 화면만 Edge IE mode에서 최종 샘플링"); L.push(""); L.push("[jQuery core를 로드하는 페이지 목록]"); model.pages.filter(function (p) { return p.hasCore; }).forEach(function (p) { L.push("- " + p.rel + " (core v" + (p.coreVer || "?") + (p.hasMigrate ? ", migrate O" : ", migrate X") + ")"); }); L.push(""); L.push("[체크 항목]"); L.push("- JQMIGRATE warning 0건 목표 (있으면 apiFindings 대조)"); L.push("- JS error 0건"); L.push("- AJAX error 없음"); L.push("- jqGrid/select2/autoNumeric/datepicker 동작"); L.push(""); L.push("[자동 생성 시나리오 상위 " + Math.min(30, model.runtimeScenarios.length) + "건]"); model.runtimeScenarios.slice(0, 30).forEach(function (s) { L.push("- " + s.id + " " + stageLabel(s.stage) + " " + (s.page || s.findingRel) + " :: " + s.action + " / PASS: " + s.passWhen); }); L.push(""); L.push("[Runtime Parity]"); L.push("- code-only/Local Lab OK: " + model.counters.RuntimeParityLocalOk + " / Chrome smoke required: " + model.counters.RuntimeChromeSmokeRequired + " / IE final sample required: " + model.counters.RuntimeIeFinalSampleRequired); L.push("- 상세: runtime_parity.html, runtimeParity.csv, ieModeRisk.csv"); writeUtf8(path.join(model.reportRoot, "runtime_test_checklist.txt"), L.join("\r\n") + "\r\n", true); } function writeRuntimeLabGuide(model) { const L = []; L.push("# Runtime Lab Guide"); L.push(""); L.push("생성: " + TOOL_NAME + " v" + TOOL_VERSION); L.push(""); L.push("## 목적"); L.push(""); L.push("- 이 가이드는 폐쇄망 반입물이 아니라 Codex/로컬 개발 PC에서 무겁게 실험할 때 쓰는 선택형 안내입니다."); L.push("- 본체 ZIP에는 Docker image, JDK, Tomcat, Maven을 포함하지 않습니다."); L.push("- 목표는 1망 code-only/mock 판정과 2망 Chrome smoke 결과의 차이를 줄이고, Edge IE mode 최종 샘플링 대상을 정확히 줄이는 것입니다."); L.push(""); L.push("## 현재 보고서 기준"); L.push(""); L.push("- 1망 code-only/Local Lab 종료 후보: " + model.counters.RuntimeParityLocalOk); L.push("- 2망 Chrome smoke 필요: " + model.counters.RuntimeChromeSmokeRequired + " / Spring/Tomcat 필요 페이지 " + model.counters.SpringRuntimePages); L.push("- Edge IE mode final sample 필요: " + model.counters.RuntimeIeFinalSampleRequired + " / 후보 페이지 " + model.counters.IeModeRiskPages); L.push(""); L.push("## 권장 실험 흐름"); L.push(""); L.push("1. Eclipse 또는 빌드 도구로 TO-BE WAR를 만듭니다. 빌드가 어렵다면 WebContent만으로 Local Lab을 먼저 유지합니다."); L.push("2. runtime-lab/inbox/app.war 위치에 WAR를 둡니다. Docker를 쓸 수 있으면 runtime-lab/docker-compose.tomcat7.yml을 사용합니다."); L.push("3. Chrome에서 http://127.0.0.1:18089/ 로 접속해 Probe 배지의 jQuery/Migrate/JSERROR/AJAXERROR를 확인합니다."); L.push("4. runtime_parity.html의 CHROME_SMOKE_REQUIRED 항목부터 확인하고, IE_FINAL_SAMPLE_REQUIRED 항목은 Windows Edge IE mode에서 최종 샘플링합니다."); L.push("5. 복사 가능한 결과는 voyager_packet.txt 또는 Probe Copy 로그로 외부 Codex에 전달합니다."); L.push(""); L.push("## Docker 실험의 한계"); L.push(""); L.push("- tomcat:7.0.109-jdk8-openjdk 이미지는 대략 수백 MB 규모입니다. 본체 반입 ZIP에 넣지 않습니다."); L.push("- Mac arm64에서는 linux/amd64 에뮬레이션이 필요할 수 있습니다."); L.push("- JEUS/WebtoB, 세션, 인증, DB, 파일스토리지, 레거시 미들웨어 기능은 Tomcat Docker로 완전 재현되지 않습니다."); L.push("- IE mode 렌더링은 Windows Edge에서만 확인 가능합니다."); writeUtf8(path.join(model.reportRoot, "runtime_lab_guide.md"), L.join("\n") + "\n", true); } function writeSampleProfile(model) { const p = path.join(model.reportRoot, "project-profile.sample.json"); writeUtf8(p, JSON.stringify({ webContentDir: "WebContent", webRootCandidates: model.profile.webRootCandidates, webRootSignals: model.profile.webRootSignals, pathVariables: model.profile.pathVariables, vendorPatterns: model.profile.vendorPatterns, vendorRecommendations: model.profile.vendorRecommendations, appScriptHints: model.profile.appScriptHints, ignoreAttrPatterns: model.profile.ignoreAttrPatterns, 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: model.profile.probe.injectTargetHints }, serverScan: model.profile.serverScan, mockDefaults: model.profile.mockDefaults, learnedWrappers: [], learnedFindings: [], sensitiveIdentifiers: [] }, null, 2), false); } const REVIEW_PROGRESS_HEADER = ["Round", "TotalAmbiguousGroups", "CasesInPack", "FocusQueue", "Manual", "Review", "XssHigh", "SourceFingerprint"]; function sourceFingerprint(model) { let h = 0; const s = model.allFiles.map(function (f) { return f.rel + ":" + f.size; }).sort().join("|"); for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) >>> 0; } return h.toString(36).padStart(7, "0"); } function writeReviewProgress(model) { const file = path.join(model.reportRoot, "review_loop_progress.csv"); const headerLine = REVIEW_PROGRESS_HEADER.map(csvCell).join(","); let round = 1; let rows = []; let targetFile = file; let prevFingerprint = ""; if (exists(file)) { try { const prevText = readUtf8(file).replace(/^\uFEFF/, ""); const prevLines = prevText.split(/\r?\n/).filter(Boolean); if (prevLines.length > 0 && prevLines[0] === headerLine) { rows = prevLines.slice(1); round = rows.length + 1; if (rows.length > 0) { const lastCols = rows[rows.length - 1].split(","); prevFingerprint = lastCols[lastCols.length - 1] || ""; } } else if (prevLines.length > 0) { const bak = file.replace(/\.csv$/, "") + ".schema-mismatch." + prevLines.length + "rows.bak.csv"; try { fs.renameSync(file, bak); warn("review_loop_progress.csv had a different/older column layout; moved old file to " + path.basename(bak) + " and started a fresh round 1"); } catch (e2) { warn("review_loop_progress.csv had a different/older column layout and could not be backed up (" + e2.message + "); writing to review_loop_progress.new.csv instead of overwriting it"); targetFile = file.replace(/\.csv$/, "") + ".new.csv"; } } } catch (e) { warn("review_loop_progress.csv could not be read (" + e.message + "); writing to review_loop_progress.new.csv instead of silently discarding round history"); targetFile = file.replace(/\.csv$/, "") + ".new.csv"; } } model.reviewRound = round; const fp = sourceFingerprint(model); if (round > 1 && prevFingerprint && prevFingerprint !== fp) { warn("source tree changed since the previous review-pack round (fingerprint " + prevFingerprint + " -> " + fp + "); caseIds for edited files may no longer line up with earlier learnedFindings answers"); } const row = [round, model.reviewCasesAll, model.reviewCases.length, model.counters.FocusQueue, model.counters.Manual, model.counters.Review, model.counters.XssHigh, fp].map(csvCell).join(","); rows.push(row); writeUtf8(targetFile, [headerLine].concat(rows).join("\r\n") + "\r\n", true); } const HERMES_RECIPES = Object.assign(Object.create(null), { "bool-attr-variable": { hypothesis: "boolean attr 변수값 도메인을 확인하면 .prop(name, booleanExpr) 전환 가능 여부를 로컬에서 확정할 수 있습니다.", staticEvidence: "함수 정의부와 전체 호출부에서 해당 인자가 true/false, Y/N, 1/0 중 하나로만 들어오는지 확인합니다.", runtimeTest: "대표 화면에서 버튼/체크박스/셀렉트의 enabled, checked, selected, readonly 상태가 기존과 같은지 클릭 전/후로 확인합니다.", passWhen: "모든 호출부 값 도메인이 하나로 일치하고 화면 상태가 기존과 같으면 AutoInferred 후보입니다.", failWhen: "문자열 'false', 빈 문자열, 혼합 타입, 서버 문자열이 섞이면 수동 조치로 남깁니다.", profileAction: "project-profile 학습값으로 자동수정을 트리거하지 말고, 코드 패치 또는 향후 로컬 룰팩 후보로 기록합니다.", safeAutomation: "medium-after-domain-check", decisions: ["manual", "review", "ignored"] }, "dom-sink": { hypothesis: "DOM sink 인자가 서버 응답/사용자 입력이면 XSS 검토 대상이고, 고정 literal 또는 escape된 값이면 낮출 수 있습니다.", staticEvidence: "sink 인자의 변수 정의, AJAX success 콜백 파라미터, JSP EL/Request 값 연결 여부를 추적합니다.", runtimeTest: "Lab/Probe에서 대표 응답에 'HERMES' 같은 무해 태그 문자열을 넣고 HTML로 해석되는지 확인합니다.", passWhen: "HTML이 필요 없거나 escape/safe wrapper를 거친 값이면 static-safe 또는 safeWrapper 학습 후보입니다.", failWhen: "서버/사용자 값이 .html/.append/replaceWith로 직접 들어가면 XssHigh로 유지합니다.", profileAction: "공통 렌더러 함수면 learnedWrappers(domSinkArg/safeWrapper), 단일 패턴이면 learnedFindings로 기록합니다.", safeAutomation: "no-code-change", roles: ["domSinkArg", "safeWrapper"], decisions: ["xss-high", "review", "static-safe", "ignored"] }, "wrapper-dom-sink": { hypothesis: "회사 공통 래퍼가 내부적으로 DOM sink인지 확인하면 호출부 전체의 위험도를 전파할 수 있습니다.", staticEvidence: "래퍼 함수 내부에서 .html/.append/.before/.after/replaceWith/parseHTML을 호출하는지 확인합니다.", runtimeTest: "래퍼 호출 화면에서 무해 태그 문자열이 DOM으로 해석되는지 Probe/Lab으로 확인합니다.", passWhen: "래퍼가 escape를 보장하면 safeWrapper, DOM에 HTML로 넣으면 domSinkArg입니다.", failWhen: "래퍼 내부 구현을 찾을 수 없거나 호출부별 의미가 다르면 Review로 유지합니다.", profileAction: "learnedWrappers에 role과 param index를 채워 다음 라운드에 전파합니다.", safeAutomation: "classification-only", roles: ["domSinkArg", "safeWrapper"], decisions: ["xss-high", "review", "static-safe"] }, "jqxhr-shorthand": { hypothesis: ".success/.error/.complete가 jqXHR 체인인지, $.error 같은 일반 함수인지 구분해야 합니다.", staticEvidence: "수신자가 $.ajax(...) 반환값인지, 변수에 담긴 jqXHR인지, 또는 $.error 전역 유틸인지 확인합니다.", runtimeTest: "AJAX 성공/실패 케이스에서 done/fail/always 전환 후 콜백 인자 순서와 화면 메시지가 같은지 확인합니다.", passWhen: "jqXHR 체인이면 done/fail/always 전환, 일반 $.error 유틸이면 jQuery 3.5.1 필수 이슈에서 제외합니다.", failWhen: "콜백 인자 의미를 모르면 Manual로 유지합니다.", profileAction: "반복 오탐이면 learnedFindings decision=ignored/review로 분류만 보정합니다.", safeAutomation: "manual-required", roles: ["ajaxSuccessJson"], decisions: ["manual", "review", "ignored"] }, "bind-to-on": { hypothesis: "jQuery 객체의 .bind는 .on으로 바꿀 수 있지만, 벤더/플러그인 내부는 직접 수정하지 않는 편이 안전합니다.", staticEvidence: "수신자 체인이 $/jQuery인지, 파일이 app 코드인지 vendor 코드인지 확인합니다.", runtimeTest: "이벤트가 중복 바인딩되지 않고 클릭/키보드/submit 동작이 기존과 같은지 확인합니다.", passWhen: "app 코드면 AutoFixed 후보, vendor면 VendorReview로 유지합니다.", failWhen: "Function.prototype.bind 또는 플러그인 내부 이벤트라면 자동수정하지 않습니다.", profileAction: "반복 vendor 오탐이면 vendorPatterns 또는 learnedFindings로 분류를 낮춥니다.", safeAutomation: "high-for-app-low-for-vendor", decisions: ["vendor-review", "review", "ignored"] }, "unbind-to-off": { hypothesis: "jQuery 객체의 .unbind는 .off로 바꿀 수 있지만, 벤더/플러그인 내부는 직접 수정하지 않는 편이 안전합니다.", staticEvidence: "수신자 체인이 $/jQuery인지, 파일이 app 코드인지 vendor 코드인지 확인합니다.", runtimeTest: "이벤트 해제 이후 중복 실행/미실행이 기존과 같은지 확인합니다.", passWhen: "app 코드면 AutoFixed 후보, vendor면 VendorReview로 유지합니다.", failWhen: "플러그인 내부 상태 저장과 결합되어 있으면 벤더 호환성 테스트로 넘깁니다.", profileAction: "반복 vendor 오탐이면 vendorPatterns 또는 learnedFindings로 분류를 낮춥니다.", safeAutomation: "high-for-app-low-for-vendor", decisions: ["vendor-review", "review", "ignored"] }, "_default": { hypothesis: "현재 정적분석만으로는 로컬 확정 근거가 부족한 후보입니다.", staticEvidence: "파일 위치, 함수명, 호출부 fanout, 주변 코드 의도를 확인합니다.", runtimeTest: "대표 화면에서 해당 함수가 실행되는 업무 플로우를 한 번 수행하고 JS error/JQMIGRATE warning을 기록합니다.", passWhen: "동작 의도와 값 출처가 설명 가능하면 learnedFindings로 분류만 보정합니다.", failWhen: "의도/값 출처를 모르면 Review 또는 Manual로 유지합니다.", profileAction: "learnedFindings에 decision과 notes를 남기되 자동수정은 트리거하지 않습니다.", safeAutomation: "classification-only", decisions: ["review", "manual", "ignored"] } }); function hermesRecipe(category) { return HERMES_RECIPES[category] || HERMES_RECIPES["_default"]; } function hermesCaseRecord(g, index) { const cat = g.topCategories[0] || "unknown"; const recipe = hermesRecipe(cat); return { no: index + 1, caseId: g.caseId, kind: g.kind, name: g.name, fanout: g.fanout || 0, occurrences: g.count, category: cat, priority: g.findings[0].priority, confidence: g.findings[0].confidence, locations: g.sampleLocationsShort, question: g.shortQuestion, hypothesis: recipe.hypothesis, staticEvidence: recipe.staticEvidence, runtimeTest: recipe.runtimeTest, passWhen: recipe.passWhen, failWhen: recipe.failWhen, profileAction: recipe.profileAction, safeAutomation: recipe.safeAutomation, roles: (recipe.roles || []).join("|"), decisions: (recipe.decisions || []).join("|"), excerpt: g.compactExcerpt || g.excerpt || "" }; } function hermesProfileTemplate(g, rec) { const recipe = hermesRecipe(rec.category); const tpl = { caseId: rec.caseId, name: rec.name, kind: rec.kind, category: rec.category, fillAfterLocalTest: true, evidenceToRecord: ["staticEvidence", "runtimeTestResult", "passOrFailReason"], allowedDecisions: recipe.decisions || ["review", "manual", "ignored"], learnedFindingTemplate: { caseId: rec.caseId, name: rec.name, decision: (recipe.decisions && recipe.decisions[0]) || "review", notes: "fill after Hermes local verification" } }; if (rec.kind === "FN" && recipe.roles && recipe.roles.length > 0) { tpl.allowedRoles = recipe.roles; tpl.learnedWrapperTemplate = { caseId: rec.caseId, name: rec.name, role: recipe.roles[0], roleOptions: recipe.roles, calleeParamIndex: null, sinkParamIndex: 0, notes: "fill exact role/index after local verification" }; } return tpl; } function writeHermesTestbench(model, records) { const R = model.reportRoot; const payload = { tool: TOOL_NAME, version: TOOL_VERSION, round: model.reviewRound, generatedAt: new Date().toISOString(), sourceRoot: model.sourceRoot, webContentRoot: model.webContentRoot, counters: { focusQueue: model.counters.FocusQueue, manual: model.counters.Manual, review: model.counters.Review, xssHigh: model.counters.XssHigh }, cases: records }; writeUtf8(path.join(R, "hermes_testbench_data.json"), JSON.stringify(payload, null, 2) + "\n", false); const H = []; H.push("Hermes Testbench
Hermes Local Testbench

jQuery 취약점 검수 시험장

외부 통신 없이 report 폴더에서 실행되는 CASE별 모킹 페이지입니다. 실제 업무 화면 대체가 아니라 로컬 검수 기준을 재현하는 용도입니다.
v" + htmlEsc(TOOL_VERSION) + "round " + htmlEsc(model.reviewRound) + "" + htmlEsc(records.length) + " cases
"); H.push(""); H.push(""); writeUtf8(path.join(R, "hermes_testbench.html"), H.join("\n"), false); } function writeHermesLocalPack(model) { const R = model.reportRoot; ensureDir(R); const records = model.reviewCases.map(hermesCaseRecord); writeCsv(path.join(R, "hermes_review_matrix.csv"), ["No", "CaseId", "Kind", "Name", "FanOut", "Occurrences", "Category", "Priority", "Confidence", "Locations", "Question", "Hypothesis", "StaticEvidence", "RuntimeTest", "PassWhen", "FailWhen", "ProfileAction", "SafeAutomation", "AllowedRoles", "AllowedDecisions"], records.map(function (r) { return [r.no, r.caseId, r.kind, r.name, r.fanout, r.occurrences, r.category, r.priority, r.confidence, r.locations, r.question, r.hypothesis, r.staticEvidence, r.runtimeTest, r.passWhen, r.failWhen, r.profileAction, r.safeAutomation, r.roles, r.decisions]; })); const M = []; M.push("# Hermes 로컬 검수팩"); M.push(""); M.push("생성: " + TOOL_NAME + " v" + TOOL_VERSION + " / round " + model.reviewRound); M.push(""); M.push("## 목적"); M.push("- 외부 AI에게 묻기 전에 로컬에서 확인할 근거와 테스트를 정리합니다."); M.push("- 외부 AI 답변을 받은 뒤에도 같은 기준으로 검수해서 project-profile.json에 넣을지 결정합니다."); M.push("- 이 파일은 코드 자동수정을 만들지 않습니다. learnedWrappers/learnedFindings도 분류 보정만 수행합니다."); M.push(""); M.push("## 사용 순서"); M.push("1. hermes_review_matrix.csv에서 CaseId별 확인 항목을 봅니다."); M.push("2. StaticEvidence 항목을 소스에서 확인하고, 필요하면 RuntimeTest를 Lab/Probe 화면에서 수행합니다."); M.push("3. 확인 결과가 충분하면 hermes_profile_patch.sample.json의 해당 template을 채워 project-profile.json으로 옮깁니다."); M.push("4. 같은 --report 폴더로 review-pack 또는 hermes-pack을 다시 실행해 FocusQueue/Manual/Review 감소 여부를 확인합니다."); M.push(""); M.push("## 요약"); M.push("- 전체 애매한 그룹: " + model.reviewCasesAll); M.push("- 이번 검수팩 포함: " + records.length); M.push("- FocusQueue: " + model.counters.FocusQueue + " / Manual: " + model.counters.Manual + " / Review: " + model.counters.Review + " / XssHigh: " + model.counters.XssHigh); M.push(""); M.push("## Case별 로컬 테스트 설계"); records.forEach(function (r) { M.push(""); M.push("### CASE " + r.no + " " + r.caseId + " " + r.name); M.push("- 위치: " + r.locations); M.push("- 유형: " + r.category + " / " + r.priority + " / fanout=" + r.fanout + " / occ=" + r.occurrences); M.push("- 질문: " + r.question); M.push("- 가설: " + r.hypothesis); M.push("- 정적 확인: " + r.staticEvidence); M.push("- 로컬 실행 테스트: " + r.runtimeTest); M.push("- 통과 기준: " + r.passWhen); M.push("- 실패 기준: " + r.failWhen); M.push("- profile 반영: " + r.profileAction); }); writeUtf8(path.join(R, "hermes_test_plan.md"), M.join("\n") + "\n", true); const profilePatch = { tool: TOOL_NAME, version: TOOL_VERSION, purpose: "Hermes local verification templates. Do not merge templates unchanged.", doNotMergeUnedited: true, howToUse: [ "Run local static/runtime checks described in hermes_test_plan.md.", "Copy only verified learnedWrapperTemplate or learnedFindingTemplate into project-profile.json.", "Never use this file to trigger code changes; learned entries only adjust classification." ], learnedWrappers: [], learnedFindings: [], templates: records.map(function (r, i) { return hermesProfileTemplate(model.reviewCases[i], r); }) }; writeUtf8(path.join(R, "hermes_profile_patch.sample.json"), JSON.stringify(profilePatch, null, 2) + "\n", false); const localReport = { tool: TOOL_NAME, version: TOOL_VERSION, round: model.reviewRound, sourceFingerprint: sourceFingerprint(model), totalAmbiguousGroups: model.reviewCasesAll, casesInPack: records.length, files: ["hermes_test_plan.md", "hermes_review_matrix.csv", "hermes_profile_patch.sample.json", "hermes_testbench.html", "hermes_testbench_data.json"], cases: records }; writeUtf8(path.join(R, "hermes_local_report.json"), JSON.stringify(localReport, null, 2) + "\n", false); writeHermesTestbench(model, records); log("hermes local pack: " + records.length + " case(s) -> hermes_test_plan.md / hermes_review_matrix.csv / hermes_testbench.html"); } function writeReviewPack(model) { writeReviewProgress(model); const R = model.reportRoot; ensureDir(R); const L = []; L.push("JQUERY35_AI_REVIEW_PACK v" + TOOL_VERSION + " r=" + model.reviewRound + " cases=" + model.reviewCases.length + "/" + model.reviewCasesAll); L.push("Return JSON only. roles=ajaxSuccessJson|domSinkArg|safeWrapper decisions=xss-high|review|manual|static-safe|vendor-review|ignored"); L.push(""); model.reviewCases.forEach(function (g, i) { L.push("---- CASE " + (i + 1) + "/" + model.reviewCases.length + " id=" + g.caseId + " ----"); L.push("k=" + (g.kind === "FN" ? "fn" : "pt") + " name=" + g.name + " fanout=" + (g.fanout || 0) + " occ=" + g.count + " cat=" + g.topCategories.join("|") + " pri=" + g.findings[0].priority + "/" + g.findings[0].confidence); L.push("loc=" + g.sampleLocationsShort); L.push("code:"); L.push(g.compactExcerpt); L.push("q=" + g.shortQuestion); L.push(""); }); L.push("ANSWER_JSON="); L.push(JSON.stringify({ learnedWrappers: [ { caseId: "", name: "", role: "ajaxSuccessJson|domSinkArg|safeWrapper", calleeParamIndex: 1, sinkParamIndex: 0, notes: "" } ], learnedFindings: [ { caseId: "", name: "", decision: "xss-high|review|manual|static-safe|vendor-review|ignored", notes: "" } ] })); const capLines = positiveIntOpt(model.opts["max-review-lines"], 300); let lines = L; if (lines.length > capLines) { lines = lines.slice(0, capLines - 1); lines.push("...(truncated " + (L.length - capLines + 1) + " lines, adjust with --max-review-lines)"); } writeUtf8(path.join(R, "ai_review_pack.txt"), lines.join("\r\n") + "\r\n", true); const jsonOut = { tool: TOOL_NAME, version: TOOL_VERSION, round: model.reviewRound, totalAmbiguousGroups: model.reviewCasesAll, cases: model.reviewCases.map(function (g) { return { caseId: g.caseId, kind: g.kind, name: g.name, fanout: g.fanout, occurrences: g.count, sampleLocations: g.sampleLocationsShort, categories: g.topCategories, currentPriority: g.findings[0].priority, currentConfidence: g.findings[0].confidence, question: g.shortQuestion, excerpt: g.compactExcerpt }; }) }; writeUtf8(path.join(R, "ai_review_pack.json"), JSON.stringify(jsonOut) + "\n", false); writeHermesLocalPack(model); log("review pack: " + model.reviewCases.length + "/" + model.reviewCasesAll + " ambiguous groups (round " + model.reviewRound + ")"); } function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } function releaseCandidateFiles() { const root = __dirname; const files = [ "jquery35-local-agent-v5.js", "run-jquery35-v5.js", "README.md", "README_KO.md", "README_FIRST.txt", "RUN_EXAMPLES_KO.txt", "VENDOR_COMPAT_KO.md", "LICENSE", "project-profile.sample.json", "project-profile.public.sample.json", "실행0_웹대시보드.bat", "실행1_분석만.bat", "실행2_TO_BE_자동수정.bat", "실행3_jquery교체시도.bat", "실행4_프로브포함.bat", "실행5_로컬랩서버.bat", "실행6_운영반영전검증.bat", "실행7_AI리뷰팩.bat", "실행8_폐쇄망증빙.bat", "실행9_배포ZIP생성.bat", "runtime-lab/README_RUNTIME_LAB_KO.md", "runtime-lab/docker-compose.tomcat7.yml", "runtime-lab/Dockerfile.tomcat7", "runtime-lab/run-runtime-lab.sh", "runtime-lab/run-runtime-lab.bat", "runtime-lab/run-spring325-tomcat7-local.sh", "runtime-lab/inbox/README.txt" ]; const assetDir = path.join(root, "assets", "jquery"); if (isDir(assetDir)) { fs.readdirSync(assetDir).sort().forEach(function (name) { if (/\.(js|txt)$/i.test(name)) files.push("assets/jquery/" + name); }); } const vendorAssetDir = path.join(root, "assets", "vendor"); function appendVendorTree(absDir, relDir) { fs.readdirSync(absDir).sort().forEach(function (name) { const abs = path.join(absDir, name); const rel = relDir + "/" + name; if (isDir(abs)) appendVendorTree(abs, rel); else files.push(rel); }); } if (isDir(vendorAssetDir)) appendVendorTree(vendorAssetDir, "assets/vendor"); const rulesDir = path.join(root, "rules"); if (isDir(rulesDir)) { fs.readdirSync(rulesDir).sort().forEach(function (name) { if (/\.json$/i.test(name)) files.push("rules/" + name); }); } return files.filter(function (rel) { return exists(path.join(root, rel.split("/").join(path.sep))); }); } function fileManifestRows(files) { return files.map(function (rel) { const abs = path.join(__dirname, rel.split("/").join(path.sep)); const st = fs.statSync(abs); return { path: rel, bytes: st.size, sha256: sha256File(abs) }; }); } function writeAirgapManifest(model) { const releaseFiles = releaseCandidateFiles(); const manifest = { tool: TOOL_NAME, version: TOOL_VERSION, generatedAt: new Date().toISOString(), sourceRoot: model ? model.sourceRoot : "", webContentRoot: model ? model.webContentRoot : "", reportRoot: model ? model.reportRoot : "", jqueryTargetVersion: model ? model.profile.jquery.targetVersion : DEFAULT_JQUERY_VERSION, airgapAssertions: { npmDependencies: "none", nodeModulesRequired: false, nodeBuiltinsOnly: ["fs", "path", "http", "crypto", "child_process", "os"], outboundNetworkCalls: "none; lab mode serves only local mock pages and probe receiver", labBindAddress: "127.0.0.1", cdnRequired: false, generatedHtmlCdnRequired: false, writesSourceTree: "never; target/report only" }, rulepackFiles: model ? (model.profile.rulepackFiles || []).map(function (f) { return { path: f, sha256: exists(f) ? sha256File(f) : "" }; }) : [], reportCounters: model ? { totalFiles: model.counters.TotalFiles, apiFindings: model.counters.ApiFindings, focusQueue: model.counters.FocusQueue, serverEndpoints: model.counters.ServerEndpoints, ajaxMappedToServer: model.counters.AjaxMappedToServer, runtimeParityLocalOk: model.counters.RuntimeParityLocalOk, runtimeParitySpringTomcat: model.counters.RuntimeParitySpringTomcat, runtimeParityIeMode: model.counters.RuntimeParityIeMode } : {}, releaseFiles: fileManifestRows(releaseFiles) }; const R = model && model.reportRoot ? model.reportRoot : path.join(process.cwd(), "release"); ensureDir(R); writeUtf8(path.join(R, "airgap_manifest.json"), JSON.stringify(manifest, null, 2) + "\n", false); const L = []; L.push("AIRGAP MANIFEST " + TOOL_NAME + " v" + TOOL_VERSION); L.push("generatedAt=" + manifest.generatedAt); L.push("nodeModulesRequired=false"); L.push("npmDependencies=none"); L.push("outboundNetworkCalls=none"); L.push("labBindAddress=127.0.0.1"); L.push("releaseFiles=" + manifest.releaseFiles.length); L.push("rulepackFiles=" + manifest.rulepackFiles.length); if (model) { L.push("sourceRoot=" + model.sourceRoot); L.push("webContentRoot=" + model.webContentRoot); L.push("serverEndpoints=" + model.counters.ServerEndpoints); L.push("ajaxMappedToServer=" + model.counters.AjaxMappedToServer + "/" + model.counters.AjaxEndpoints); } L.push(""); L.push("FILES"); manifest.releaseFiles.forEach(function (f) { L.push(f.sha256 + " " + f.path + " " + f.bytes + " bytes"); }); writeUtf8(path.join(R, "airgap_manifest.txt"), L.join("\r\n") + "\r\n", true); return manifest; } let CRC32_TABLE = null; function crc32(buf) { if (!CRC32_TABLE) { CRC32_TABLE = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); CRC32_TABLE[n] = c >>> 0; } } let crc = 0 ^ -1; for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buf[i]) & 0xFF]; return (crc ^ -1) >>> 0; } function dosDateTime(d) { const year = Math.max(1980, d.getFullYear()); const time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2); const date = ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(); return { time: time, date: date }; } function zipHeaderLocal(nameBuf, data, crc, dt) { const h = Buffer.alloc(30); h.writeUInt32LE(0x04034b50, 0); h.writeUInt16LE(20, 4); h.writeUInt16LE(0x0800, 6); h.writeUInt16LE(0, 8); h.writeUInt16LE(dt.time, 10); h.writeUInt16LE(dt.date, 12); h.writeUInt32LE(crc, 14); h.writeUInt32LE(data.length, 18); h.writeUInt32LE(data.length, 22); h.writeUInt16LE(nameBuf.length, 26); h.writeUInt16LE(0, 28); return h; } function zipHeaderCentral(nameBuf, data, crc, dt, offset) { const h = Buffer.alloc(46); h.writeUInt32LE(0x02014b50, 0); h.writeUInt16LE(20, 4); h.writeUInt16LE(20, 6); h.writeUInt16LE(0x0800, 8); h.writeUInt16LE(0, 10); h.writeUInt16LE(dt.time, 12); h.writeUInt16LE(dt.date, 14); h.writeUInt32LE(crc, 16); h.writeUInt32LE(data.length, 20); h.writeUInt32LE(data.length, 24); h.writeUInt16LE(nameBuf.length, 28); h.writeUInt16LE(0, 30); h.writeUInt16LE(0, 32); h.writeUInt16LE(0, 34); h.writeUInt16LE(0, 36); h.writeUInt32LE(0, 38); h.writeUInt32LE(offset, 42); return h; } function writeZip(zipFile, entries) { const parts = []; const centrals = []; let offset = 0; const now = dosDateTime(new Date()); entries.forEach(function (e) { const nameBuf = Buffer.from(e.name, "utf8"); const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), "utf8"); const crc = crc32(data); const local = zipHeaderLocal(nameBuf, data, crc, now); parts.push(local, nameBuf, data); centrals.push(zipHeaderCentral(nameBuf, data, crc, now, offset), nameBuf); offset += local.length + nameBuf.length + data.length; }); const centralStart = offset; let centralSize = 0; centrals.forEach(function (b) { centralSize += b.length; }); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralSize, 12); end.writeUInt32LE(centralStart, 16); end.writeUInt16LE(0, 20); ensureDir(path.dirname(zipFile)); fs.writeFileSync(zipFile, Buffer.concat(parts.concat(centrals).concat([end]))); } function writeReleaseZip(opts) { const outDir = path.resolve(opts.report || path.join(process.cwd(), "release")); ensureDir(outDir); const files = releaseCandidateFiles(); const manifest = { tool: TOOL_NAME, version: TOOL_VERSION, generatedAt: new Date().toISOString(), airgap: { npmDependencies: "none", nodeModulesRequired: false, outboundNetworkCalls: "none" }, files: fileManifestRows(files) }; const entries = files.map(function (rel) { const abs = path.join(__dirname, rel.split("/").join(path.sep)); return { name: TOOL_NAME + "-" + TOOL_VERSION + "/" + rel, data: fs.readFileSync(abs) }; }); entries.push({ name: TOOL_NAME + "-" + TOOL_VERSION + "/airgap_release_manifest.json", data: JSON.stringify(manifest, null, 2) + "\n" }); const zipFile = path.join(outDir, TOOL_NAME + "-v" + TOOL_VERSION + "-public.zip"); writeZip(zipFile, entries); writeUtf8(path.join(outDir, "airgap_release_manifest.json"), JSON.stringify(manifest, null, 2) + "\n", false); log("release zip written: " + zipFile + " (" + entries.length + " entries)"); return zipFile; } function writeAllReports(model, extra) { ensureDir(model.reportRoot); writeCsvReports(model); writeXls(model); writeIndexHtml(model); writePacket(model); writeAiVerdictPack(model); writeChatSummary(model); writeSampleProfile(model); writeRuntimeChecklist(model); writeRuntimeLabGuide(model); writeRecommendedCommits(model); writeAirgapManifest(model); if (!model.opts["no-lab"]) writeMockFiles(model); if (extra && extra.pr) writePrReport(model); log("report written: " + model.reportRoot); log(" - index.html (dashboard), summary.csv, apiFindings.csv, findingCategorySummary.csv, directoryRiskSummary.csv, focusQueue.csv, jquery35_report.xls"); log(" - runtime_scenarios.html / runtimeScenarios.csv / uiElementInventory.csv / selectorElementMap.csv"); log(" - runtime_parity.html / runtimeParity.csv / ieModeRisk.csv / runtime_lab_guide.md"); log(" - voyager_packet.txt / assistant_packet.txt / ai_verdict_packet.txt / verdict_evidence.html / chat_summary.txt / airgap_manifest.txt"); } const MIME = { ".html": "text/html", ".htm": "text/html", ".js": "application/javascript", ".css": "text/css", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".json": "application/json", ".txt": "text/plain", ".md": "text/markdown", ".yml": "text/yaml", ".yaml": "text/yaml", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".map": "application/json", ".xls": "application/vnd.ms-excel", ".csv": "text/csv" }; function labTransformJsp(model, wcBase, rel, visited, depth) { if (depth > 10) return ""; const abs = path.join(wcBase, rel.split("/").join(path.sep)); let text; try { text = readLatin1(abs); } catch (e) { try { text = readLatin1(path.join(model.webContentRoot, rel.split("/").join(path.sep))); } catch (e2) { return ""; } } text = text.replace(/<%--[\s\S]*?--%>/g, ""); text = text.replace(/<%@\s*(page|taglib)\b[^%]*%>/gi, ""); const incRe = /<%@\s*include\s+file\s*=\s*(?:"([^"]*)"|'([^']*)')\s*%>|]*\/?>(?:\s*<\/jsp:include>)?/gi; text = text.replace(incRe, function (whole, a, b, c, d) { const raw = a || b || c || d || ""; let sub = applyPathVars(raw, model.profile).split(/[?#]/)[0]; let incRel; if (sub.charAt(0) === "/") incRel = normalizeWcPath(sub); else incRel = normalizeWcPath(toPosix(path.posix.dirname(toPosix(rel))) + "/" + sub); const key = incRel.toLowerCase(); if (visited[key]) return ""; visited[key] = true; const inner = labTransformJsp(model, wcBase, incRel, visited, depth + 1); delete visited[key]; return inner; }); text = text.replace(/]*value\s*=\s*(?:"\$\{([^}"]*)\}"|'\$\{([^}']*)\}')[^>]*\/?>(?:\s*<\/c:out>)?/gi, function (w, a, b) { return "[" + (a || b || "") + "]"; }); text = text.replace(/<\/?(c|fmt|spring|tiles|sec|ui|fn|sitemesh):[a-zA-Z]+\b[^>]*>/g, ""); text = text.replace(/<(\/?)form:([a-zA-Z]+)/g, function (w, close, tag) { const map = { form: "form", input: "input", select: "select", option: "option", textarea: "textarea", checkbox: "input", radiobutton: "input", hidden: "input", label: "label", errors: "span", password: "input" }; return "<" + close + (map[tag.toLowerCase()] || "div"); }); text = applyPathVars(text, model.profile); text = text.replace(/<%=[\s\S]*?%>/g, ""); text = text.replace(/<%[\s\S]*?%>/g, ""); text = text.replace(/\$\{[^{}]*\}/g, ""); return text; } function labPageHtml(model, wcBase, rel) { const visited = {}; visited[rel.toLowerCase()] = true; let body = labTransformJsp(model, wcBase, rel, visited, 0); const banner = '
JQ35 LOCAL LAB (mock) - ' + htmlEsc(rel) + ' - JSP/JSTL/DB not executed. Final verification must run on Eclipse/Tomcat. [page list]
'; const probeTag = ''; const baseTag = ''; if (/]*>/i.test(body) && !/]*>)/i, "$1" + baseTag); } else if (!/]*>/i.test(body)) { body = "" + baseTag + "" + body; } if (/]*>/i.test(body)) { body = body.replace(/(]*>)/i, "$1" + banner); } else { body = banner + body; } if (/<\/body\s*>/i.test(body)) body = body.replace(/<\/body\s*>/i, probeTag + ""); else body += probeTag; return body; } function labPagesListHtml(model) { const parts = []; parts.push('JQ35 Lab pages'); parts.push("

JQ35 Local Lab - page list (" + model.pages.length + ")

"); parts.push('

mock rendering only: Spring controller / DB / session / tiles are NOT executed.

    '); model.pages.slice().sort(function (a, b) { return a.rel < b.rel ? -1 : 1; }).forEach(function (p) { let mark = ""; if (p.oldCore) mark += ' [old jQuery ' + htmlEsc(p.coreVer) + "]"; if (p.riskMultiCore) mark += ' [multi core]'; if (p.riskMigrateMissing) mark += ' [migrate missing]'; parts.push('
  • ' + htmlEsc(p.rel) + "" + mark + "
  • "); }); parts.push("
"); return parts.join("\n"); } function startLab(model, opts) { const port = parseInt(opts.port, 10) || 18080; const wcBase = model.targetWcRoot && isDir(model.targetWcRoot) ? model.targetWcRoot : model.webContentRoot; const probeLogDir = path.join(model.reportRoot, "probeLogs"); const routes = {}; model.ajaxRows.forEach(function (r) { if (r.urlNorm && r.urlNorm.indexOf("_EL_") < 0 && r.urlNorm.indexOf("_JSP_") < 0) { routes[r.urlNorm] = { type: r.mock, method: r.method }; } }); const mockDefaults = mergeConfig(jsonClone(DEFAULT_MOCK_DEFAULTS), model.profile.mockDefaults || {}); const mock = { json: JSON.stringify(mockDefaults.json), html: String(mockDefaults.html || DEFAULT_MOCK_DEFAULTS.html) }; const probeJs = genProbeJs(); const server = http.createServer(function (req, res) { try { const u = new URL(req.url, "http://localhost"); const p = decodeURIComponent(u.pathname); if (p === "/__probe/log" && req.method === "POST") { let body = ""; req.on("data", function (ch) { if (body.length < 2 * 1024 * 1024) body += ch; }); req.on("end", function () { ensureDir(probeLogDir); const fn = path.join(probeLogDir, "probe-" + new Date().toISOString().slice(0, 10) + ".log"); fs.appendFileSync(fn, "==== " + new Date().toISOString() + " ====\r\n" + body + "\r\n\r\n"); res.writeHead(200, { "Content-Type": "application/json" }); res.end('{"ok":true}'); }); return; } if (p === "/" || p === "/_pages") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(labPagesListHtml(model)); return; } if (p === "/_page") { const rel = normalizeWcPath(u.searchParams.get("p") || ""); if (!model.ctxByRel[rel] || !model.ctxByRel[rel].isPage) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("unknown page: " + rel + " (see /_pages)"); return; } const html = labPageHtml(model, wcBase, rel); res.writeHead(200, { "Content-Type": "text/html" }); res.end(Buffer.from(html, "latin1")); return; } if (p.indexOf("/_report/") === 0) { const rp = path.join(model.reportRoot, normalizeWcPath(p.slice(9)).split("/").join(path.sep)); if (isUnderDir(rp, model.reportRoot) && exists(rp) && !isDir(rp)) { res.writeHead(200, { "Content-Type": (MIME[path.extname(rp).toLowerCase()] || "application/octet-stream") + "; charset=utf-8" }); res.end(fs.readFileSync(rp)); return; } res.writeHead(404); res.end("not found"); return; } if (p === "/js/" + PROBE_FILE_NAME) { const onDisk = path.join(wcBase, "js", PROBE_FILE_NAME); res.writeHead(200, { "Content-Type": "application/javascript" }); res.end(exists(onDisk) ? fs.readFileSync(onDisk) : Buffer.from(probeJs, "latin1")); return; } if (p === "/favicon.ico") { res.writeHead(204); res.end(); return; } const wcRel = normalizeWcPath(p); if (wcRel.toLowerCase().indexOf("web-inf/") === 0) { res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); res.end("WEB-INF direct access blocked. Use /_page?p=" + wcRel); return; } const fileAbs = path.join(wcBase, wcRel.split("/").join(path.sep)); if (isUnderDir(fileAbs, wcBase) && exists(fileAbs) && !isDir(fileAbs)) { res.writeHead(200, { "Content-Type": MIME[path.extname(fileAbs).toLowerCase()] || "application/octet-stream" }); res.end(fs.readFileSync(fileAbs)); return; } const routeKey = "/" + wcRel; const route = routes[routeKey] || routes[wcRel]; if (route || /\.do$/i.test(wcRel)) { const t = route ? route.type : "json"; if (req.method === "POST" || req.method === "PUT") { let b2 = ""; req.on("data", function (ch) { if (b2.length < 2 * 1024 * 1024) b2 += ch; }); req.on("end", function () { res.writeHead(200, { "Content-Type": t === "json" ? "application/json" : "text/html; charset=utf-8" }); res.end(t === "json" ? mock.json : mock.html); }); return; } res.writeHead(200, { "Content-Type": t === "json" ? "application/json" : "text/html; charset=utf-8" }); res.end(t === "json" ? mock.json : mock.html); return; } res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("not found: " + p + "\n(hint: pages -> /_pages, reports -> /_report/index.html)"); } catch (e) { res.writeHead(500, { "Content-Type": "text/plain" }); res.end("lab error: " + e.message); } }); server.listen(port, "127.0.0.1", function () { log("local lab server started (mock, backend NOT executed)"); log(" pages : http://localhost:" + port + "/_pages"); log(" report : http://localhost:" + port + "/_report/index.html"); log(" probe rx: POST http://localhost:" + port + "/__probe/log -> " + probeLogDir); log(" serving : " + wcBase); log("stop with Ctrl+C"); }); return server; } const UI_STATE_KEYS = ["source", "target", "report", "profile", "rulepack", "serverSource", "verifySource", "labPort", "migrateTrace", "noServerScan", "maxReviewCases", "contextLines", "maxReviewLines"]; const UI_RUN_MODES = Object.assign(Object.create(null), { "plan": 1, "autofix": 1, "patch-jquery": 1, "probe": 1, "verify-clean": 1, "pr-report": 1, "packet": 1, "ai-verdict-packet": 1, "review-pack": 1, "hermes-pack": 1, "airgap-manifest": 1, "release-zip": 1 }); function uiStateFile(opts) { return path.resolve(opts["ui-state"] || path.join(os.homedir(), ".jquery35-local-agent-ui.json")); } function defaultUiState(opts) { return { source: opts.source || "", target: opts.target || "", report: opts.report || path.join(process.cwd(), "jquery35_report_v5"), profile: opts.profile || "", rulepack: opts.rulepack || "", serverSource: opts["server-source"] || "", verifySource: "", labPort: String(positiveIntOpt(opts["lab-port"], 18080)), migrateTrace: opts["migrate-trace"] === true, noServerScan: opts["no-server-scan"] === true, maxReviewCases: opts["max-review-cases"] || "20", contextLines: opts["context-lines"] || "1", maxReviewLines: opts["max-review-lines"] || "300" }; } function loadUiState(opts) { const st = defaultUiState(opts); const raw = readJsonMaybe(uiStateFile(opts), false); if (raw) UI_STATE_KEYS.forEach(function (k) { if (raw[k] !== undefined) st[k] = raw[k]; }); ["source", "target", "report", "profile", "rulepack", "serverSource"].forEach(function (k) { const optKey = k === "serverSource" ? "server-source" : k; if (opts[optKey]) st[k] = opts[optKey]; }); if (opts["migrate-trace"]) st.migrateTrace = true; if (opts["no-server-scan"]) st.noServerScan = true; return st.source ? uiApplySourceDefaults(st, raw || {}, !!opts.source && !raw) : st; } function saveUiState(opts, state) { const out = {}; UI_STATE_KEYS.forEach(function (k) { out[k] = state[k]; }); writeUtf8(uiStateFile(opts), JSON.stringify(out, null, 2) + "\n", false); } function normalizedUiState(input, prev) { const out = Object.assign({}, prev || {}); UI_STATE_KEYS.forEach(function (k) { if (input[k] === undefined) return; if (k === "migrateTrace" || k === "noServerScan") out[k] = input[k] === true || input[k] === "true"; else out[k] = String(input[k] == null ? "" : input[k]).trim(); }); out.labPort = String(positiveIntOpt(out.labPort, 18080)); out.maxReviewCases = String(positiveIntOpt(out.maxReviewCases, 20)); out.contextLines = String(positiveIntOpt(out.contextLines, 1)); out.maxReviewLines = String(positiveIntOpt(out.maxReviewLines, 300)); return out; } function uiHasExt(root, exts, maxDirs) { if (!root || !isDir(root)) return false; const want = {}; exts.forEach(function (e) { want[e.toLowerCase()] = 1; }); let seenDirs = 0; const stack = [root]; while (stack.length && seenDirs < (maxDirs || 500)) { const dir = stack.pop(); seenDirs++; let names = []; try { names = fs.readdirSync(dir); } catch (e) { continue; } for (let i = 0; i < names.length; i++) { const name = names[i]; const abs = path.join(dir, name); let st; try { st = fs.statSync(abs); } catch (e) { continue; } if (st.isDirectory()) { if (!EXCLUDE_DIRS[name.toLowerCase()]) stack.push(abs); } else if (want[path.extname(name).toLowerCase()]) { return true; } } } return false; } function uiProjectRootFromSource(root) { const parts = toPosix(path.resolve(root)).split("/"); const leaf = parts[parts.length - 1].toLowerCase(); if (leaf === "webcontent" && parts.length > 1) return path.dirname(root); if (leaf === "webapp" && parts.length >= 3 && parts[parts.length - 2].toLowerCase() === "main" && parts[parts.length - 3].toLowerCase() === "src") { return path.resolve(root, "..", "..", ".."); } return root; } function uiFindServerSource(sourceRoot, projectRoot) { const roots = uniq([ path.join(projectRoot, "src", "main", "java"), path.join(projectRoot, "src"), path.join(projectRoot, "java"), path.join(projectRoot, "WEB-INF", "src"), path.join(sourceRoot, "src", "main", "java"), path.join(sourceRoot, "src"), sourceRoot, projectRoot ]); for (let i = 0; i < roots.length; i++) { if (uiHasExt(roots[i], [".java", ".xml"], 220)) return path.resolve(roots[i]); } return ""; } function uiFindRulepack(sourceRoot, projectRoot) { const files = [ path.join(sourceRoot, "jquery35-rulepack.json"), path.join(projectRoot, "jquery35-rulepack.json") ]; for (let i = 0; i < files.length; i++) if (exists(files[i])) return path.resolve(files[i]); const dirs = [ path.join(sourceRoot, "jquery35-rulepack"), path.join(projectRoot, "jquery35-rulepack") ]; for (let j = 0; j < dirs.length; j++) if (isDir(dirs[j])) return path.resolve(dirs[j]); return ""; } function uiFindProfile(sourceRoot, projectRoot, reportRoot) { const files = [ path.join(sourceRoot, "project-profile.json"), path.join(projectRoot, "project-profile.json"), path.join(reportRoot, "project-profile.generated.json") ]; for (let i = 0; i < files.length; i++) if (exists(files[i])) return path.resolve(files[i]); return path.resolve(path.join(reportRoot, "project-profile.generated.json")); } function uiWriteGeneratedProfile(file, sourceRoot, projectRoot, webContentRoot, serverSource) { if (!file || exists(file)) return; let webRel = "WebContent"; if (webContentRoot && path.resolve(webContentRoot).toLowerCase() === path.resolve(sourceRoot).toLowerCase()) webRel = "."; else if (webContentRoot && isUnderDir(webContentRoot, sourceRoot)) webRel = toPosix(path.relative(sourceRoot, webContentRoot)) || "."; const profile = { generatedBy: TOOL_NAME + " v" + TOOL_VERSION + " UI auto setup", note: "Auto-generated from source path. Keep learnedWrappers/learnedFindings here after local review.", webContentDir: webRel, 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(), 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: DEFAULT_PROBE_HINTS.slice() }, serverScan: mergeConfig(jsonClone(DEFAULT_SERVER_SCAN), serverSource ? { sourceOverride: serverSource } : {}), mockDefaults: jsonClone(DEFAULT_MOCK_DEFAULTS), learnedWrappers: [], learnedFindings: [], sensitiveIdentifiers: [] }; writeUtf8(file, JSON.stringify(profile, null, 2) + "\n", false); } function uiDerivedPaths(source, createProfile) { const src = String(source || "").trim(); if (!src) return {}; const root = path.resolve(src); const projectRoot = uiProjectRootFromSource(root); const parent = path.dirname(projectRoot); const base = path.basename(projectRoot).replace(/[\\\/]+$/, "") || "legacy-app"; const target = path.join(parent, base + "_jquery35_tobe"); const report = path.join(parent, base + "_jquery35_report_v5"); const rulepack = uiFindRulepack(root, projectRoot); const profileForDetect = defaultProfile(loadRulepack(rulepack ? { rulepack: rulepack } : {}, root)); const webContentRoot = detectWebContent(root, profileForDetect) || ""; const serverSource = uiFindServerSource(root, projectRoot); const profile = uiFindProfile(root, projectRoot, report); if (createProfile) uiWriteGeneratedProfile(profile, root, projectRoot, webContentRoot, serverSource); return { source: root, projectRoot: projectRoot, webContentRoot: webContentRoot, target: target, report: report, verifySource: target, profile: profile, profileExists: exists(profile), rulepack: rulepack, serverSource: serverSource }; } function uiApplySourceDefaults(state, prev, force) { if (!state.source) return state; const prevSource = prev && prev.source ? path.resolve(prev.source) : ""; const sourceChanged = !prevSource || path.resolve(state.source) !== prevSource; const d = uiDerivedPaths(state.source, true); ["target", "report", "verifySource", "profile", "serverSource"].forEach(function (k) { if (force || sourceChanged || !state[k]) state[k] = d[k] || state[k]; }); if ((force || sourceChanged || !state.rulepack) && d.rulepack) state.rulepack = d.rulepack; state.source = d.source || state.source; return state; } function uiListDirectory(dirRaw) { let dir = String(dirRaw || "").trim(); if (!dir) dir = os.homedir(); dir = path.resolve(dir); if (!isDir(dir)) dir = path.dirname(dir); const roots = []; if (process.platform === "win32") { for (let c = 65; c <= 90; c++) { const drive = String.fromCharCode(c) + ":\\"; if (isDir(drive)) roots.push(drive); } } else { roots.push("/"); } let entries = []; try { entries = fs.readdirSync(dir).map(function (name) { const abs = path.join(dir, name); return { name: name, path: abs, dir: isDir(abs) }; }).filter(function (e) { return e.dir && e.name.charAt(0) !== "."; }).sort(function (a, b) { return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; }).slice(0, 300); } catch (e) { } return { path: dir, parent: path.dirname(dir), roots: roots, entries: entries, sep: path.sep }; } function uiAddArg(args, name, val) { if (val !== undefined && val !== null && String(val) !== "") args.push("--" + name, String(val)); } function uiBuildArgs(mode, state, forLab, useVerifySource) { if (!UI_RUN_MODES[mode] && mode !== "lab") throw new Error("unsupported ui mode: " + mode); const args = ["--mode", mode]; const report = state.report || ""; if (mode === "release-zip") { if (!report) throw new Error("report/release folder is required"); uiAddArg(args, "report", report); return args; } let source = state.source || ""; const sourceIsVerify = mode === "verify-clean" || useVerifySource === true; if (sourceIsVerify) source = state.verifySource || state.target || state.source || ""; if (!source) throw new Error("source is required"); if (!report) throw new Error("report folder is required"); uiAddArg(args, "source", source); if (!sourceIsVerify && state.target) uiAddArg(args, "target", state.target); if ((mode === "autofix" || mode === "patch-jquery" || mode === "probe") && !state.target) { throw new Error("--target is required for mode " + mode); } uiAddArg(args, "report", report); uiAddArg(args, "profile", state.profile); uiAddArg(args, "rulepack", state.rulepack); uiAddArg(args, "server-source", state.serverSource); if (state.noServerScan) args.push("--no-server-scan"); if (state.migrateTrace && (mode === "patch-jquery" || mode === "probe" || mode === "autofix")) args.push("--migrate-trace"); if (mode === "review-pack" || mode === "hermes-pack") { uiAddArg(args, "max-review-cases", state.maxReviewCases); uiAddArg(args, "context-lines", state.contextLines); uiAddArg(args, "max-review-lines", state.maxReviewLines); } if (forLab || mode === "lab") uiAddArg(args, "port", state.labPort || "18080"); return args; } const UI_SEQUENCE_STEPS = [ { mode: "plan", verify: false }, { mode: "autofix", verify: false }, { mode: "patch-jquery", verify: false }, { mode: "hermes-pack", verify: true }, { mode: "ai-verdict-packet", verify: true }, { mode: "verify-clean", verify: true }, { mode: "pr-report", verify: true }, { mode: "airgap-manifest", verify: true } ]; function uiRunnerScript() { return path.join(__dirname, "run-jquery35-v5.js"); } function uiJobView(job) { if (!job) return null; return { id: job.id, mode: job.mode, running: !!job.running, startedAt: job.startedAt, finishedAt: job.finishedAt || "", exitCode: job.exitCode, error: job.error || "", log: job.log.join("") }; } function uiAppendLog(job, chunk) { job.log.push(String(chunk || "")); const joined = job.log.join(""); if (joined.length > 120000) job.log = ["...(old log trimmed)\n", joined.slice(joined.length - 100000)]; } function uiReportFileList(state) { const R = state.report || ""; const files = [ ["index.html", "Main report", "report"], ["jquery35_report.xls", "Excel report", "report"], ["voyager_packet.txt", "Voyager copy packet", "report"], ["assistant_packet.txt", "Assistant packet", "report"], ["ai_verdict_packet.txt", "AI verdict packet", "report"], ["verdict_evidence.html", "AI verdict evidence", "report"], ["verdict_evidence.json", "AI verdict JSON", "report"], ["runtime_parity.html", "Runtime parity", "lab"], ["runtimeParity.csv", "Runtime parity CSV", "lab"], ["ieModeRisk.csv", "IE mode risk CSV", "lab"], ["runtime_lab_guide.md", "Runtime lab guide", "lab"], ["focusQueue.csv", "Focus queue", "queue"], ["autoFixed.csv", "Auto fixes", "queue"], ["manualQueue.csv", "Manual queue", "queue"], ["serverEndpoints.csv", "Server endpoints", "server"], ["ajaxToServerMap.csv", "AJAX to server", "server"], ["hermes_server_evidence.json", "Server evidence JSON", "server"], ["ai_review_pack.txt", "AI review pack", "review"], ["ai_review_pack.json", "AI review JSON", "review"], ["hermes_test_plan.md", "Hermes test plan", "review"], ["hermes_review_matrix.csv", "Hermes matrix", "review"], ["hermes_testbench.html", "Hermes testbench", "review"], ["airgap_manifest.txt", "Airgap manifest", "airgap"], ["airgap_manifest.json", "Airgap JSON", "airgap"], ["mock_routes.json", "Mock routes", "lab"], ["runtime_test_checklist.txt", "Runtime checklist", "lab"], ["pr_description.md", "PR description", "ci"], ["ci_checklist.md", "CI checklist", "ci"] ]; const out = []; if (R && isDir(R)) { files.forEach(function (f) { const abs = path.join(R, f[0]); if (!exists(abs) || isDir(abs)) return; const st = fs.statSync(abs); out.push({ file: f[0], label: f[1], group: f[2], size: st.size, href: "/report/" + encodeURIComponent(f[0]) }); }); try { fs.readdirSync(R).sort().forEach(function (name) { if (!/\.zip$/i.test(name)) return; const abs = path.join(R, name); if (!exists(abs) || isDir(abs)) return; const st = fs.statSync(abs); out.push({ file: name, label: "Release ZIP", group: "release", size: st.size, href: "/report/" + encodeURIComponent(name) }); }); } catch (e) { } } return out; } function uiSendJson(res, code, obj) { res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); res.end(JSON.stringify(obj)); } function uiReadJson(req, cb) { let body = ""; req.on("data", function (ch) { body += ch; if (body.length > 1024 * 1024) req.destroy(); }); req.on("end", function () { try { cb(null, body ? JSON.parse(body) : {}); } catch (e) { cb(e); } }); } function uiServeReportFile(res, state, relRaw) { const R = state.report || ""; if (!R || !isDir(R)) { res.writeHead(404); res.end("report folder not found"); return; } const rel = normalizeWcPath(decodeURIComponent(relRaw || "")); const abs = path.resolve(path.join(R, rel.split("/").join(path.sep))); const root = path.resolve(R); if (!(abs.toLowerCase() === root.toLowerCase() || abs.toLowerCase().indexOf(root.toLowerCase() + path.sep) === 0) || !exists(abs) || isDir(abs)) { res.writeHead(404); res.end("not found"); return; } const ext = path.extname(abs).toLowerCase(); const ct = MIME[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": ct + (/^(text\/|application\/json|application\/javascript)/.test(ct) ? "; charset=utf-8" : "") }); res.end(fs.readFileSync(abs)); } function dashboardUiHtml() { const fields = ["source:원본 source", "target:TO-BE target", "report:보고서/report", "profile:project-profile.json", "rulepack:rulepack", "serverSource:Java source", "verifySource:verify-clean source", "labPort:Lab port", "maxReviewCases:review cases", "contextLines:context lines", "maxReviewLines:review lines"].map(function (p) { const a = p.split(":"); const browse = /^(source|target|report|profile|rulepack|serverSource|verifySource)$/.test(a[0]); const auto = a[0] === "source" ? " onchange=\"autoSetupFromSource(true)\" onblur=\"autoSetupFromSource(false)\"" : ""; return "
" + (browse ? "" : "") + "
"; }).join(""); return "JQ35 Local Console
jquery35-local-agent v" + htmlEsc(TOOL_VERSION) + "

JQ35 Local Console

idlelab off

실행

기존 산출물

작업 로그

"; } function startDashboardUi(opts) { let state = loadUiState(opts); let jobSeq = 0; let currentJob = null; let labJob = null; function startChild(mode, args, asLab) { const target = { id: ++jobSeq, mode: mode, running: true, startedAt: new Date().toISOString(), finishedAt: "", exitCode: null, error: "", log: [] }; const child = cp.spawn(process.execPath, [uiRunnerScript()].concat(args), { cwd: __dirname, stdio: ["ignore", "pipe", "pipe"] }); target.child = child; uiAppendLog(target, "$ node run-jquery35-v5.js " + args.map(function (a) { return /\s/.test(a) ? '"' + a + '"' : a; }).join(" ") + "\n"); child.stdout.on("data", function (d) { uiAppendLog(target, d); }); child.stderr.on("data", function (d) { uiAppendLog(target, d); }); child.on("error", function (e) { target.error = e.message; uiAppendLog(target, "\n[spawn error] " + e.message + "\n"); }); child.on("close", function (code) { target.running = false; target.exitCode = code; target.finishedAt = new Date().toISOString(); if (asLab && labJob === target) labJob = null; }); if (asLab) labJob = target; else currentJob = target; return target; } function runSequence() { const target = { id: ++jobSeq, mode: "pipeline", running: true, startedAt: new Date().toISOString(), finishedAt: "", exitCode: null, error: "", log: [] }; currentJob = target; const steps = UI_SEQUENCE_STEPS.slice(); const runStep = function (i) { if (i >= steps.length) { uiAppendLog(target, "\n[pipeline] starting Local Lab from verify source\n"); try { if (!(labJob && labJob.running)) startChild("lab", uiBuildArgs("lab", state, true, true), true); } catch (e) { uiAppendLog(target, "[pipeline] lab skipped: " + e.message + "\n"); } target.running = false; target.exitCode = 0; target.finishedAt = new Date().toISOString(); uiAppendLog(target, "[pipeline] complete\n"); return; } const step = steps[i]; const mode = step.mode; let args; try { args = uiBuildArgs(mode, state, false, step.verify === true); } catch (e) { target.running = false; target.exitCode = 2; target.error = e.message; target.finishedAt = new Date().toISOString(); uiAppendLog(target, "[pipeline] " + mode + " skipped: " + e.message + "\n"); return; } uiAppendLog(target, "\n[pipeline] " + (i + 1) + "/" + steps.length + " " + mode + (step.verify ? " (verify source)" : " (source)") + "\n"); uiAppendLog(target, "$ node run-jquery35-v5.js " + args.map(function (a) { return /\s/.test(a) ? '"' + a + '"' : a; }).join(" ") + "\n"); const child = cp.spawn(process.execPath, [uiRunnerScript()].concat(args), { cwd: __dirname, stdio: ["ignore", "pipe", "pipe"] }); target.child = child; child.stdout.on("data", function (d) { uiAppendLog(target, d); }); child.stderr.on("data", function (d) { uiAppendLog(target, d); }); child.on("error", function (e) { target.error = e.message; uiAppendLog(target, "\n[spawn error] " + e.message + "\n"); }); child.on("close", function (code) { if (code !== 0) { target.running = false; target.exitCode = code; target.finishedAt = new Date().toISOString(); uiAppendLog(target, "[pipeline] stopped at " + mode + " exit=" + code + "\n"); return; } runStep(i + 1); }); }; runStep(0); return target; } const server = http.createServer(function (req, res) { try { const u = new URL(req.url, "http://localhost"); if (req.method === "GET" && (u.pathname === "/" || u.pathname === "/index.html")) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }); res.end(dashboardUiHtml()); return; } if (req.method === "GET" && u.pathname === "/api/state") { uiSendJson(res, 200, { state: state, job: uiJobView(currentJob), lab: uiJobView(labJob), files: uiReportFileList(state) }); return; } if (req.method === "GET" && u.pathname === "/api/browse") { uiSendJson(res, 200, uiListDirectory(u.searchParams.get("dir") || "")); return; } if (req.method === "POST" && u.pathname === "/api/default-paths") { uiReadJson(req, function (err, body) { if (err) { uiSendJson(res, 400, { ok: false, error: err.message }); return; } uiSendJson(res, 200, uiDerivedPaths(body.source || state.source, body.createProfile !== false)); }); return; } if (req.method === "POST" && u.pathname === "/api/config") { uiReadJson(req, function (err, body) { if (err) { uiSendJson(res, 400, { ok: false, error: err.message }); return; } const prevState = Object.assign({}, state); state = normalizedUiState(body, state); state = uiApplySourceDefaults(state, prevState, body.auto === true); saveUiState(opts, state); uiSendJson(res, 200, { ok: true, state: state }); }); return; } if (req.method === "POST" && u.pathname === "/api/run") { uiReadJson(req, function (err, body) { if (err) { uiSendJson(res, 400, { ok: false, error: err.message }); return; } if (currentJob && currentJob.running) { uiSendJson(res, 409, { ok: false, error: "job already running" }); return; } try { const mode = String(body.mode || ""); const args = uiBuildArgs(mode, state, false); const j = startChild(mode, args, false); uiSendJson(res, 200, { ok: true, job: uiJobView(j) }); } catch (e) { uiSendJson(res, 400, { ok: false, error: e.message }); } }); return; } if (req.method === "POST" && u.pathname === "/api/run-sequence") { if (currentJob && currentJob.running) { uiSendJson(res, 409, { ok: false, error: "job already running" }); return; } try { const j = runSequence(); uiSendJson(res, 200, { ok: true, job: uiJobView(j) }); } catch (e) { uiSendJson(res, 400, { ok: false, error: e.message }); } return; } if (req.method === "POST" && u.pathname === "/api/lab/start") { if (labJob && labJob.running) { uiSendJson(res, 409, { ok: false, error: "lab already running" }); return; } try { const args = uiBuildArgs("lab", state, true, true); const j = startChild("lab", args, true); uiSendJson(res, 200, { ok: true, lab: uiJobView(j) }); } catch (e) { uiSendJson(res, 400, { ok: false, error: e.message }); } return; } if (req.method === "POST" && u.pathname === "/api/lab/stop") { if (labJob && labJob.running && labJob.child) { labJob.child.kill(); uiAppendLog(labJob, "\n[ui] stop requested\n"); } uiSendJson(res, 200, { ok: true }); return; } if (req.method === "GET" && u.pathname.indexOf("/report/") === 0) { uiServeReportFile(res, state, u.pathname.slice(8)); return; } if (u.pathname === "/favicon.ico") { res.writeHead(204); res.end(); return; } res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("not found"); } catch (e) { uiSendJson(res, 500, { ok: false, error: e.message }); } }); const port = positiveIntOpt(opts.port, 18088); server.listen(port, "127.0.0.1", function () { log("local web dashboard started"); log(" ui : http://127.0.0.1:" + port + "/"); log(" state : " + uiStateFile(opts)); log("stop with Ctrl+C"); }); return server; } function doVerifyClean(model, opts) { const lines = []; let failCount = 0, warnCount = 0; const add = function (level, name, detail) { lines.push(level + "|" + name + "|" + detail); if (level === "FAIL") failCount++; if (level === "WARN") warnCount++; (level === "FAIL" ? fail : level === "WARN" ? warn : log)(name + ": " + detail); }; if (model.oldCoreRefs.length > 0) { add("FAIL", "old-jquery-refs", model.oldCoreRefs.length + " script tag(s) still load jQuery < " + TARGET_JQUERY_FLOOR_VERSION + ": " + model.counters.OldJquerySrcs); } else add("PASS", "old-jquery-refs", "no jQuery < " + TARGET_JQUERY_FLOOR_VERSION + " references"); const multi = model.pages.filter(function (p) { return p.riskMultiCore; }); if (multi.length > 0) add("FAIL", "multiple-jquery-core", multi.length + " page(s): " + multi.slice(0, 5).map(function (p) { return p.rel; }).join(", ")); else add("PASS", "multiple-jquery-core", "no page loads jQuery core twice"); const probeRefs = []; model.textFiles.forEach(function (ctx) { if (ctx.rel.toLowerCase().indexOf(PROBE_FILE_NAME) >= 0) { probeRefs.push(ctx.rel + " (probe file itself)"); return; } if (ctx.text.indexOf(PROBE_FILE_NAME) >= 0 || ctx.text.indexOf(PROBE_MARKER) >= 0) probeRefs.push(ctx.rel); }); model.allFiles.forEach(function (f) { if (fileNameOf(f.rel) === PROBE_FILE_NAME && TEXT_EXTS.indexOf(f.ext) < 0) probeRefs.push(f.rel); }); if (probeRefs.length > 0) add("FAIL", "probe-leftover", "runtime probe must be removed before production: " + uniq(probeRefs).slice(0, 10).join(", ")); else add("PASS", "probe-leftover", "no runtime probe leftovers"); const crit = model.findings.filter(function (f) { return f.priority === "Critical"; }); if (crit.length > 0) add("FAIL", "critical-findings", crit.length + " critical finding(s) remain"); else add("PASS", "critical-findings", "no critical findings"); const mig = model.pages.filter(function (p) { return p.riskMigrateMissing; }); if (mig.length > 0) add("WARN", "migrate-missing", mig.length + " page(s) load jQuery " + TARGET_JQUERY_FLOOR_VERSION + "+ without Migrate: " + mig.slice(0, 5).map(function (p) { return p.rel; }).join(", ")); else add("PASS", "migrate-missing", "all jQuery " + TARGET_JQUERY_FLOOR_VERSION + "+ pages load Migrate"); const migBefore = model.pages.filter(function (p) { return p.riskMigrateBeforeCore; }); if (migBefore.length > 0) add("WARN", "migrate-order", migBefore.length + " page(s) load Migrate before jQuery core"); else add("PASS", "migrate-order", "migrate load order OK"); const oldFiles = model.scriptInv.filter(function (r) { return String(r[5]).indexOf("OLD_JQUERY_CORE") === 0; }); if (oldFiles.length > 0) add("WARN", "old-jquery-file-exists", oldFiles.length + " old jQuery file(s) still on disk (unreferenced?): " + oldFiles.map(function (r) { return r[0]; }).join(", ")); else add("PASS", "old-jquery-file-exists", "no old jQuery core files on disk"); const synFail = model.syntaxRows.filter(function (r) { return r.result === "FAIL"; }); if (synFail.length > 0) add("WARN", "js-syntax", synFail.length + " js file(s) failed Node syntax check (may be legacy-IE syntax)"); else add("PASS", "js-syntax", "all checked js files parse OK"); add("INFO", "focus-queue-remaining", String(model.focus.length)); add("INFO", "xss-high-remaining", String(model.counters.XssHigh)); add("INFO", "vendor-review-remaining", String(model.counters.VendorReview)); const overall = failCount > 0 ? "FAIL" : warnCount > 0 ? "WARN" : "PASS"; lines.unshift("RESULT=" + overall); lines.unshift("VERIFY_CLEAN " + TOOL_NAME + " v" + TOOL_VERSION + " source=" + model.sourceRoot); writeUtf8(path.join(model.reportRoot, "verify_clean_result.txt"), lines.join("\r\n") + "\r\n", true); log("verify-clean result: " + overall + " (fail=" + failCount + " warn=" + warnCount + ")"); let code = 0; if (failCount > 0) code = 2; else if (warnCount > 0 && opts["warn-as-error"]) code = 1; return { code: code, failCount: failCount, warnCount: warnCount, overall: overall }; } const ST_LAYOUT = [ '<%@ page contentType="text/html; charset=UTF-8" %>', '', '', '' ].join("\r\n"); const ST_LIST_JSP = [ '<%@ page contentType="text/html; charset=UTF-8" %>', '<%@ include file="../../layouts/common_script_lib.jsp" %>', "", '
', '', '
', '', "", "" ].join("\r\n"); const ST_SECOND_JSP = [ '<%@ page contentType="text/html; charset=UTF-8" %>', "", '', '', '', "second page" ].join("\r\n"); const ST_UTIL_JS = [ "function setBtn(sts) {", '\t$("#btn").attr("disabled", sts);', "}", "function toggleAll(flag) {", '\t$(".itm").attr("disabled", flag);', "}", 'setBtn("Y");', 'setBtn("N");', "toggleAll(true);", "toggleAll(false);", "var blean = true;", '$("#lineDel").attr("disabled", blean);', 'var $list = $("#list");', '$list.delegate(', ' ".row",', ' "click",', ' onRow', ');', "function onRow() {", '\t$list.unbind("mouseover");', "}", '$("#panel").removeClass("on");', '$("#chain").removeClass("on").', 'unbind("focus").', 'unbind("blur");', "var fn2 = onRow.bind(this);", 'var greeting = $.trim(" hi ");', "", "" ].join("\r\n"); const ST_JQGRID_JS = '(function($){$.fn.fakeGrid=function(o){this.bind("click",function(){});this.attr("disabled",true);return this;};})(jQuery);'; const ST_WRAPPER_JS = [ "function fnAjaxWrap(url, cb) {", '\t$.ajax({ url: url, dataType: "json" }).success(cb);', "}", "function renderCell(v) {", '\t$("#cellHost").html(v);', "}", "function esc(v) {", "\treturn String(v).replace(/[<>&]/g, \"\");", "}", 'fnAjaxWrap("/board/data.do", function(d){', '\t$("#out").html(d);', "});", "renderCell(resultdata);", '$("#msgBox").append(esc(response));', "" ].join("\r\n"); const ST_CONTROLLER_JAVA = [ "package com.example.sample;", "", "import org.springframework.stereotype.Controller;", "import org.springframework.web.bind.annotation.PostMapping;", "import org.springframework.web.bind.annotation.RequestMapping;", "import org.springframework.web.bind.annotation.ResponseBody;", "", "@Controller", "@RequestMapping(\"/sample\")", "public class SampleController {", " @PostMapping(\"/list.do\")", " @ResponseBody", " public Object list() { return null; }", "}", "" ].join("\n"); const ST_SPRING_XML = [ "", "", " ", " ", "", "" ].join("\n"); function selfTest(opts) { const base = path.join(os.tmpdir(), "jq35-selftest-" + Date.now()); const src = path.join(base, "sample-app"); const wc = path.join(src, "WebContent"); log("self-test sandbox: " + base); writeLatin1(path.join(wc, "WEB-INF", "layouts", "common_script_lib.jsp"), ST_LAYOUT); writeLatin1(path.join(wc, "WEB-INF", "views", "sample", "list.jsp"), ST_LIST_JSP); writeLatin1(path.join(wc, "WEB-INF", "views", "common", "second_page.jsp"), ST_SECOND_JSP); writeLatin1(path.join(wc, "js", "util.js"), ST_UTIL_JS); writeLatin1(path.join(wc, "js", "wrapper_demo.js"), ST_WRAPPER_JS); writeLatin1(path.join(wc, "js", "jquery-1.10.2.min.js"), "/*! jQuery v1.10.2 | (c) fixture */"); writeLatin1(path.join(wc, "js", "jquery-ui-1.10.4.min.js"), "/*! jQuery UI 1.10.4 fixture */"); writeLatin1(path.join(wc, "resources", "jqgrid", "js", "jquery.jqGrid.min.js"), ST_JQGRID_JS); writeLatin1(path.join(wc, "css", "common.css"), ".a{color:#000}"); writeUtf8(path.join(src, "src", "main", "java", "com", "example", "sample", "SampleController.java"), ST_CONTROLLER_JAVA, false); writeUtf8(path.join(src, "src", "main", "webapp", "WEB-INF", "spring", "mvc.xml"), ST_SPRING_XML, false); const results = []; const check = function (name, ok, detail) { results.push({ name: name, ok: !!ok, detail: detail || "" }); (ok ? log : fail)(" [" + (ok ? "PASS" : "FAIL") + "] " + name + (detail && !ok ? " -> " + detail : "")); }; const mk = function (extra) { return Object.assign({ _: [], "safe-packet": true, "max-packet-lines": "400" }, extra); }; try { log("self-test 1/8: plan"); const t1 = path.join(base, "tobe"); const r1 = path.join(base, "report"); const m1 = buildModel(mk({ source: src, target: t1, report: r1 }), "plan"); analyze(m1); writeAllReports(m1, {}); const c1 = m1.counters; check("critical detected (2 old jquery refs)", c1.Critical === 2, "got " + c1.Critical); check("autoFixed >= 6", c1.AutoFixed >= 6, "got " + c1.AutoFixed); check("autoInferred == 3 (sts Y/N + flag bool + blean heuristic)", c1.AutoInferred === 3, "got " + c1.AutoInferred); check("xssHigh >= 1 (.html(response))", c1.XssHigh >= 1, "got " + c1.XssHigh); check("staticHtmlLow >= 1 (append option literal)", c1.StaticHtmlLow >= 1, "got " + c1.StaticHtmlLow); check("vendorReview >= 1 (jqgrid fixture)", c1.VendorReview >= 1, "got " + c1.VendorReview); check("focusQueue > 0", c1.FocusQueue > 0, "got " + c1.FocusQueue); check("server endpoint detected from Java annotation", c1.ServerEndpoints >= 1 && m1.serverEndpointRows.some(function (r) { return r.path === "/sample/list.do" && r.httpMethod === "POST"; }), JSON.stringify(m1.serverEndpointRows)); check("ajax mapped to server endpoint", c1.AjaxMappedToServer >= 1 && m1.ajaxServerRows.some(function (r) { return r.matched === "Y" && r.handler.indexOf("SampleController#list") >= 0; }), JSON.stringify(m1.ajaxServerRows)); const trimPlanFinding = m1.findings.find(function (f) { return f.rel === "js/util.js" && f.category === "trim-deprecated"; }); check("trim-deprecated deferred for 3.5.1 landing", !!trimPlanFinding && trimPlanFinding.priority === "StaticHtmlLow" && trimPlanFinding.action === "Ignored" && !m1.focus.some(function (f) { return f.category === "trim-deprecated"; }), trimPlanFinding ? JSON.stringify([trimPlanFinding.priority, trimPlanFinding.action]) : "finding not found"); check("effective include resolved (list.jsp sees layout core)", m1.pages.some(function (p) { return p.rel.indexOf("views/sample/list.jsp") >= 0 && p.oldCore && p.effectiveScripts >= 3; }), ""); check("function-bind not touched (onRow.bind)", !m1.findings.some(function (f) { return f.rel === "js/util.js" && f.category === "bind-to-on" && f.action === "Changed" && f.line >= 16; }), ""); ["summary.csv", "apiFindings.csv", "findingCategorySummary.csv", "directoryRiskSummary.csv", "focusQueue.csv", "critical.csv", "xssHigh.csv", "assistant_packet.txt", "voyager_packet.txt", "ai_verdict_packet.txt", "verdict_evidence.json", "verdict_evidence.html", "chat_summary.txt", "index.html", "jquery35_report.xls", "jspPages.csv", "pageScriptEffective.csv", "ajaxEndpoints.csv", "serverEndpoints.csv", "ajaxToServerMap.csv", "uiElementInventory.csv", "selectorElementMap.csv", "runtimeScenarios.csv", "runtime_scenarios.json", "runtime_scenarios.html", "runtimeParity.csv", "ieModeRisk.csv", "runtime_parity.html", "runtime_lab_guide.md", "hermes_server_evidence.json", "airgap_manifest.json", "airgap_manifest.txt", "mock_routes.json"].forEach(function (f) { check("report file " + f, exists(path.join(r1, f)), ""); }); const voyagerPacket = readUtf8(path.join(r1, "voyager_packet.txt")); check("voyager packet is copy-paste compact and includes runtime scenarios", voyagerPacket.indexOf("JQ35_VOYAGER_PACKET") >= 0 && voyagerPacket.indexOf("SUMMARY|") >= 0 && voyagerPacket.indexOf("RT|") >= 0 && voyagerPacket.indexOf("SEL|") >= 0 && voyagerPacket.indexOf("PARITY|") >= 0, voyagerPacket.slice(0, 500)); const aiVerdictPacket = readUtf8(path.join(r1, "ai_verdict_packet.txt")); const verdictEvidence = JSON.parse(readUtf8(path.join(r1, "verdict_evidence.json"))); check("ai verdict packet is <=1000 chars and has fixed answer schema", aiVerdictPacket.length <= 1000 && aiVerdictPacket.indexOf("JQ35_AI_VERDICT") >= 0 && aiVerdictPacket.indexOf("VAL|sourceOnly=Y|chromeSmoke=not_done|ieFinal=not_done") >= 0 && aiVerdictPacket.indexOf("VERDICT|one=HUMAN_REVIEW_OK|NEED_MORE_RUNTIME|NEED_IEDRIVER|BLOCKED") >= 0 && verdictEvidence.suggestedRiskHint && verdictEvidence.runtimePlan.scenariosPlanned === c1.RuntimeScenarios && verdictEvidence.runtimePlan.chromeSmokeResult === "not-ingested" && verdictEvidence.runtimePlan.ieFinalSampleResult === "not-ingested", "len=" + aiVerdictPacket.length + " evidence=" + JSON.stringify(verdictEvidence.runtimePlan)); const parityCsv = readUtf8(path.join(r1, "runtimeParity.csv")); check("runtime parity classifies code-only vs Chrome smoke vs IE final sample", c1.RuntimeParityRows > 0 && c1.RuntimeChromeSmokeRequired > 0 && c1.RuntimeIeFinalSampleRequired > 0 && parityCsv.indexOf("ValidationLane") >= 0 && parityCsv.indexOf("CHROME_SMOKE_REQUIRED") >= 0 && parityCsv.indexOf("IE_FINAL_SAMPLE_REQUIRED") >= 0, parityCsv.slice(0, 600)); check("ui element inventory captures buttons/inputs", m1.uiElementRows.some(function (e) { return e.id === "lineDel" && e.role === "button" && e.text.indexOf("Delete") >= 0; }) && m1.uiElementRows.some(function (e) { return e.id === "chk" && e.role === "choice"; }), JSON.stringify(m1.uiElementRows.slice(0, 8))); check("selector map links #lineDel to JSP element", m1.selectorElementRows.some(function (r) { return r.selector === "#lineDel" && r.element.indexOf("button#lineDel") >= 0 && r.page.indexOf("views/sample/list.jsp") >= 0; }), JSON.stringify(m1.selectorElementRows.slice(0, 12).map(function (r) { return [r.selector, r.page, r.element, r.confidence]; }))); check("runtime scenarios generated from selector/page evidence", m1.runtimeScenarios.length > 0 && m1.runtimeScenarios.some(function (s) { return s.uiTarget.indexOf("lineDel") >= 0 && s.action.indexOf("활성/비활성") >= 0; }), JSON.stringify(m1.runtimeScenarios.slice(0, 8).map(function (s) { return [s.id, s.page, s.uiTarget, s.action]; }))); const mockRoutes = JSON.parse(readUtf8(path.join(r1, "mock_routes.json"))); check("mock route carries server handler evidence", mockRoutes.routes.some(function (r) { return r.serverMatched === true && r.handler.indexOf("SampleController#list") >= 0; }), JSON.stringify(mockRoutes)); const indexHtml = readUtf8(path.join(r1, "index.html")); check("dashboard focus split-view modal present", indexHtml.indexOf("focusDetailModal") >= 0 && indexHtml.indexOf("__JQ35_FOCUS_DETAILS__") >= 0 && indexHtml.indexOf("openFocusDetail(") >= 0, ""); check("dashboard staged action queue present", indexHtml.indexOf("단계별 조치 큐") >= 0 && indexHtml.indexOf("조치 범위 로드맵") < 0 && indexHtml.indexOf("단계별 FocusQueue") < 0 && indexHtml.indexOf("1차 최소") >= 0 && indexHtml.indexOf("2차 안정화") >= 0 && indexHtml.indexOf("3차 최대/후속") >= 0, ""); check("dashboard stage counts split total auto queue", indexHtml.indexOf("전체") >= 0 && indexHtml.indexOf("자동") >= 0 && indexHtml.indexOf("") >= 0, ""); check("dashboard category and directory summaries present", indexHtml.indexOf("유형/경로 분포") >= 0 && indexHtml.indexOf("event-shortcut-load") >= 0 && indexHtml.indexOf("directoryRiskSummary.csv") >= 0, ""); check("dashboard links runtime scenarios", indexHtml.indexOf("Runtime 검증 시나리오 (Chrome smoke / IE final sample)") >= 0 && indexHtml.indexOf("runtime_scenarios.html") >= 0, ""); check("dashboard links runtime parity analyzer", indexHtml.indexOf("Runtime Parity 분석") >= 0 && indexHtml.indexOf("runtime_parity.html") >= 0 && indexHtml.indexOf("ieModeRisk.csv") >= 0, ""); log("self-test 2/8: autofix"); const m2 = buildModel(mk({ source: src, target: t1, report: r1 }), "autofix"); analyze(m2); writeTarget(m2, {}); writeAllReports(m2, {}); const utilTobe = readLatin1(path.join(t1, "WebContent", "js", "util.js")); check("AutoInferred sts === Y", utilTobe.indexOf('.prop("disabled", sts === "Y")') >= 0, trunc(utilTobe, 200)); check("AutoInferred flag boolean", utilTobe.indexOf('.prop("disabled", flag)') >= 0, ""); check("AutoInferred blean heuristic", utilTobe.indexOf('$("#lineDel").prop("disabled", blean)') >= 0, ""); check("delegate -> on", utilTobe.indexOf('.on("click", ".row", onRow)') >= 0, ""); check("unbind -> off", utilTobe.indexOf('$list.off("mouseover")') >= 0, ""); check("trailing-dot chain rewrite preserves newline", utilTobe.indexOf('removeClass("on").\r\noff("focus").\r\noff("blur")') >= 0 && utilTobe.indexOf('removeClass("on").off("focus")') < 0, utilTobe); check("Function.bind preserved", utilTobe.indexOf("onRow.bind(this)") >= 0, ""); const indexHtmlAuto = readUtf8(path.join(r1, "index.html")); const detailMatch = indexHtmlAuto.match(/window\.__JQ35_FOCUS_DETAILS__=([\s\S]*?);\n\(function/); let dashboardDetails = []; try { dashboardDetails = detailMatch ? JSON.parse(detailMatch[1]) : []; } catch (e) { dashboardDetails = []; } const unbindDetail = dashboardDetails.filter(function (d) { return d.sourceKind === "AutoFixed" && d.category === "unbind-to-off"; })[0]; const asIsHit = unbindDetail && unbindDetail.asIs && unbindDetail.asIs.rows.filter(function (r) { return r.hit; })[0]; const toBeHit = unbindDetail && unbindDetail.toBe && unbindDetail.toBe.rows.filter(function (r) { return r.hit; })[0]; check("dashboard TO-BE snippet follows shifted edit location", !!unbindDetail && !!asIsHit && !!toBeHit && asIsHit.text.indexOf('.unbind("mouseover")') >= 0 && toBeHit.text.indexOf('.off("mouseover")') >= 0 && toBeHit.text.indexOf("removeClass") < 0 && unbindDetail.asIs.line !== unbindDetail.toBe.line, JSON.stringify(unbindDetail)); const listTobe = readLatin1(path.join(t1, "WebContent", "WEB-INF", "views", "sample", "list.jsp")); check("window load -> on(load)", listTobe.indexOf('.on("load", function(){ initPage(); })') >= 0, ""); check("bind -> on in jsp", listTobe.indexOf('$("#btn1").on("click"') >= 0, ""); check("size -> length", listTobe.indexOf('$("#rows").length') >= 0, ""); check("attr checked true -> prop", listTobe.indexOf('.prop("checked", true)') >= 0, ""); check("removeAttr readonly -> prop false", listTobe.indexOf('.prop("readonly", false)') >= 0, ""); check("attr readonly true-string -> prop true", listTobe.indexOf('.prop("readonly", true)') >= 0, ""); check(".html(response) NOT auto-changed", listTobe.indexOf('$("#grid").html(response)') >= 0, ""); check("self-closed tag expanded (jQuery 3.5 htmlPrefilter)", listTobe.indexOf('append("
zz")') >= 0, trunc(listTobe, 300)); const layoutTobe = readLatin1(path.join(t1, "WebContent", "WEB-INF", "layouts", "common_script_lib.jsp")); check("old jquery NOT swapped in autofix", layoutTobe.indexOf("jquery-1.10.2.min.js") >= 0, ""); const vendorTobe = readLatin1(path.join(t1, "WebContent", "resources", "jqgrid", "js", "jquery.jqGrid.min.js")); check("vendor file untouched", vendorTobe === ST_JQGRID_JS, ""); log("self-test 3/8: probe (separate target)"); const t2 = path.join(base, "tobe_probe"); const m3 = buildModel(mk({ source: src, target: t2, report: r1 }), "probe"); analyze(m3); writeTarget(m3, { probe: true }); check("probe file created", exists(path.join(t2, "WebContent", "js", PROBE_FILE_NAME)), ""); const layoutProbe = readLatin1(path.join(t2, "WebContent", "WEB-INF", "layouts", "common_script_lib.jsp")); check("probe injected into layout", layoutProbe.indexOf(PROBE_FILE_NAME) >= 0, ""); log("self-test 4/8: verify-clean must FAIL on probe target"); const rv1 = path.join(base, "report_verify_fail"); const m4 = buildModel(mk({ source: t2, report: rv1 }), "verify-clean"); analyze(m4); const v1 = doVerifyClean(m4, mk({})); check("verify-clean FAIL (old jquery + probe)", v1.code === 2 && v1.failCount >= 2, "code=" + v1.code + " fail=" + v1.failCount); log("self-test 5/8: patch-jquery"); const t3 = path.join(base, "tobe_patch"); const m5 = buildModel(mk({ source: src, target: t3, report: r1, "migrate-trace": true }), "patch-jquery"); analyze(m5); writeTarget(m5, { patch: true }); writeAllReports(m5, {}); const layoutPatched = readLatin1(path.join(t3, "WebContent", "WEB-INF", "layouts", "common_script_lib.jsp")); check("bundled jQuery files copied to TO-BE", exists(path.join(t3, "WebContent", "js", "jquery-3.5.1.min.js")) && exists(path.join(t3, "WebContent", "js", "jquery-migrate-3.6.0.min.js")) && m5.patchResults.filter(function (r) { return r[2] === "BUNDLED"; }).length >= 2, JSON.stringify(m5.patchResults)); check("core swapped to 3.5.1", layoutPatched.indexOf("jquery-3.5.1.min.js") >= 0 && layoutPatched.indexOf("jquery-1.10.2.min.js") < 0, trunc(layoutPatched, 200)); check("migrate inserted after core", layoutPatched.indexOf("jquery-migrate-3.6.0.min.js") >= 0, ""); check("migrate after core order", layoutPatched.indexOf("jquery-3.5.1.min.js") < layoutPatched.indexOf("jquery-migrate-3.6.0.min.js"), ""); check("migrate tracing snippet after Migrate", layoutPatched.indexOf("jquery-migrate-3.6.0.min.js") < layoutPatched.indexOf("jQuery.migrateTrace = true") && layoutPatched.indexOf("jQuery.migrateMute = false") > layoutPatched.indexOf("jquery-migrate-3.6.0.min.js"), trunc(layoutPatched, 300)); const secondPatched = readLatin1(path.join(t3, "WebContent", "WEB-INF", "views", "common", "second_page.jsp")); check("second page swapped too", secondPatched.indexOf("jquery-3.5.1.min.js") >= 0, ""); check("second migrate inserted", secondPatched.indexOf("jquery-migrate-3.6.0.min.js") >= 0, ""); check("patch-jquery leaves non-script src text alone", secondPatched.indexOf('var auditCopy = "${pageContext.request.contextPath}/js/jquery-1.10.2.min.js"') >= 0 && secondPatched.indexOf('deployment note: ${pageContext.request.contextPath}/js/jquery-1.10.2.min.js') >= 0, trunc(secondPatched, 300)); check("patch-jquery removes replaced old core file from TO-BE", !exists(path.join(t3, "WebContent", "js", "jquery-1.10.2.min.js")) && m5.patchResults.some(function (r) { return r[2] === "REMOVED_OLD_FILE"; }), JSON.stringify(m5.patchResults)); log("self-test 6/8: verify-clean must PASS on patched target"); fs.rmSync(path.join(t3, "WebContent", "js", "jquery-1.10.2.min.js"), { force: true }); const rv2 = path.join(base, "report_verify_pass"); const m6 = buildModel(mk({ source: t3, report: rv2 }), "verify-clean"); analyze(m6); const v2 = doVerifyClean(m6, mk({})); check("verify-clean no FAIL on patched target", v2.failCount === 0, "fail=" + v2.failCount + " overall=" + v2.overall); log("self-test 7/8: wrapper learning round-trip (ajaxSuccessJson / domSinkArg / safeWrapper)"); const rBase = path.join(base, "report_wrapper_baseline"); const mBase = buildModel(mk({ source: src, report: rBase }), "plan"); analyze(mBase); const wrapBaseFindings = mBase.findings.filter(function (f) { return f.rel === "js/wrapper_demo.js"; }); check("baseline: .html(d) inside custom wrapper is Review (not yet tainted)", wrapBaseFindings.some(function (f) { return f.category === "dom-sink" && f.priority === "Review" && /unknown origin: d\b/.test(f.reason); }), JSON.stringify(wrapBaseFindings.map(function (f) { return [f.line, f.category, f.priority, f.reason]; }))); check("baseline: renderCell(resultdata) produces NO finding (unknown wrapper is invisible)", !wrapBaseFindings.some(function (f) { return f.category === "wrapper-dom-sink"; }), ""); check("baseline: esc(response) is XssHigh (identifier-name false positive)", wrapBaseFindings.some(function (f) { return f.category === "dom-sink" && f.priority === "XssHigh" && f.line === 14; }), JSON.stringify(wrapBaseFindings.map(function (f) { return [f.line, f.category, f.priority]; }))); const wrapperProfilePath = path.join(base, "learned-profile.json"); writeUtf8(wrapperProfilePath, JSON.stringify({ learnedWrappers: [ { name: "fnAjaxWrap", role: "ajaxSuccessJson", calleeParamIndex: 1, notes: "custom ajax json wrapper" }, { name: "renderCell", role: "domSinkArg", sinkParamIndex: 0, notes: "custom cell renderer" }, { name: "esc", role: "safeWrapper", notes: "html escape helper" } ] }, null, 2), false); const rLearn = path.join(base, "report_wrapper_learned"); const mLearn = buildModel(mk({ source: src, report: rLearn, profile: wrapperProfilePath }), "plan"); analyze(mLearn); const wrapLearnFindings = mLearn.findings.filter(function (f) { return f.rel === "js/wrapper_demo.js"; }); check("learned: .html(d) reclassified XssHigh via wrapper taint propagation", wrapLearnFindings.some(function (f) { return f.category === "dom-sink" && f.priority === "XssHigh" && /ajax callback parameter 'd'/.test(f.reason); }), JSON.stringify(wrapLearnFindings.map(function (f) { return [f.line, f.category, f.priority, f.reason]; }))); check("learned: renderCell(resultdata) now flagged XssHigh via domSinkArg role", wrapLearnFindings.some(function (f) { return f.category === "wrapper-dom-sink" && f.priority === "XssHigh" && f.reason.indexOf("renderCell") >= 0; }), JSON.stringify(wrapLearnFindings.map(function (f) { return [f.line, f.category, f.priority]; }))); check("learned: esc(response) downgraded to StaticHtmlLow via safeWrapper role", wrapLearnFindings.some(function (f) { return f.category === "dom-sink" && f.priority === "StaticHtmlLow" && f.line === 14; }), JSON.stringify(wrapLearnFindings.map(function (f) { return [f.line, f.category, f.priority]; }))); check("learned rules never set action=Changed (safety invariant)", !wrapLearnFindings.some(function (f) { return (f.category === "dom-sink" || f.category === "wrapper-dom-sink") && f.action === "Changed"; }), ""); log("self-test 8/8: review-pack round counter + learnedFindings override"); const rReview = path.join(base, "report_review_pack"); const mR1 = buildModel(mk({ source: src, report: rReview }), "review-pack"); analyze(mR1); writeAllReports(mR1, {}); writeReviewPack(mR1); check("ai_review_pack.txt created", exists(path.join(rReview, "ai_review_pack.txt")), ""); check("ai_review_pack.json created", exists(path.join(rReview, "ai_review_pack.json")), ""); check("hermes_test_plan.md created", exists(path.join(rReview, "hermes_test_plan.md")), ""); check("hermes_review_matrix.csv created", exists(path.join(rReview, "hermes_review_matrix.csv")), ""); check("hermes_testbench.html created", exists(path.join(rReview, "hermes_testbench.html")), ""); check("hermes_testbench_data.json created", exists(path.join(rReview, "hermes_testbench_data.json")), ""); check("hermes_profile_patch.sample.json created", exists(path.join(rReview, "hermes_profile_patch.sample.json")), ""); const packTxt = readUtf8(path.join(rReview, "ai_review_pack.txt")); check("review pack has marker and at least one CASE", packTxt.indexOf("JQUERY35_AI_REVIEW_PACK") >= 0 && packTxt.indexOf("---- CASE 1/") >= 0, ""); check("review pack excerpt redacts string literals", !/hi /.test(packTxt) && packTxt.indexOf("= 0, ""); const packJson = JSON.parse(readUtf8(path.join(rReview, "ai_review_pack.json"))); check("review pack json cases array non-empty", Array.isArray(packJson.cases) && packJson.cases.length > 0, ""); const hermesPlan = readUtf8(path.join(rReview, "hermes_test_plan.md")); check("hermes plan has local verification criteria", hermesPlan.indexOf("Hermes 로컬 검수팩") >= 0 && hermesPlan.indexOf("통과 기준") >= 0 && hermesPlan.indexOf("로컬 실행 테스트") >= 0, ""); const hermesMatrix = readUtf8(path.join(rReview, "hermes_review_matrix.csv")); check("hermes matrix has evidence/test columns", hermesMatrix.indexOf("StaticEvidence") >= 0 && hermesMatrix.indexOf("RuntimeTest") >= 0 && hermesMatrix.indexOf("AllowedDecisions") >= 0, ""); const hermesBench = readUtf8(path.join(rReview, "hermes_testbench.html")); check("hermes testbench has local mock arenas", hermesBench.indexOf("Hermes Local Testbench") >= 0 && hermesBench.indexOf("runDomBench") >= 0 && hermesBench.indexOf("runAjaxBench") >= 0, ""); const hermesBenchData = JSON.parse(readUtf8(path.join(rReview, "hermes_testbench_data.json"))); check("hermes testbench data mirrors cases", Array.isArray(hermesBenchData.cases) && hermesBenchData.cases.length === packJson.cases.length && hermesBenchData.files === undefined, ""); const hermesPatch = JSON.parse(readUtf8(path.join(rReview, "hermes_profile_patch.sample.json"))); check("hermes profile patch is template-only", hermesPatch.doNotMergeUnedited === true && Array.isArray(hermesPatch.templates) && hermesPatch.templates.length > 0 && hermesPatch.learnedWrappers.length === 0, ""); check("hermes wrapper template uses one concrete role", !hermesPatch.templates.some(function (t) { return t.learnedWrapperTemplate && /\|/.test(t.learnedWrapperTemplate.role); }), ""); check("round counter starts at 1", mR1.reviewRound === 1, "got " + mR1.reviewRound); const mR2 = buildModel(mk({ source: src, report: rReview }), "review-pack"); analyze(mR2); writeAllReports(mR2, {}); writeReviewPack(mR2); check("round counter increments on second run in same report dir", mR2.reviewRound === 2, "got " + mR2.reviewRound); const rHermes = path.join(base, "report_hermes_pack"); const mHermes = buildModel(mk({ source: src, report: rHermes }), "hermes-pack"); analyze(mHermes); writeAllReports(mHermes, {}); writeReviewPack(mHermes); check("hermes-pack alias writes local pack", exists(path.join(rHermes, "hermes_test_plan.md")) && exists(path.join(rHermes, "hermes_testbench.html")) && exists(path.join(rHermes, "ai_review_pack.txt")), ""); const learnedCaseId = caseIdOf("FN", "renderCell"); const overridePath = path.join(base, "learned-findings-profile.json"); writeUtf8(overridePath, JSON.stringify({ learnedFindings: [{ caseId: learnedCaseId, decision: "static-safe", notes: "renderCell escapes before this call in the real codebase" }] }, null, 2), false); const rOverride = path.join(base, "report_override"); const mOv = buildModel(mk({ source: src, report: rOverride, profile: overridePath }), "plan"); analyze(mOv); const learnedFinding = mOv.findings.find(function (f) { return f.rel === "js/wrapper_demo.js" && f.category === "dom-sink" && f._groupName === "renderCell"; }); check("learnedFindings override reclassifies renderCell dom-sink to StaticHtmlLow", !!learnedFinding && learnedFinding.priority === "StaticHtmlLow" && learnedFinding.action === "Ignored", learnedFinding ? JSON.stringify([learnedFinding.priority, learnedFinding.action, learnedFinding.reason]) : "finding not found"); check("caseIdOf produces distinct wide ids for adjacent numeric names (no truncation collision)", caseIdOf("FN", "btn0") !== caseIdOf("FN", "btn1") && caseIdOf("FN", "btn1") !== caseIdOf("FN", "btn2"), caseIdOf("FN", "btn0") + " / " + caseIdOf("FN", "btn1") + " / " + caseIdOf("FN", "btn2")); const overrideMismatchPath = path.join(base, "learned-findings-mismatch-profile.json"); writeUtf8(overrideMismatchPath, JSON.stringify({ learnedFindings: [{ caseId: learnedCaseId, name: "someUnrelatedFunctionName", decision: "static-safe", notes: "corroboration name deliberately wrong" }] }, null, 2), false); const rMismatch = path.join(base, "report_override_mismatch"); const mMismatch = buildModel(mk({ source: src, report: rMismatch, profile: overrideMismatchPath }), "plan"); analyze(mMismatch); const learnedFindingMismatch = mMismatch.findings.find(function (f) { return f.rel === "js/wrapper_demo.js" && f.category === "dom-sink" && f._groupName === "renderCell"; }); check("learnedFindings override skipped when name corroboration mismatches (safety net)", !!learnedFindingMismatch && learnedFindingMismatch.priority === "Review" && learnedFindingMismatch.action === "ReviewOnly", learnedFindingMismatch ? JSON.stringify([learnedFindingMismatch.priority, learnedFindingMismatch.action]) : "finding not found"); const relDir = path.join(base, "release"); const zipPath = writeReleaseZip(mk({ report: relDir })); const zipSig = fs.readFileSync(zipPath).slice(0, 4).toString("hex"); check("release-zip writes valid zip signature", exists(zipPath) && zipSig === "504b0304", zipSig); const relManifest = JSON.parse(readUtf8(path.join(relDir, "airgap_release_manifest.json"))); check("release manifest includes rulepack files", relManifest.files.some(function (f) { return f.path === "rules/public-defaults.json"; }), JSON.stringify(relManifest.files.map(function (f) { return f.path; }))); check("release manifest includes optional runtime-lab scaffold only", relManifest.files.some(function (f) { return f.path === "runtime-lab/docker-compose.tomcat7.yml"; }) && relManifest.files.some(function (f) { return f.path === "runtime-lab/inbox/README.txt"; }) && !relManifest.files.some(function (f) { return /app\.war$/i.test(f.path); }), JSON.stringify(relManifest.files.map(function (f) { return f.path; }))); check("release manifest includes bundled jQuery automation assets", relManifest.files.some(function (f) { return f.path === "assets/jquery/jquery-3.5.1.min.js"; }) && relManifest.files.some(function (f) { return f.path === "assets/jquery/jquery-migrate-3.6.0.js"; }) && relManifest.files.some(function (f) { return f.path === "assets/jquery/jquery-migrate-3.6.0.min.js"; }) && relManifest.files.some(function (f) { return f.path === "assets/jquery/README.txt"; }), JSON.stringify(relManifest.files.map(function (f) { return f.path; }))); const uiHtml = dashboardUiHtml(); check("ui dashboard contains run API and report link surface", uiHtml.indexOf("/api/run") >= 0 && uiHtml.indexOf("/api/state") >= 0 && uiHtml.indexOf("기존 산출물") >= 0 && uiHtml.indexOf("Lab Local Mock") >= 0, ""); const uiFiles = uiReportFileList({ report: r1 }); check("ui report API exposes voyager copy packet link", uiFiles.some(function (f) { return f.file === "voyager_packet.txt" && f.label === "Voyager copy packet"; }) && uiFiles.some(function (f) { return f.file === "ai_verdict_packet.txt" && f.label === "AI verdict packet"; }) && uiFiles.some(function (f) { return f.file === "verdict_evidence.html" && f.label === "AI verdict evidence"; }), JSON.stringify(uiFiles.map(function (f) { return [f.file, f.label]; }))); check("ui report API exposes runtime parity links", uiFiles.some(function (f) { return f.file === "runtime_parity.html"; }) && uiFiles.some(function (f) { return f.file === "runtime_lab_guide.md"; }), JSON.stringify(uiFiles.map(function (f) { return [f.file, f.label]; }))); check("ui dashboard contains browse, auto setup, and standardized pipeline controls", uiHtml.indexOf("/api/browse") >= 0 && uiHtml.indexOf("/api/run-sequence") >= 0 && uiHtml.indexOf("Browse") >= 0 && uiHtml.indexOf("Auto setup") >= 0 && uiHtml.indexOf("Pipeline: S1") >= 0 && uiHtml.indexOf("S1 분석") >= 0 && uiHtml.indexOf("S7 AI 판정팩") >= 0 && uiHtml.indexOf("S11 배포 ZIP") >= 0, ""); const uiArgs = uiBuildArgs("autofix", normalizedUiState({ source: src, target: t1, report: r1 }, defaultUiState(mk({}))), false); check("ui autofix command includes target/report/source", uiArgs.indexOf("--source") >= 0 && uiArgs.indexOf("--target") >= 0 && uiArgs.indexOf("--report") >= 0, uiArgs.join(" ")); const uiPipelineReportArgs = uiBuildArgs("pr-report", normalizedUiState({ source: src, target: t3, verifySource: t3, report: r1 }, defaultUiState(mk({}))), false, true); check("ui pipeline post-patch modes use verify source without excluding target", uiPipelineReportArgs[uiPipelineReportArgs.indexOf("--source") + 1] === t3 && uiPipelineReportArgs.indexOf("--target") < 0, uiPipelineReportArgs.join(" ")); const derived = uiDerivedPaths(src, true); check("ui derived paths create project sibling target/report/profile defaults", derived.target.indexOf(path.basename(src) + "_jquery35_tobe") >= 0 && derived.report.indexOf(path.basename(src) + "_jquery35_report_v5") >= 0 && derived.profile.indexOf("project-profile.generated.json") >= 0 && exists(derived.profile), JSON.stringify(derived)); const uiAutoState = normalizedUiState({ source: src }, defaultUiState(mk({}))); uiApplySourceDefaults(uiAutoState, {}, false); check("ui source-only config auto-fills executable paths", !!uiAutoState.target && !!uiAutoState.report && !!uiAutoState.profile && !!uiAutoState.serverSource && uiAutoState.verifySource === uiAutoState.target, JSON.stringify(uiAutoState)); check("ui child runner uses wrapper script", path.basename(uiRunnerScript()) === "run-jquery35-v5.js" && exists(uiRunnerScript()), uiRunnerScript()); } catch (e) { check("no unexpected exception", false, e.stack || e.message); } const passN = results.filter(function (r) { return r.ok; }).length; const failN = results.length - passN; log(""); log("SELF-TEST RESULT: " + (failN === 0 ? "PASS" : "FAIL") + " (" + passN + "/" + results.length + " checks passed)"); log("sandbox kept for inspection: " + base); return failN === 0 ? 0 : 1; } function run(argv) { const opts = parseArgs(argv); if (opts.help || argv.length === 0) { process.stdout.write(helpText()); return; } let mode = opts.mode || "plan"; if (opts["audit-only"]) mode = "plan"; if (opts["self-test"]) mode = "self-test"; if (MODES.indexOf(mode) < 0) { fail("unknown mode: " + mode); process.stdout.write(helpText()); process.exitCode = 1; return; } log(TOOL_NAME + " v" + TOOL_VERSION + " mode=" + mode); try { if (mode === "self-test") { process.exitCode = selfTest(opts); return; } if (mode === "ui") { startDashboardUi(opts); return; } if (mode === "release-zip") { writeReleaseZip(opts); return; } if (!opts.report) throw new Error("--report is required"); const model = buildModel(opts, mode); analyze(model); if (mode === "plan") { writeAllReports(model, {}); } else if (mode === "autofix" || mode === "patch-jquery" || mode === "probe") { writeTarget(model, { patch: mode === "patch-jquery" || opts["patch-jquery"] === true, probe: mode === "probe" || opts["inject-probe"] === true }); writeAllReports(model, {}); } else if (mode === "lab") { writeAllReports(model, {}); startLab(model, opts); return; } else if (mode === "verify-clean") { writeAllReports(model, {}); const v = doVerifyClean(model, opts); process.exitCode = v.code; return; } else if (mode === "pr-report") { writeAllReports(model, { pr: true }); } else if (mode === "packet") { ensureDir(model.reportRoot); writeCsv(path.join(model.reportRoot, "summary.csv"), ["Key", "Value"], Object.keys(model.counters).map(function (k) { return [k, model.counters[k]]; })); writePacket(model); writeAiVerdictPack(model); writeChatSummary(model); log("packet written: " + path.join(model.reportRoot, "assistant_packet.txt") + " / " + path.join(model.reportRoot, "voyager_packet.txt") + " / " + path.join(model.reportRoot, "ai_verdict_packet.txt")); } else if (mode === "ai-verdict-packet") { ensureDir(model.reportRoot); writeCsv(path.join(model.reportRoot, "summary.csv"), ["Key", "Value"], Object.keys(model.counters).map(function (k) { return [k, model.counters[k]]; })); writeAiVerdictPack(model); log("ai verdict packet written: " + path.join(model.reportRoot, "ai_verdict_packet.txt") + " (" + joinPacketChars(aiVerdictPacketLines(model)).length + " chars)"); } else if (mode === "review-pack" || mode === "hermes-pack") { writeAllReports(model, {}); writeReviewPack(model); } else if (mode === "airgap-manifest") { writeAirgapManifest(model); log("airgap manifest written: " + path.join(model.reportRoot, "airgap_manifest.json")); } const c = model.counters; log(""); log("SUMMARY: files=" + c.TotalFiles + " findings=" + c.ApiFindings + " critical=" + c.Critical + " autoFixed=" + (c.AutoFixed + c.AutoInferred) + " manual=" + c.Manual + " xssHigh=" + c.XssHigh + " focusQueue=" + c.FocusQueue); if (c.Critical > 0 && mode !== "patch-jquery") { warn("old jQuery core references remain (" + c.Critical + "); they are replaced only by patch-jquery mode"); } } catch (e) { fail(e.message); process.exitCode = 1; } } module.exports = { run: run, version: TOOL_VERSION, _internal: { maskJs: maskJs, receiverInfo: receiverInfo, classifyLib: classifyLib, buildModel: buildModel, analyze: analyze, writeTarget: writeTarget, doVerifyClean: doVerifyClean, selfTest: selfTest, genProbeJs: genProbeJs } };