From 6a3481f09a4e75047c87334ecf516acf827b4aa2 Mon Sep 17 00:00:00 2001 From: Jayden Yoon ZK <19250644+JaydenYoonZK@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:25:16 +0800 Subject: [PATCH 1/4] Recognise WordPress sites, report what a scan skipped, and stop flagging clean core A scan now finds the WordPress install its targets belong to, prints the version, and tags every flagged file that is the site's own code as config, core, theme, or plugin, with advice to replace it with a clean copy rather than only quarantine it. Scanning only wp-content still knows its site. Folders named cache are scanned. The report counts the folders skipped by name and lists them, and --include puts them back, with "all" for everything. A website address is refused with the two ways to scan that site, over SSH or on a downloaded copy, instead of "no such file or folder". Rules match what malware writes, not what it resembles: the WSO rule wants WSO's own function and constant names; the hex rule wants a dangerous function or request variable spelled in hex, or a hex literal called as a function; the hidden iframe rule wants a hidden frame with a real destination and skips the Tag Manager noscript snippet. The long-line, high-entropy, and base64-blob heuristics also require a decoder, an execution call, or request input. A cached page whose only PHP is the die() guard is left alone. Tag attribute scans are bounded so a page of unterminated tags cannot stall a scan. --- bin/jayshield.js | 40 ++++++++- src/heuristics.js | 80 ++++++++++++++---- src/index.js | 8 ++ src/report.js | 39 ++++++++- src/rules.js | 54 ++++++++++-- src/scanner.js | 38 +++++++-- src/site.js | 172 +++++++++++++++++++++++++++++++++++++++ src/walk.js | 25 ++++-- test/cli.test.mjs | 62 ++++++++++++++ test/heuristics.test.mjs | 51 +++++++++++- test/rules.test.mjs | 37 +++++++++ test/scanner.test.mjs | 102 +++++++++++++++++++++++ test/site.test.mjs | 106 ++++++++++++++++++++++++ 13 files changed, 770 insertions(+), 44 deletions(-) create mode 100644 src/site.js create mode 100644 test/site.test.mjs diff --git a/bin/jayshield.js b/bin/jayshield.js index 08aedf7..dc05cbe 100755 --- a/bin/jayshield.js +++ b/bin/jayshield.js @@ -11,6 +11,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { scan } from "../src/scanner.js"; +import { DEFAULT_SKIP_DIRS } from "../src/walk.js"; import { parseHashList } from "../src/hashes.js"; import { quarantineFiles, @@ -50,11 +51,17 @@ const BOOLEAN_FLAGS = new Set([ "json", "follow-symlinks", "no-color", "verbose", "dry-run", "yes", "help", "version", "banner", "no-banner" ]); -const VALUE_FLAGS = new Set(["min-severity", "ignore-rule", "hashes", "max-size", "vault"]); +const VALUE_FLAGS = new Set(["min-severity", "ignore-rule", "hashes", "max-size", "vault", "include"]); +const LIST_FLAGS = new Set(["ignore-rule", "include"]); const ALIASES = { h: "help", V: "version", v: "verbose", q: "quarantine", j: "json" }; +// A website address is not something JayShield can open. It reads files. +const LOOKS_LIKE_URL = /^[a-z][a-z0-9+.-]*:\/\//i; +const LOOKS_LIKE_HOST = /^(?:www\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:\/.*)?$/i; +const GUIDE_URL = "https://github.com/JayHackPro/JayShield#scan-a-wordpress-site"; + function parseArgs(argv) { - const flags = { "ignore-rule": [] }; + const flags = { "ignore-rule": [], include: [] }; const positionals = []; let bad = null; @@ -75,7 +82,7 @@ function parseArgs(argv) { if (VALUE_FLAGS.has(name)) { if (value === null) value = argv[++i]; if (value === undefined) { bad = `--${name} needs a value`; break; } - if (name === "ignore-rule") flags["ignore-rule"].push(...value.split(",").map((s) => s.trim()).filter(Boolean)); + if (LIST_FLAGS.has(name)) flags[name].push(...value.split(",").map((s) => s.trim()).filter(Boolean)); else flags[name] = value; } else if (BOOLEAN_FLAGS.has(name)) { flags[name] = true; @@ -107,9 +114,17 @@ function helpText(v) { jayshield file.php app/ scan several targets jayshield . scan the current folder jayshield . --min-severity high show only high and critical + jayshield . --include vendor also scan a folder skipped by name jayshield . --json > report.json machine-readable output jayshield . --verbose include rule ids and references + ${color.bold("Websites")} + JayShield reads files on disk. It does not connect to a URL. + To check a live site, run it on the server over SSH, or on a copy of + the site downloaded with SFTP or a backup. WordPress files that are + flagged are marked, with advice to replace rather than only remove. + ${color.dim(GUIDE_URL)} + ${color.bold("Remove")} ${color.dim("(safe: files are moved, never deleted)")} jayshield . --quarantine move every threat into a local vault jayshield . --quarantine --dry-run preview what would move @@ -124,6 +139,8 @@ function helpText(v) { ${color.bold("Options")} --min-severity critical | high | medium | low --ignore-rule silence one or more rules + --include scan folders skipped by name, or "all" + (skipped: ${[...DEFAULT_SKIP_DIRS].join(" ")}) --hashes add known-bad sha256 hashes (one per line) --max-size skip files larger than this (default 5) --vault quarantine folder (default ${QUARANTINE_DIR}) @@ -150,6 +167,20 @@ function fail(message) { process.exitCode = 2; } +/** Someone passed a website address. Say plainly what JayShield can do instead. */ +function failUrl(target) { + const host = target.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/\/.*$/, "") || "example.com"; + const lines = [ + `JayShield scans files on disk. It cannot connect to ${target}.`, + " To check that site, run JayShield where its files are:", + color.brand(` on the server, over SSH jayshield /var/www/${host}/public_html`), + color.brand(` or on a downloaded copy jayshield ~/Downloads/${host}`), + " Get the files with SFTP, your host's file manager, or a full site backup.", + color.dim(` Guide: ${GUIDE_URL}`) + ]; + fail(lines.join("\n")); +} + async function main() { const { flags, positionals, bad } = parseArgs(process.argv.slice(2)); if (flags["no-color"] || flags.json) setColor(false); @@ -191,15 +222,18 @@ async function main() { const targets = positionals.length ? positionals : ["."]; for (const t of targets) { + if (LOOKS_LIKE_URL.test(t)) return failUrl(t); try { await fs.access(t); } catch { + if (LOOKS_LIKE_HOST.test(t)) return failUrl(t); return fail(`no such file or folder: ${t}`); } } const result = await scan(targets, { ignoreRules: new Set(flags["ignore-rule"]), + includeDirs: flags.include, extraHashes, maxBytes, minSeverity: flags["min-severity"], diff --git a/src/heuristics.js b/src/heuristics.js index c10fc62..1f1892b 100644 --- a/src/heuristics.js +++ b/src/heuristics.js @@ -57,6 +57,39 @@ const UPLOAD_PATH = /(?:wp-content\/(?:uploads|cache)|\/uploads?\/|\/media\/|\/a const PHP_TAG = Buffer.from("` guard and hold only HTML after it. The guard +// runs nothing, so a file whose only PHP is that guard is not an executable +// planted in the cache. Anything else in the file is still scanned as usual. +const PHP_GUARD = /<\?php\s+(?:die|exit)\s*(?:\(\s*(?:['"][^'"]*['"])?\s*\))?\s*;?\s*\?>/g; +const CODE_MARKER = /<\?php\b|<\?=|<%|#!/; + +/** True when the text carries a die/exit guard and no other code marker. */ +export function onlyGuardPhp(text) { + const stripped = text.replace(PHP_GUARD, ""); + return stripped.length !== text.length && !CODE_MARKER.test(stripped); +} + +// Shape alone is not evidence. A packed payload has to unpack and run itself, +// so the shape heuristics below also require one of these: a decoder, an +// execution primitive, or request input. An SVG icon on one long line, a +// lookup table full of escapes, or an embedded WebAssembly module has none. +const SERVER_PRIMITIVE = /\b(?:eval|assert|base64_decode|gzinflate|gzuncompress|gzdecode|str_rot13|create_function|preg_replace|system|passthru|shell_exec|exec|popen|proc_open)\s*\(|\$_(?:GET|POST|REQUEST|COOKIE)\b|\$\w+\s*\(\s*\$/i; +const BROWSER_SINK = /\beval\s*\(|\bFunction\s*\(|document\.write\s*\(|\.innerHTML\s*=|createElement\s*\(\s*['"]script|location(?:\.href|\.replace)?\s*[=(]|\.src\s*=/; + +/** Every line at or over the length cap, so a check can look inside them. */ +function longLines(text, min) { + const out = []; + let start = 0; + for (let i = 0; i <= text.length; i++) { + if (i === text.length || text.charCodeAt(i) === 10) { + if (i - start >= min) out.push(text.slice(start, i)); + start = i + 1; + } + } + return out; +} + /** Byte offset of the first PHP open tag anywhere in a buffer, or -1. */ function phpTagOffset(buffer) { const a = buffer.indexOf(PHP_TAG); @@ -126,8 +159,9 @@ export function runHeuristics(file) { const findings = []; const posix = file.path.toLowerCase().replace(/\\/g, "/"); - // An executable script sitting in an uploads or media folder. - if (EXECUTABLE_KINDS.has(file.kind) && UPLOAD_PATH.test(posix)) { + // An executable script sitting in an uploads or media folder. A cache page + // whose only PHP is the die() guard is left alone, see onlyGuardPhp. + if (EXECUTABLE_KINDS.has(file.kind) && UPLOAD_PATH.test(posix) && !onlyGuardPhp(file.text)) { findings.push(mk( "heuristic.exec_in_uploads", "Executable script in an uploads folder", @@ -140,10 +174,11 @@ export function runHeuristics(file) { } // Very high entropy in a text-based source file means packed or encrypted - // content, which legitimate source rarely is. + // content. Legitimate source rarely has it, but data tables do, so the file + // must also hold something that could unpack or run the payload. if (file.kind === "php" || file.kind === "asp" || file.kind === "perl") { const entropy = shannonEntropy(file.buffer); - if (entropy >= 5.6 && file.buffer.length >= 512) { + if (entropy >= 5.6 && file.buffer.length >= 512 && SERVER_PRIMITIVE.test(file.text)) { findings.push(mk( "heuristic.high_entropy", "Packed or encrypted server script", @@ -156,24 +191,33 @@ export function runHeuristics(file) { } } - // A single enormous line in a server script is almost always a minified - // one-line payload rather than hand-written code. + // A single enormous line in a server script that also decodes or runs + // something is the shape of a one-line payload. A long line of SVG markup + // or a big array literal is just a long line. if ((file.kind === "php" || file.kind === "asp") && longestLine(file.text) >= 2000) { - findings.push(mk( - "heuristic.long_line", - "Very long single line in a server script", - "medium", - "obfuscation", - file.text, - /.{2000,}/, - "One line thousands of characters long is the shape of a compressed, hidden payload." - )); + const payloadLine = longLines(file.text, 2000).find((line) => SERVER_PRIMITIVE.test(line)); + if (payloadLine) { + findings.push(mk( + "heuristic.long_line", + "Very long single line that decodes or runs code", + "medium", + "obfuscation", + file.text, + /.{2000,}/, + "One line thousands of characters long, with a decoder or an execution call on it, is the shape of a compressed, hidden payload." + )); + } } - // A large base64 blob assigned to a variable, then handed to a decoder, is - // the loader half of most packed shells. + // A large base64 blob paired with a decoder is the loader half of most + // packed shells. In the browser the decoded text must also reach something + // that runs it; an embedded WebAssembly module or font decoded with atob + // does not. const blob = /['"][A-Za-z0-9+/]{200,}={0,2}['"]/; - if ((file.kind === "php" || file.kind === "js") && blob.test(file.text) && /(?:base64_decode|atob|gzinflate|gzuncompress)/.test(file.text)) { + const decoderNearby = file.kind === "js" + ? /\batob\s*\(/.test(file.text) && BROWSER_SINK.test(file.text) + : /(?:base64_decode|gzinflate|gzuncompress|gzdecode)\s*\(/.test(file.text); + if ((file.kind === "php" || file.kind === "js") && blob.test(file.text) && decoderNearby) { findings.push(mk( "heuristic.base64_blob", "Large encoded blob with a decoder nearby", diff --git a/src/index.js b/src/index.js index 4e62f58..9469fc3 100644 --- a/src/index.js +++ b/src/index.js @@ -15,6 +15,14 @@ */ export { scan, scanBuffer, worstSeverity, SEVERITY_RANK } from "./scanner.js"; +export { walk, DEFAULT_SKIP_DIRS, skipDirsWithout } from "./walk.js"; +export { + findWordPressRoots, + classifySiteFile, + readWordPressVersion, + countSiteRoles, + SITE_ROLES +} from "./site.js"; export { RULES, rulesForKind, kindForPath } from "./rules.js"; export { runHeuristics, shannonEntropy } from "./heuristics.js"; export { KNOWN_BAD, sha256, matchHash, parseHashList } from "./hashes.js"; diff --git a/src/report.js b/src/report.js index 2fc99b0..cd7a5f3 100644 --- a/src/report.js +++ b/src/report.js @@ -8,6 +8,7 @@ import { color, severityColor } from "./colors.js"; import { worstSeverity } from "./scanner.js"; +import { countSiteRoles, SITE_ROLES } from "./site.js"; const GLYPH = { critical: "✕", // x @@ -69,11 +70,21 @@ export function formatHuman(result, opts = {}) { if (result.stats.unreadable) notes.push(`${result.stats.unreadable} unreadable`); out.push(" " + color.dim(notes.join(", "))); } + for (const site of result.sites || []) { + const v = site.version ? `WordPress ${site.version}` : "WordPress (version not read)"; + out.push(" " + color.dim(`${v} at ${site.root}`)); + } + if (result.stats.skippedDirs) { + const names = (result.skippedDirNames || []).join(", "); + const plural = result.stats.skippedDirs === 1 ? "folder" : "folders"; + const include = (result.skippedDirNames || []).join(",") || "all"; + out.push(" " + color.dim(`Skipped ${result.stats.skippedDirs} ${plural} by name (${names}). Scan them too with --include ${include}`)); + } out.push(""); if (!result.infected.length) { out.push(" " + color.green(color.bold("✔ No malware found."))); - out.push(" " + color.dim("Every file scanned came back clean.")); + out.push(" " + color.dim("Nothing in the files scanned matched a known technique, heuristic, or bad hash.")); out.push(""); return out.join("\n"); } @@ -82,7 +93,8 @@ export function formatHuman(result, opts = {}) { const worst = record.findings[0].severity; const paint = severityColor(worst); const verb = opts.quarantined ? color.dim(" (quarantined)") : ""; - out.push(" " + paint(color.bold(worst.toUpperCase().padEnd(9))) + color.bold(record.path) + verb); + const site = record.site ? color.dim(` [wordpress ${record.site.role}]`) : ""; + out.push(" " + paint(color.bold(worst.toUpperCase().padEnd(9))) + color.bold(record.path) + site + verb); for (const f of record.findings) { const g = severityColor(f.severity)(GLYPH[f.severity] || "•"); @@ -117,14 +129,33 @@ export function formatHuman(result, opts = {}) { ); out.push(""); + const roles = countSiteRoles(result.infected); + const siteFiles = Object.values(roles).reduce((a, b) => a + b, 0); + if (siteFiles) { + const breakdown = SITE_ROLES.filter((r) => roles[r]).map((r) => `${roles[r]} ${r}`).join(", "); + out.push(" " + color.yellow(color.bold(`${siteFiles} flagged file${siteFiles === 1 ? " is" : "s are"} part of WordPress itself`)) + color.dim(` (${breakdown})`)); + out.push(" " + color.dim("Injected code inside a real file means the whole file is untrusted. Replace these with")); + out.push(" " + color.dim("clean copies of the same version rather than only removing them:")); + for (const role of SITE_ROLES) { + if (!roles[role]) continue; + const advice = result.infected.find((r) => r.site && r.site.role === role).site.advice; + out.push(" " + color.dim(` ${role}: `) + color.gray(advice)); + } + out.push(""); + } + if (!opts.quarantined) { out.push(" " + color.dim("Review the findings, then remove them safely with:")); out.push(" " + color.brand(` jayshield ${quoteTargets(result.targets)} --quarantine`)); out.push(" " + color.dim("Quarantine moves files into a local vault. Put them back any time with --restore.")); + if (siteFiles) out.push(" " + color.dim("Quarantining the WordPress files above takes the site offline until you replace them.")); out.push(""); } else { out.push(" " + color.dim("Files above were moved into the quarantine vault. Restore any of them with:")); out.push(" " + color.brand(" jayshield --restore")); + if (siteFiles) { + out.push(" " + color.yellow("WordPress files were moved too. If the site is offline now, restore them, then replace them with clean copies.")); + } out.push(""); } @@ -147,12 +178,15 @@ export function toJson(result, extra = {}) { startedAt: result.startedAt, durationMs: result.durationMs, targets: result.targets, + sites: result.sites || [], summary: { scanned: result.stats.scanned, clean: result.stats.clean, infected: result.infected.length, skippedLarge: result.stats.skippedLarge, unreadable: result.stats.unreadable, + skippedDirs: result.stats.skippedDirs || 0, + skippedDirNames: result.skippedDirNames || [], worstSeverity: worstSeverity(result), bySeverity: result.countsBySeverity, byCategory: result.countsByCategory @@ -160,6 +194,7 @@ export function toJson(result, extra = {}) { infected: result.infected.map((r) => ({ path: r.path, size: r.size, + site: r.site ? { type: r.site.type, role: r.site.role, root: r.site.root } : null, findings: r.findings.map((f) => ({ rule: f.id, name: f.name, diff --git a/src/rules.js b/src/rules.js index 744872d..c7178c1 100644 --- a/src/rules.js +++ b/src/rules.js @@ -72,6 +72,36 @@ export function kindForPath(filePath) { } } +/** + * The hex-escaped spelling of a word, either letter case per character, so + * "\x65\x76\x61\x6c" and "\x45\x56\x41\x4C" both read as eval. Legitimate code + * writes binary constants as hex escapes all the time; nobody spells a + * function name that way unless they want it unseen. + */ +function hexWord(word) { + return word + .split("") + .map((ch) => { + const lower = ch.toLowerCase().charCodeAt(0).toString(16).padStart(2, "0"); + const upper = ch.toUpperCase().charCodeAt(0).toString(16).padStart(2, "0"); + return lower === upper ? `\\\\x${lower}` : `\\\\x(?:${lower}|${upper})`; + }) + .join(""); +} + +const HEX_HIDDEN_WORDS = [ + "eval", "assert", "system", "passthru", "shell_exec", "exec", "popen", "proc_open", + "base64_decode", "gzinflate", "gzuncompress", "gzdecode", "str_rot13", "create_function", + "preg_replace", "file_put_contents", "fwrite", "move_uploaded_file", + "_GET", "_POST", "_REQUEST", "_COOKIE" +]; + +// A dangerous name spelled in hex, or a hex string literal called as a function. +const HEX_HIDDEN = new RegExp( + HEX_HIDDEN_WORDS.map(hexWord).join("|") + `|["'](?:\\\\x[0-9a-f]{2}){3,}["']\\s*\\(`, + "i" +); + export const RULES = [ // ----- PHP obfuscation: decode-then-run, the signature of almost every shell { @@ -176,12 +206,12 @@ export const RULES = [ }, { id: "php.hex_obfuscation", - name: "Long hex-escaped string", - severity: "medium", + name: "Function name hidden in hex escapes", + severity: "high", category: "obfuscation", kinds: ["php", "js"], - pattern: /(?:\\x[0-9a-f]{2}){12,}/i, - description: "A long run of hex escapes is a common way to hide function names and payloads.", + pattern: HEX_HIDDEN, + description: "A dangerous function or request variable is spelled out as hex escapes so a reader does not see it. Binary constants written in hex are normal and are not flagged.", references: ["https://owasp.org/www-community/attacks/Web_Shell"] }, { @@ -242,8 +272,8 @@ export const RULES = [ severity: "critical", category: "webshell", kinds: ["php", "other"], - pattern: /WSO(?:hex)?|wso_ex|\$default_charset\s*=.*['"]FilesMan['"]/i, - description: "Fingerprint of the WSO webshell, one of the most widely reused PHP shells.", + pattern: /\bWSO_VERSION\b|\bwso(?:Login|Logout|Header|Footer|SecParam|Ex|PrintTree|Action|Ajax)\s*\(|\$wso_ex\b|\bWSO\s+(?:shell|\d+\.\d+)|\$default_charset\s*=.*['"]FilesMan['"]/i, + description: "Fingerprint of the WSO webshell, one of the most widely reused PHP shells. Matches its own function and constant names, not the letters w-s-o inside another word.", references: ["https://owasp.org/www-community/attacks/Web_Shell"] }, { @@ -324,8 +354,14 @@ export const RULES = [ severity: "high", category: "injection", kinds: ["html", "php", "js"], - pattern: /]*(?:(?:width|height)\s*=\s*["']?\s*[01]\b|style\s*=\s*["'][^"']*(?:display\s*:\s*none|visibility\s*:\s*hidden|position\s*:\s*absolute[^"']*(?:top|left)\s*:\s*-?\d))/i, - description: "An invisible iframe usually delivers malware or ad fraud to visitors without a trace on the page.", + // A hidden iframe that points somewhere, so it must carry a src. Skipped: + // a tag with no src, an empty or javascript: frame (upload libraries use + // those as a scratch target), and the Google Tag Manager noscript snippet + // that sits in most theme headers. + // Attribute scans stop at the next tag and are bounded, so a page full of + // unterminated tags costs linear time rather than stalling the scan. + pattern: /]{0,3000}\bsrc\s*=)(?![^<>]{0,3000}\bsrc\s*=\s*(?:["']?\s*(?:javascript:|about:blank)|["']\s*["']|["']?[^"'>\s]{0,300}googletagmanager\.com\/ns\.html))[^<>]{0,3000}(?:\b(?:width|height)\s*=\s*["']?\s*[01]\b|style\s*=\s*["'][^"'<>]{0,3000}(?:display\s*:\s*none|visibility\s*:\s*hidden|position\s*:\s*absolute[^"'<>]{0,3000}(?:top|left)\s*:\s*-?\d))/i, + description: "An invisible iframe that loads another address usually delivers malware or ad fraud to visitors without a trace on the page.", references: ["https://owasp.org/www-community/attacks/Content_Spoofing"] }, { @@ -378,7 +414,7 @@ export const RULES = [ severity: "medium", category: "spam", kinds: ["html", "php"], - pattern: /<(?:div|span)\b[^>]*style\s*=\s*["'][^"']*(?:display\s*:\s*none|position\s*:\s*absolute[^"']*left\s*:\s*-\d{3,})[^"']*["'][^>]*>\s*(?:]*>[^<]*<\/a>\s*){2,}/i, + pattern: /<(?:div|span)\b[^<>]{0,3000}style\s*=\s*["'][^"'<>]{0,3000}(?:display\s*:\s*none|position\s*:\s*absolute[^"'<>]{0,3000}left\s*:\s*-\d{3,})[^"'<>]{0,3000}["'][^<>]{0,3000}>\s*(?:]{0,3000}>[^<]{0,3000}<\/a>\s*){2,}/i, description: "A block of links hidden from visitors but read by search engines, the shape of injected spam.", references: ["https://developers.google.com/search/docs/essentials/spam-policies"] }, diff --git a/src/scanner.js b/src/scanner.js index 11ad9f0..0a9bd02 100644 --- a/src/scanner.js +++ b/src/scanner.js @@ -7,10 +7,11 @@ */ import { promises as fs } from "node:fs"; -import { walk, DEFAULT_SKIP_DIRS } from "./walk.js"; +import { walk, DEFAULT_SKIP_DIRS, skipDirsWithout } from "./walk.js"; import { rulesForKind, globalize, kindForPath } from "./rules.js"; import { runHeuristics, runByteHeuristics, evidenceAt } from "./heuristics.js"; import { matchHash } from "./hashes.js"; +import { findWordPressRoots, noteWordPressRoot, classifySiteFile, readWordPressVersion } from "./site.js"; export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 }; const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MB @@ -106,7 +107,8 @@ export function scanBuffer(filePath, buffer, options = {}) { * @param {number} [options.maxBytes] skip files larger than this * @param {Set} [options.ignoreRules] * @param {Map} [options.extraHashes] - * @param {Set} [options.skipDirs] + * @param {Set} [options.skipDirs] directory names to skip (default DEFAULT_SKIP_DIRS) + * @param {Iterable} [options.includeDirs] default-skipped names to scan anyway, or "all" * @param {boolean} [options.followSymlinks] * @param {string} [options.minSeverity] drop findings below this level * @param {(info:{path:string,findings:Array}) => void} [options.onFile] @@ -115,21 +117,35 @@ export function scanBuffer(filePath, buffer, options = {}) { export async function scan(targets, options = {}) { const maxBytes = options.maxBytes || DEFAULT_MAX_BYTES; const minRank = SEVERITY_RANK[options.minSeverity] || 0; - const skipDirs = options.skipDirs || DEFAULT_SKIP_DIRS; + const skipDirs = options.skipDirs || (options.includeDirs ? skipDirsWithout(options.includeDirs) : DEFAULT_SKIP_DIRS); const started = Date.now(); const result = { targets, infected: [], - stats: { scanned: 0, skippedLarge: 0, unreadable: 0, bytes: 0, clean: 0 }, + stats: { scanned: 0, skippedLarge: 0, unreadable: 0, bytes: 0, clean: 0, skippedDirs: 0 }, + skippedDirNames: [], + sites: [], countsBySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, countsByCategory: {}, startedAt: new Date(started).toISOString(), durationMs: 0 }; + const skippedNames = new Set(); + const onSkip = (name) => { + result.stats.skippedDirs++; + skippedNames.add(name); + }; + + // Which WordPress installs do these targets belong to? Checked up front so + // a scan of only wp-content still knows its site, and extended during the + // walk when a wp-settings.php turns up deeper in the tree. + const wpRoots = new Set(await findWordPressRoots(targets)); + for (const target of targets) { - for await (const entry of walk(target, { skipDirs, followSymlinks: options.followSymlinks })) { + for await (const entry of walk(target, { skipDirs, followSymlinks: options.followSymlinks, onSkip })) { + noteWordPressRoot(entry.path, wpRoots); if (entry.size > maxBytes) { result.stats.skippedLarge++; continue; @@ -164,6 +180,18 @@ export async function scan(targets, options = {}) { result.infected.sort( (a, b) => topRank(b.findings) - topRank(a.findings) || a.path.localeCompare(b.path) ); + + // Which flagged files are the site's own code. The report gives these + // different advice: replace with a clean copy rather than only remove. + for (const record of result.infected) { + const site = classifySiteFile(record.path, wpRoots); + if (site) record.site = site; + } + for (const root of [...wpRoots].sort()) { + result.sites.push({ type: "wordpress", root, version: await readWordPressVersion(root) }); + } + + result.skippedDirNames = [...skippedNames].sort(); result.durationMs = Date.now() - started; return result; } diff --git a/src/site.js b/src/site.js new file mode 100644 index 0000000..4e28611 --- /dev/null +++ b/src/site.js @@ -0,0 +1,172 @@ +/*! + * JayShield by JayHackPro + * Find and remove web malware, webshells, and backdoors. + * Released under JayHackPro® Inc. Designed by Jayden Yoon ZK. + * MIT License: use it freely, and keep this notice. The brand stays behind the code. + * https://github.com/JayHackPro/JayShield + */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; + +/** + * Site awareness. + * + * JayShield scans files on disk. On a WordPress site some of those files are + * the site's own code, and the site cannot run without them. When one of them + * is flagged, the honest advice is different: quarantining wp-config.php or a + * theme's functions.php takes the site offline, and injected code inside a + * real file means the whole file is untrusted and should be replaced with a + * clean copy. This module tells the report which flagged files are which. + */ + +// A WordPress root holds these two side by side. +const WP_MARKER_FILE = "wp-settings.php"; +const WP_MARKER_DIR = "wp-includes"; +const MAX_ANCESTORS = 16; + +/** Role labels, in the order the report should mention them. */ +export const SITE_ROLES = ["config", "core", "theme", "plugin"]; + +const ADVICE = { + config: + "This is a WordPress configuration file. Quarantining it takes the site offline. Open it, remove the injected code by hand or rebuild it from wp-config-sample.php, and rotate the database password and salts.", + core: + "This is a WordPress core file. Quarantining it takes the site offline. Replace it with a clean copy of the same version from wordpress.org, or reinstall core.", + theme: + "This is a theme file. Quarantining it breaks the theme and can blank the site. Reinstall the theme from where you got it, or switch to a default theme first.", + plugin: + "This is a plugin file. Quarantining it makes WordPress deactivate the plugin. Delete the plugin and reinstall a clean copy from wordpress.org or the vendor." +}; + +// Root files the site cannot serve without. +const ROOT_CONFIG = new Set(["wp-config.php", ".htaccess", "web.config", ".user.ini", "php.ini"]); +const ROOT_CORE = new Set([ + "index.php", "wp-load.php", "wp-settings.php", "wp-blog-header.php", "wp-cron.php", + "wp-login.php", "xmlrpc.php", "wp-mail.php", "wp-links-opml.php", "wp-signup.php", + "wp-activate.php", "wp-comments-post.php", "wp-trackback.php", "wp-config-sample.php" +]); + +function toPosix(p) { + return p.replace(/\\/g, "/"); +} + +async function isWordPressRoot(dir) { + try { + const [f, d] = await Promise.all([ + fs.stat(path.join(dir, WP_MARKER_FILE)), + fs.stat(path.join(dir, WP_MARKER_DIR)) + ]); + return f.isFile() && d.isDirectory(); + } catch { + return false; + } +} + +/** Read the WordPress version from wp-includes/version.php, or null. */ +export async function readWordPressVersion(root) { + try { + const text = await fs.readFile(path.join(root, WP_MARKER_DIR, "version.php"), "utf8"); + const m = /\$wp_version\s*=\s*['"]([^'"]+)['"]/.exec(text); + return m ? m[1] : null; + } catch { + return null; + } +} + +/** + * Find the WordPress installs that contain or sit under the scan targets. + * Each target is checked, then its ancestors, so scanning only wp-content + * still knows which site it belongs to. Roots found while walking are added + * later by the scanner through noteWordPressRoot(). + * + * @param {string[]} targets + * @returns {Promise} absolute root directories + */ +export async function findWordPressRoots(targets) { + const roots = new Set(); + for (const target of targets) { + let dir; + try { + const stat = await fs.stat(target); + dir = path.resolve(stat.isDirectory() ? target : path.dirname(target)); + } catch { + continue; + } + for (let i = 0; i < MAX_ANCESTORS; i++) { + if (await isWordPressRoot(dir)) { + roots.add(dir); + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + return [...roots]; +} + +/** + * Called by the scanner for every file it sees. If this file is the marker + * of a WordPress install that has not been recorded yet, record its root. + * + * @param {string} filePath + * @param {Set} roots absolute roots, mutated in place + */ +export function noteWordPressRoot(filePath, roots) { + if (path.basename(filePath) !== WP_MARKER_FILE) return; + roots.add(path.resolve(path.dirname(filePath))); +} + +/** + * Which part of a WordPress site a file belongs to, or null for files the + * site does not need (uploads, cache, stray scripts, anything outside a + * known install). Only files under a detected root are classified, so a + * random index.php elsewhere is never called core. + * + * @param {string} filePath + * @param {Iterable} roots absolute WordPress roots + * @returns {{type:"wordpress", root:string, role:string, advice:string}|null} + */ +export function classifySiteFile(filePath, roots) { + const abs = path.resolve(filePath); + for (const root of roots) { + const rel = path.relative(root, abs); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue; + const posix = toPosix(rel); + const role = roleFor(posix); + if (!role) return null; + return { type: "wordpress", root, role, advice: ADVICE[role] }; + } + return null; +} + +function roleFor(rel) { + const parts = rel.split("/"); + const name = parts[parts.length - 1]; + if (parts.length === 1) { + if (ROOT_CONFIG.has(name)) return "config"; + if (ROOT_CORE.has(name)) return "core"; + return null; + } + const top = parts[0]; + if (top === "wp-admin" || top === "wp-includes") return "core"; + if (top === "wp-content" && parts.length >= 3) { + // wp-content/themes//... and wp-content/plugins//... + // Only code counts; a stray image inside a theme is not site code. + if (!/\.(?:php\d?|phtml|pht|inc)$/i.test(name)) return null; + if (parts[1] === "themes" && parts.length >= 4) return "theme"; + if (parts[1] === "plugins" && parts.length >= 4) return "plugin"; + } + return null; +} + +/** Count flagged records by site role, for the report. */ +export function countSiteRoles(records) { + const counts = {}; + for (const r of records) { + if (!r.site) continue; + counts[r.site.role] = (counts[r.site.role] || 0) + 1; + } + return counts; +} diff --git a/src/walk.js b/src/walk.js index 4d8ee31..81f539c 100644 --- a/src/walk.js +++ b/src/walk.js @@ -11,8 +11,12 @@ import path from "node:path"; /** * Directory names that are noise for a malware scan. They are skipped by - * default so a scan of a real project stays fast and readable. The user - * can still force them back in with --include. + * default so a scan of a real project stays fast and readable. The scan + * report says which of them were skipped, and --include puts them back. + * + * A plain "cache" folder is NOT on this list on purpose. On a WordPress site + * wp-content/cache is web-served, often writable, and a common place to plant + * a shell, so skipping it would turn a real infection into a clean report. */ export const DEFAULT_SKIP_DIRS = new Set([ ".git", @@ -21,10 +25,16 @@ export const DEFAULT_SKIP_DIRS = new Set([ "node_modules", "vendor", ".jayshield-quarantine", - ".cache", - "cache" + ".cache" ]); +/** The skip set minus the names the user asked to include. "all" includes everything. */ +export function skipDirsWithout(include) { + const wanted = new Set(include || []); + if (wanted.has("all")) return new Set(); + return new Set([...DEFAULT_SKIP_DIRS].filter((name) => !wanted.has(name))); +} + /** * Walk a starting path and yield every file underneath it, one at a time, * without ever loading the whole tree into memory. Symbolic links are not @@ -36,11 +46,13 @@ export const DEFAULT_SKIP_DIRS = new Set([ * @param {Set} [options.skipDirs] directory names to skip * @param {boolean} [options.followSymlinks=false] * @param {(dir: string) => boolean} [options.enterDir] return false to skip a directory + * @param {(name: string, dir: string) => void} [options.onSkip] called for every directory skipped by name */ export async function* walk(root, options = {}) { const skipDirs = options.skipDirs || DEFAULT_SKIP_DIRS; const followSymlinks = Boolean(options.followSymlinks); const enterDir = options.enterDir; + const onSkip = typeof options.onSkip === "function" ? options.onSkip : null; const stat = await fs.lstat(root); if (stat.isFile()) { @@ -79,7 +91,10 @@ export async function* walk(root, options = {}) { } if (dirent.isDirectory()) { - if (skipDirs.has(entry.name)) continue; + if (skipDirs.has(entry.name)) { + if (onSkip) onSkip(entry.name, full); + continue; + } if (enterDir && !enterDir(full)) continue; stack.push(full); } else if (dirent.isFile()) { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index a422ea3..64e899d 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -88,3 +88,65 @@ test("a missing target fails cleanly with exit 2", () => { assert.equal(r.status, 2); assert.match(r.stderr, /no such file or folder/); }); + +test("a website address is refused with an explanation, not a confusing file error", () => { + for (const target of ["https://example.com", "http://example.com/wp-admin/", "example.com", "www.example.com/blog"]) { + const r = run([target]); + assert.equal(r.status, 2, target); + assert.match(r.stderr, /scans files on disk/, target); + assert.match(r.stderr, /cannot connect to/, target); + assert.match(r.stderr, /over SSH/, target); + assert.match(r.stderr, /downloaded copy/, target); + assert.doesNotMatch(r.stderr, /no such file or folder/, target); + } +}); + +test("--include scans a folder that is skipped by name", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "jayshield-cli-include-")); + try { + await fs.mkdir(path.join(dir, "vendor")); + await fs.writeFile(path.join(dir, "ok.php"), " { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "jayshield-cli-wp-")); + try { + await fs.mkdir(path.join(dir, "wp-includes")); + await fs.writeFile(path.join(dir, "wp-settings.php"), " scanBuffer(p, Buffer.from(content)).map((f) => f.id); @@ -43,6 +43,19 @@ test("flags an executable script inside a web uploads folder", () => { assert.ok(ids.includes("heuristic.exec_in_uploads")); }); +test("a cached page whose only PHP is the die() guard is not an executable in the cache", () => { + const guard = "\ncached\n"; + assert.equal(onlyGuardPhp(guard), true); + assert.equal(onlyGuardPhp("\n"), true); + assert.equal(onlyGuardPhp("no php at all"), false); + assert.equal(onlyGuardPhp("\n"), false); + assert.deepEqual(idsFor("/var/www/site/wp-content/cache/supercache/wp-cache-1.php", guard), []); + // The guard does not hide a payload that follows it. + const ids = idsFor("/var/www/site/wp-content/cache/supercache/wp-cache-2.php", "\n"); + assert.ok(ids.includes("heuristic.exec_in_uploads")); + assert.ok(ids.includes("php.exec_user_input")); +}); + test("does NOT flag a normal script just because a system ancestor is named tmp or cache", () => { // Regression: earlier the heuristic walked every ancestor up to root, so a // file under /private/tmp or /var/cache was wrongly called an upload. @@ -51,9 +64,43 @@ test("does NOT flag a normal script just because a system ancestor is named tmp }); test("flags packed or one-line payloads by shape", () => { - const packed = " { + // Real WordPress core shapes that used to be flagged. + const svgLine = " ' ` '&#x${(0x1f300 + i).toString(16)};‍️' => ${i},`).join("\n") + "\n);"; + assert.deepEqual(idsFor("wp-includes/formatting.php", entityTable), []); + + const wasm = "const bytes = atob('" + "AGFzbQEAAAAB".repeat(40) + "'); WebAssembly.instantiate(Uint8Array.from(bytes, c => c.charCodeAt(0)));"; + assert.deepEqual(idsFor("router/index.js", wasm), []); + + // High entropy with no way to unpack or run anything is data, not a payload. + const noise = " "\\x" + ((i * 7919) % 256).toString(16).padStart(2, "0")).join("") + "\";"; + assert.ok(!idsFor("table.php", noise).includes("heuristic.high_entropy")); + assert.ok(!idsFor("table.php", noise).includes("php.hex_obfuscation")); + + // A dense payload with a decoder next to it is still flagged. The bytes are + // pseudo-random so the base64 text is close to the entropy of a real packer. + let seed = 12345; + const bytes = Buffer.alloc(6000); + for (let i = 0; i < bytes.length; i++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + bytes[i] = (seed >>> 16) & 0xff; + } + const packed = " { assert.ok(idsFor("p.html", '').includes("js.hidden_iframe")); }); +test("the WSO rule matches WSO's own names, not the letters w-s-o inside another word", () => { + assert.ok(idsFor("x.php", "WSO 4.2.5';").includes("shell.wso")); + // Real WordPress core lines that used to be called a webshell. + assert.deepEqual(idsFor("wp-admin/includes/continents-cities.php", " { + // "system" and "eval" spelled in hex, lower and upper case. + assert.ok(idsFor("x.php", ' { + assert.ok(idsFor("p.html", '').includes("js.hidden_iframe")); + assert.ok(idsFor("p.php", "';").includes("js.hidden_iframe")); + assert.ok(idsFor("p.js", "document.write('');").includes("js.hidden_iframe")); + // marginwidth="0" is not width="0". + assert.deepEqual(idsFor("embed.php", '\';'), []); + // A hidden scratch frame with no destination, as upload libraries create. + assert.deepEqual(idsFor("moxie.js", "temp.innerHTML = '';"), []); + // The Google Tag Manager noscript snippet that sits in most theme headers. + assert.deepEqual(idsFor("header.php", ''), []); +}); + test("catches the EICAR test string in any file kind", () => { const ids = idsFor("note.txt", "harmless X5O EICAR-STANDARD-ANTIVIRUS-TEST-FILE marker"); assert.ok(ids.includes("test.eicar")); diff --git a/test/scanner.test.mjs b/test/scanner.test.mjs index 34189bd..1018761 100644 --- a/test/scanner.test.mjs +++ b/test/scanner.test.mjs @@ -15,6 +15,28 @@ async function tempTree() { return dir; } +/** A WordPress install with threats planted where they turn up in real life. */ +async function wordpressTree() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "jayshield-wp-")); + const mk = async (rel, text) => { + const full = path.join(dir, rel); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, text); + }; + await mk("wp-settings.php", "\ncached\n"); + await mk("vendor/lib/x.php", " { const dir = await tempTree(); try { @@ -75,6 +97,86 @@ test("binary files are not run through the text rules", () => { assert.ok(!findings.some((f) => f.id === "php.eval_decode")); }); +test("a cache folder is scanned by default, and a shell planted there is found", async () => { + const dir = await wordpressTree(); + try { + const result = await scan([dir]); + const paths = result.infected.map((r) => path.relative(dir, r.path).split(path.sep).join("/")); + assert.ok(paths.includes("wp-content/cache/planted.php"), "planted cache shell was not found"); + // The legacy WP Super Cache page, whose only PHP is the die() guard, is not a threat. + assert.ok(!paths.includes("wp-content/cache/supercache/wp-cache-abc.php"), "guard-only cache page was flagged"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("skipped folders are counted and named, and --include puts them back", async () => { + const dir = await wordpressTree(); + try { + const byDefault = await scan([dir]); + assert.equal(byDefault.stats.skippedDirs, 2); + assert.deepEqual(byDefault.skippedDirNames, ["node_modules", "vendor"]); + + const withVendor = await scan([dir], { includeDirs: ["vendor"] }); + assert.equal(withVendor.stats.skippedDirs, 1); + assert.deepEqual(withVendor.skippedDirNames, ["node_modules"]); + assert.equal(withVendor.stats.scanned, byDefault.stats.scanned + 1); + + const everything = await scan([dir], { includeDirs: ["all"] }); + assert.equal(everything.stats.skippedDirs, 0); + assert.equal(everything.stats.scanned, byDefault.stats.scanned + 2); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("a WordPress install is recognised and its own flagged files are marked", async () => { + const dir = await wordpressTree(); + try { + const result = await scan([dir]); + assert.equal(result.sites.length, 1); + assert.equal(result.sites[0].type, "wordpress"); + assert.equal(result.sites[0].version, "6.8.2"); + assert.equal(result.sites[0].root, path.resolve(dir)); + + const roleOf = (name) => { + const r = result.infected.find((x) => x.path.endsWith(name)); + assert.ok(r, `${name} was not flagged`); + return r.site ? r.site.role : null; + }; + assert.equal(roleOf("wp-config.php"), "config"); + assert.equal(roleOf("functions.php"), "theme"); + assert.equal(roleOf("shell.php"), null); + assert.equal(roleOf("planted.php"), null); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("scanning only wp-content still knows which WordPress site it belongs to", async () => { + const dir = await wordpressTree(); + try { + const result = await scan([path.join(dir, "wp-content")]); + assert.equal(result.sites.length, 1); + assert.equal(result.sites[0].root, path.resolve(dir)); + const theme = result.infected.find((x) => x.path.endsWith("functions.php")); + assert.equal(theme.site.role, "theme"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("a plain folder reports no site and no site roles", async () => { + const dir = await tempTree(); + try { + const result = await scan([dir]); + assert.deepEqual(result.sites, []); + assert.ok(result.infected.every((r) => !r.site)); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test("a clean tree returns an empty result", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "jayshield-clean-")); try { diff --git a/test/site.test.mjs b/test/site.test.mjs new file mode 100644 index 0000000..74f7d7e --- /dev/null +++ b/test/site.test.mjs @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + findWordPressRoots, + classifySiteFile, + readWordPressVersion, + countSiteRoles +} from "../src/site.js"; + +/** A small WordPress install with the markers JayShield looks for. */ +async function wordpressTree() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "jayshield-site-")); + const site = path.join(dir, "public_html"); + for (const sub of [ + "wp-includes", + "wp-admin", + "wp-content/themes/twentytwentyfive", + "wp-content/plugins/akismet", + "wp-content/uploads/2026/09", + "wp-content/cache" + ]) { + await fs.mkdir(path.join(site, sub), { recursive: true }); + } + await fs.writeFile(path.join(site, "wp-settings.php"), " { + const { dir, site } = await wordpressTree(); + try { + assert.deepEqual(await findWordPressRoots([site]), [site]); + assert.deepEqual(await findWordPressRoots([path.join(site, "wp-content", "uploads")]), [site]); + assert.deepEqual(await findWordPressRoots([path.join(site, "wp-settings.php")]), [site]); + // A folder with no WordPress above it finds nothing. + assert.deepEqual(await findWordPressRoots([dir]), []); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("reads the WordPress version, and returns null when it cannot", async () => { + const { dir, site } = await wordpressTree(); + try { + assert.equal(await readWordPressVersion(site), "6.8.2"); + assert.equal(await readWordPressVersion(dir), null); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("classifies the files a WordPress site cannot run without", () => { + const root = "/srv/site"; + const role = (rel) => { + const hit = classifySiteFile(path.join(root, rel), [root]); + return hit ? hit.role : null; + }; + assert.equal(role("wp-config.php"), "config"); + assert.equal(role(".htaccess"), "config"); + assert.equal(role("index.php"), "core"); + assert.equal(role("wp-load.php"), "core"); + assert.equal(role("wp-includes/load.php"), "core"); + assert.equal(role("wp-admin/includes/file.php"), "core"); + assert.equal(role("wp-content/themes/twentytwentyfive/functions.php"), "theme"); + assert.equal(role("wp-content/plugins/akismet/akismet.php"), "plugin"); + assert.equal(role("wp-content/plugins/akismet/views/config.php"), "plugin"); +}); + +test("does not call the files a site can live without site files", () => { + const root = "/srv/site"; + const hit = (rel) => classifySiteFile(path.join(root, rel), [root]); + assert.equal(hit("wp-content/uploads/2026/09/shell.php"), null); + assert.equal(hit("wp-content/cache/planted.php"), null); + assert.equal(hit("wp-content/mu-plugins/dropper.php"), null); + assert.equal(hit("wp-content/themes/twentytwentyfive/screenshot.png"), null); + assert.equal(hit("wp-content/plugins/akismet/readme.txt"), null); + assert.equal(hit("wp-content/themes/index.php"), null); + assert.equal(hit("backup.php"), null); +}); + +test("a file outside every known root is never called core, whatever its name", () => { + assert.equal(classifySiteFile("/srv/other/index.php", ["/srv/site"]), null); + assert.equal(classifySiteFile("/srv/site-two/wp-config.php", ["/srv/site"]), null); + assert.equal(classifySiteFile("/srv/site/index.php", []), null); +}); + +test("every classification carries plain advice and the site it belongs to", () => { + const hit = classifySiteFile("/srv/site/wp-config.php", ["/srv/site"]); + assert.equal(hit.type, "wordpress"); + assert.equal(hit.root, "/srv/site"); + assert.match(hit.advice, /offline/); + assert.match(hit.advice, /salts/); +}); + +test("counts flagged records by role", () => { + const counts = countSiteRoles([ + { site: { role: "core" } }, + { site: { role: "core" } }, + { site: { role: "theme" } }, + { path: "uploads/shell.php" } + ]); + assert.deepEqual(counts, { core: 2, theme: 1 }); +}); From f768a9dd16f496c7c63442fe2e65ff218e11e9dd Mon Sep 17 00:00:00 2001 From: Jayden Yoon ZK <19250644+JaydenYoonZK@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:25:16 +0800 Subject: [PATCH 2/4] Release 1.3.0: requirements, a WordPress guide, and what JayShield can promise README gains a Requirements section, a step-by-step guide to scanning a WordPress site on any hosting with an after-the-scan checklist, the skip list and --include, and a plain account of what the scanner can and cannot do, with the clean-code corpus it is checked against. The landing page says the same in one line. --- CHANGELOG.md | 64 +++++++++++++++++++ README.md | 165 ++++++++++++++++++++++++++++++++++++++++++++---- docs/index.html | 4 +- package.json | 2 +- 4 files changed, 218 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b80622..19ae6b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,70 @@ All notable changes to JayShield are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 1.3.0 - 2026-09-14 + +A release about being usable on a real site, and honest about what a scan +means. + +### Added + +- WordPress awareness. A scan recognises a WordPress install and prints its + version, and every flagged file that belongs to WordPress itself is tagged + `config`, `core`, `theme`, or `plugin` in the report and in `--json`. Those + files carry different advice: replace them with a clean copy of the same + version rather than only removing them, because quarantining one takes the + site or the theme offline. Scanning only `wp-content` still knows its site. +- `--include ` scans folders that are skipped by name, and + `--include all` scans everything. The report now says how many folders were + skipped and which names, so a clean result is never quieter than it should + be. Skipped names are also listed in `--help`. +- A clear answer when given a website address. `jayshield https://example.com` + used to fail with "no such file or folder"; it now explains that JayShield + reads files on disk and shows the two ways to scan that site, over SSH or on + a downloaded copy. +- README: a Requirements section, a step-by-step guide to scanning a WordPress + site on any hosting, an after-the-scan checklist for the things a file + scanner cannot do, and a plain list of what JayShield can and cannot promise. +- `scan()` accepts `includeDirs` and returns `sites`, `skippedDirNames`, and + `stats.skippedDirs`; each infected record may carry `site`. The site helpers + and the walker are exported from the package. + +### Changed + +- Folders named `cache` are now scanned. On WordPress, `wp-content/cache` is + web-served, writable, and a common place to plant a shell, and the upload + heuristic already treated it as one, so skipping it left a blind spot. +- The upload heuristic no longer flags a cached page whose only PHP is the + `` guard that WP Super Cache writes. A payload after the + guard is still caught. +- The clean-scan line now reads "Nothing in the files scanned matched a known + technique, heuristic, or bad hash", which is what a clean result means. + +### Fixed + +- A pristine WordPress 7.1 download was reported as 26 infected files, five + of them critical. Every one was a false positive, and every one is fixed: + - The WSO webshell rule matched the letters w-s-o inside any word, so + "Dawson", "WSODs", `useNewSodiumAPI`, and a certificate bundle were all + called a webshell. It now matches WSO's own function and constant names. + - The hex-escape rule flagged every long binary constant, in sodium_compat, + getID3, and SimplePie. It now flags a dangerous function name or request + variable spelled in hex, or a hex string literal called as a function, + and is high severity because that is never innocent. + - The hidden iframe rule matched `marginwidth="0"`, a hidden scratch frame + with a `javascript:` source, and the Google Tag Manager noscript snippet + that sits in most theme headers. It now requires a hidden frame with a + real destination, and a page full of unterminated tags can no longer + stall a scan. + - The long-line, high-entropy, and base64-blob heuristics fired on SVG + icons, entity tables, arrays of class names, and an embedded WebAssembly + module. Each now also requires a decoder, an execution call, or request + input on the same file or line, which a packed payload cannot do without. +- Verified clean with the new rules against WordPress 7.1 with and without + `--include vendor`, WooCommerce 11.1.0, the Astra theme 4.13.11, jQuery + 3.7.1, and Laravel 12.x, while every planted threat in the test fixtures is + still found. + ## 1.2.1 - 2026-07-13 ### Changed diff --git a/README.md b/README.md index 4024458..d56102d 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,18 @@ site stops serving them right away. It is one small tool with no dependencies, so you can trust it, read it, and run it anywhere Node is installed. +## Requirements + +- **Node.js 18 or newer** on the machine that runs the scan. Check with + `node -v`. If that prints nothing or an older version, install Node from + [nodejs.org](https://nodejs.org/) (macOS, Windows, and Linux installers). +- **The site's files on that machine.** JayShield reads files on disk. It does + not connect to a website address, so `jayshield https://example.com` is + refused with an explanation. See [Scan a WordPress site](#scan-a-wordpress-site) + for the two ways to get the files in front of it. + +Nothing else: no account, no PHP, no database access, no background service. + ## Quick start No install required, run it straight from npm: @@ -57,6 +69,81 @@ jayshield ./public_html The examples below write `jayshield` for short, meaning however you choose to run it. +## Scan a WordPress site + +JayShield finds the files an attacker planted or changed. To do that it needs +the site's files, so pick whichever of these fits your hosting. + +**Path A: run it on the server.** If your host gives you SSH (most VPS plans, +managed WordPress hosts, and many shared hosts), log in and check for Node: + +```bash +ssh you@your-server +node -v +``` + +If Node is there, scan the site root, which is usually `public_html`, `htdocs`, +`www`, or `/var/www/`: + +```bash +npx @jayhackpro/jayshield ~/public_html +``` + +If Node is missing and you cannot install it, use Path B. On many shared hosts +you can install Node without root through cPanel's "Setup Node.js App" or with +[nvm](https://github.com/nvm-sh/nvm). + +**Path B: scan a downloaded copy.** This works on any hosting, including +hosting with no SSH at all. Download the whole site folder with an SFTP client +(FileZilla, Cyberduck, WinSCP), with the "compress and download" button in your +host's file manager, or from a full backup made by your backup plugin. Unzip it +on your own computer and scan the folder: + +```bash +npx @jayhackpro/jayshield ~/Downloads/example.com +``` + +The report is about the copy. Nothing on the live site changes, so clean the +live site by hand using the paths in the report: delete planted files over SFTP +and replace infected WordPress files with clean copies (see below). + +**Scan the whole site root, not just `wp-content`.** Attackers also edit +`wp-config.php`, `.htaccess`, and the root `index.php`, and JayShield can only +report on what it is pointed at. For a deeper scan add `--include vendor` so +plugin dependency folders are read too. + +**Reading the results on a WordPress site.** JayShield recognises a WordPress +install, prints its version, and tags each flagged file that belongs to +WordPress itself: + +- **No tag** (`wp-content/uploads`, `wp-content/cache`, a stray file in the + root): almost always planted. Quarantine it. +- **`[wordpress config]`**, **`[wordpress core]`**, **`[wordpress theme]`**, + **`[wordpress plugin]`**: a real file with injected code. The whole file is + untrusted, so replace it with a clean copy of the same version (core and + plugins from wordpress.org, the theme from where you got it) rather than only + removing it. Quarantining one of these takes the site or the theme offline + until you do; `--restore` puts it back if that happens. + +**After the scan.** Removing the files is the start, not the end. Do these too, +because JayShield cannot: + +1. Update WordPress, every plugin, and every theme, and delete the ones you do + not use. The way in was almost always an outdated or nulled one. +2. Change every password: WordPress admins, hosting panel, SFTP, and the + database user in `wp-config.php`. Rotate the salts in `wp-config.php` with + [new values](https://api.wordpress.org/secret-key/1.1/salt/). +3. Look for admin users you did not create, in Users, and remove them. +4. Check the database, which JayShield never reads: `siteurl` and `home` in + `wp_options`, unknown entries in `wp_options`, and `