Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir,...>` 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
`<?php die(); ?>` 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
Expand Down
165 changes: 151 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/<your-site>`:

```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 `<script>` or `<iframe>`
tags inside `wp_posts` content.
5. Verify core against the official checksums with
[WP-CLI](https://wp-cli.org/): `wp core verify-checksums`. This catches
changed core files that match no signature.
6. Read `.htaccess` for redirects you did not write.
7. If Google flagged the site, request a review in Search Console once it is
clean.

## What a scan looks like

A report ranked by severity, with the exact line and a plain-language reason for every finding:
Expand Down Expand Up @@ -92,6 +179,12 @@ JayShield never deletes your files. When you are ready to clean up, quarantine
moves each flagged file into a local vault and records where it came from, so
your live site stops serving it at once while every byte is kept.

On a WordPress site, check the tags in the report first. Files tagged
`[wordpress core]`, `[wordpress config]`, `[wordpress theme]`, or
`[wordpress plugin]` are the site's own code with something injected;
quarantining them takes the site or the theme offline until you replace them
with clean copies. Run `--dry-run` first so you know what will move.

```bash
# preview what would move, changing nothing
jayshield ./public_html --quarantine --dry-run
Expand Down Expand Up @@ -130,6 +223,7 @@ jayshield [paths...] [options]
| `--purge --yes` | Permanently delete the vault (last resort) |
| `--min-severity <level>` | `critical`, `high`, `medium`, or `low` |
| `--ignore-rule <id,...>` | Silence one or more rules by id |
| `--include <dir,...>` | Scan folders that are skipped by name, or `all` |
| `--hashes <file>` | Add known bad sha256 hashes, one per line |
| `--max-size <MB>` | Skip files larger than this (default 5) |
| `--vault <dir>` | Quarantine folder (default `.jayshield-quarantine`) |
Expand All @@ -147,6 +241,15 @@ jayshield [paths...] [options]
Exit codes: `0` clean, `1` threats found, `2` error. That makes JayShield easy
to drop into a CI pipeline or a cron job.

**What is skipped, and how to see it.** Folders named `.git`, `.svn`, `.hg`,
`node_modules`, `vendor`, `.cache`, and the quarantine vault are skipped by
default so a project scan stays fast, and files over 5 MB are skipped for size.
The report says how many folders were skipped and which names, so a clean
result is never quieter than it should be. `--include vendor` scans one of
them, `--include all` scans everything, `--max-size` raises the size cap.
A folder named plainly `cache` is scanned: on WordPress, `wp-content/cache` is
a favourite place to plant a shell.

## Use it in a pipeline

```bash
Expand All @@ -168,11 +271,14 @@ npm install @jayhackpro/jayshield
```js
import { scan, quarantineFiles } from "@jayhackpro/jayshield";

const result = await scan(["./public_html"]);
const result = await scan(["./public_html"], { includeDirs: ["vendor"] });
console.log(`${result.infected.length} infected of ${result.stats.scanned} scanned`);
for (const site of result.sites) console.log(`WordPress ${site.version} at ${site.root}`);

if (result.infected.length) {
await quarantineFiles(result.infected, { vaultDir: ".vault" });
// Quarantine planted files. Files tagged as the site's own code need a clean copy instead.
const planted = result.infected.filter((r) => !r.site);
if (planted.length) {
await quarantineFiles(planted, { vaultDir: ".vault" });
}
```

Expand All @@ -197,17 +303,48 @@ readable objects. Each one has an id, a severity, the file kinds it applies to,
and a pattern. Add one, add a test in [`test/rules.test.mjs`](test/rules.test.mjs),
and send a pull request.

## What it is, and what it is not

JayShield is a fast first responder for web malware. It is excellent at finding
the webshells, backdoors, injections, and known bad files that make up the large
majority of website compromises, and at removing them without data loss.

It is not a full endpoint protection suite and does not run in the kernel or
watch memory in real time. On a busy production server it works best alongside a
host scanner and a web application firewall, as the fast, readable, scriptable
layer you can run on demand and in your pipelines. Signatures find known
techniques, so pair a scan with good backups and prompt updates.
## What JayShield can and cannot promise

JayShield is a fast first responder for web malware: a file scanner you run on
demand, read in full, and script.

**It can:**

- Read every file under the folders you name, except the skipped folder names
and oversized files it tells you about, and match each one against its
signature library, its heuristics, and the known bad hash set.
- Show the exact file, line, and evidence for every finding, so you can judge
it yourself.
- Recognise a WordPress install and tell you which flagged files are the site's
own code, so you replace those and remove the rest.
- Quarantine without data loss. Every moved file is kept byte for byte and can
be restored.
- Fit a pipeline: JSON output, exit codes, no dependencies, no network calls.

**It cannot:**

- Scan a website by its address. It has no crawler, and a webshell in
`wp-content/uploads` is invisible from the outside anyway. Get the files.
- Read a database. Malware that lives in `wp_options` or inside posts, and rogue
admin users, are out of its sight.
- Prove a file is unchanged. It has no copy of the official WordPress, plugin,
or theme checksums; use `wp core verify-checksums` for that.
- Know which plugin versions are vulnerable, or how the attacker got in.
- Watch in real time, block requests, or replace a firewall and backups.
- Catch everything. Signatures and heuristics find known techniques. A clean
report means nothing matched, not that the site is clean.
- Be right every time. Odd but legitimate code can trip a heuristic. The report
shows the line so you can decide, and `--ignore-rule` silences a rule you
have checked.

**How it is checked.** Before a release the rules are run over real, clean
code and must report nothing. For 1.3.0 (September 2026) that corpus was
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, over twelve thousand files
in all, with zero findings. The same run must still catch every planted threat
in the test fixtures. The test suite runs on every change on Node 18, 20, and
22. If JayShield flags a clean file on your site, that is a bug worth
[reporting](https://github.com/JayHackPro/JayShield/issues).

## Development

Expand Down
40 changes: 37 additions & 3 deletions bin/jayshield.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -124,6 +139,8 @@ function helpText(v) {
${color.bold("Options")}
--min-severity <level> critical | high | medium | low
--ignore-rule <id,...> silence one or more rules
--include <dir,...> scan folders skipped by name, or "all"
(skipped: ${[...DEFAULT_SKIP_DIRS].join(" ")})
--hashes <file> add known-bad sha256 hashes (one per line)
--max-size <MB> skip files larger than this (default 5)
--vault <dir> quarantine folder (default ${QUARANTINE_DIR})
Expand All @@ -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);
Expand Down Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion docs/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@
const lines = [
{ d: 350, html: "" },
{ d: 260, html: '<span class="t-banner"><span class="t-ascii">' + ASCII + '</span><span class="t-compact"> ▓█ JayHackPro █▓</span></span>' },
{ d: 140, html: '<span class="t-sub"> JayShield® · find and remove web malware</span><span class="t-dim"> v1.2.1</span>' },
{ d: 140, html: '<span class="t-sub"> JayShield® · find and remove web malware</span><span class="t-dim"> v1.3.0</span>' },
{ d: 100, html: '<span class="t-dim"> github.com/JayHackPro/JayShield</span>' },
{ d: 200, html: "" },
{ d: 420, html: '<span class="t-dim"> Scanned 1,284 files (24.6 MB) in 0.9s</span>' },
Expand Down
Loading
Loading