- Scheme 89.1%
- Shell 9.9%
- Makefile 1%
| .jerboa | ||
| docs | ||
| scripts | ||
| support | ||
| test | ||
| tools | ||
| virus | ||
| .build.yml | ||
| .gitignore | ||
| .gitsafeignore | ||
| .jerbuild | ||
| AGENTS.md | ||
| clamav-parity.md | ||
| dependency-lock.tsv | ||
| jpkg.lock | ||
| jpkg.policy.sexp | ||
| jpkg.sexp | ||
| LICENSE | ||
| Makefile | ||
| NOTICE | ||
| README.md | ||
| SECURITY.md | ||
jerboa-virus
A pure-Jerboa malware scanner: a literal/hex/regex pattern engine with a condition DSL, a parallel filesystem walker, offline hash-intel feeds, and a YARA importer for ingesting third-party rule packs.
Ships as a single self-contained binary — no Chez or Jerboa needed at runtime.
Design
- Jerboa with a pinned native safety kernel. No shelling out to YARA, ClamAV, or another antivirus on the scan hot path. The matcher, condition evaluator, hashers, and intel loaders are Jerboa; the Rust Thompson-NFA regex engine is statically linked so untrusted regexes cannot downgrade to a backtracking implementation.
- Rules as data. Malware rules are plain Jerboa hash literals — testable in the REPL, composable, easy for both humans and LLMs to author. A YARA importer is bundled for ingesting existing rule packs (supported subset documented below).
- Hybrid intel. Offline SHA-256 / SHA-1 feeds (MalwareBazaar, URLhaus, NSRL known-good filter, anything line-oriented) fold into the scanner alongside rules. Online APIs are out of scope.
- Performance first. Pre-compiled rulesets, immutable for
thread-safe sharing across worker pools. A single Aho-Corasick pass
per file finds every candidate across all rules; hex wildcards verify
by mask on hit, and regex patterns pre-filter through their longest
required literal (extracted at compile time) before the engine runs.
Binary content is detected by a NUL probe in the first 4 KiB and
regex is skipped for it by default — see
--regex-mode.
Rule example
(def rule:xmrig
(make-rule
(make-hash
'id 'xmrig
'name "Miner.XMRig"
'description "XMRig cryptocurrency miner indicators."
'severity 'high
'tags '(miner cryptocurrency xmrig)
'strings (make-hash
'name "xmrig"
'banner "XMRig"
'stratum (make-hash 'regex "stratum\\+tcp://")
'algo (make-hash 'regex "(rx/0|cn/r|kawpow|cn-pico)"))
'condition '(and (str name) (any-of stratum algo banner)))))
Patterns are dispatched by shape:
| Author writes | Pattern type |
|---|---|
"xmrig" |
literal text (UTF-8 encoded) |
#vu8(77 90 144 0) |
literal bytes |
(make-hash 'hex "4d 5a ?? 00") |
hex bytes with ?? wildcards |
(make-hash 'regex "stratum.+") |
guaranteed-linear Rust regex over a Latin-1 byte view |
Conditions support: (str ID), (any-of ID...), (all-of ID...),
(none-of ID...), (n-of N ID...), (and ...), (or ...), (not ...).
Build & install
Requires jerbuild on PATH, or set JERBUILD=/path/to/jerbuild.
Chez and the Jerboa standard library are supplied by jerbuild at build
time. The runtime verifier requires the exact executable, bundle, and native
library digests in dependency-lock.tsv.
The current lock honestly records the shared Jerboa source as
UNCOMMITTED-DIRTY. Functional builds and adversarial tests run against the
measured bundle, but make install, source fetching, and release evidence fail
closed until those shared changes have an immutable committed revision.
make test # run the full test suite
make binary # produces ./jscan (self-contained executable)
make security # local security/import checks
make sbom # source/dependency inventory
make reproducibility-report
make archive-regex-stress
# inert local archive/regex stress evidence
make soak-evidence # local parity/soak status
make verify # security + binary build + tests
make bounded-run ARGS='PATH'
# limited worker; enables PCRE2-only ClamAV rules
make release-evidence # write dist/release-evidence/
make install # build + install to ~/.local/bin/jscan
make uninstall # remove the installed binary
Security and release details live in SECURITY.md,
docs/threat-model.md, docs/security-hardening.md, and
docs/sandbox-policy.md, and docs/release-evidence.md.
CLI
jscan PATH [PATH...] # recursive scan with worker pool
jscan --severity high ~ # scan your home directory with filters
jscan scan PATH [PATH...] # equivalent explicit form
jscan match-file PATH # scan a single file
jscan list-rules # show bundled rules
jscan list-feeds # show available threat-intel feeds
jscan update [FEED...] # download/refresh feeds into the cache
scan flags:
-j N,--jobs N— worker thread count (default 4)--max-size MB— skip files larger than MB megabytes (default 100)--follow-symlinks— follow symlinks (off by default). Directory device/inode identities prevent cycles, and resolved targets may not escape the explicitly supplied scan roots.--no-progress— suppress the per-second progress line on stderr--regex-mode MODE—auto(default),full, orlit-only.autoruns regex on text files and skips it on binary content (literals + hex still scan via Aho-Corasick).fullalways runs regex;lit-onlynever does. Usefullif your rules have regex patterns intended to fire inside executables.--intel FILE— load known-malicious hashes from FILE (repeatable). Lines: SHA-256 or SHA-1 hex with an optional label after whitespace or comma.#comments and blank lines ignored.--rules FILE— load custom Jerboa-format rule files (repeatable).--yara-rules FILE— load YARA-format rules (repeatable). Supported subset detailed below.--no-builtin-rules— skip the bundled rule pack; use only--rules/--yara-rules.--tag TAG— keep only rules tagged TAG (repeatable; OR semantics).--severity LEVEL— keep only rules at LEVEL or higher (info|low|medium|high|critical).--output FORMAT— per-finding line format:text(default) orndjson. NDJSON streams one JSON object per finding so downstream tools can consume incrementally.--exclude GLOB— skip paths matching GLOB (repeatable). The glob matches against both the full path and the basename, so*.logworks without**/*.log.**/*recursion is supported.--ignore-file FILE— read one--excludepattern per line from FILE. If.jscanignoreexists in the current directory it is loaded automatically.--color WHEN— colorize text output:auto|always|never. Defaultautoenables color only when stdout is a TTY. Ignored for ndjson.--no-auto-feeds— skip auto-loading of cached intel + YARA rule packs from~/.local/share/jscan/. Use this when you want explicit control via--inteland--rules/--yara-rules.--clamav-db PATH— load explicit ClamAV.cvd/.cldfiles, raw database member files, or one-level directories of raw database files (repeatable). This is useful for no-malware parity fixture testing againstclamscan --database=PATH.--clamav-pua— opt into ClamAV PUA signature members (hdu,hsu,mdu,msu,ndu,ldu) from cached CVDs. These are disabled by default because PUA coverage is intentionally higher-noise.
Skipped automatically: .git, node_modules, __pycache__, .cache,
.cargo, .rustup, .venv, .tox, .gradle, .idea, .vscode,
vendor, the binary itself, plus kernel-virtual filesystems
(/proc, /sys, /dev, /run).
Exit code is 0 on a clean scan, 1 when one or more rules fire, and
2 on usage or I/O errors.
Threat-intel feed fetcher
jscan update downloads curated public feeds and caches them so
the next scan auto-loads them. No registration or API keys are
needed. The fetcher uses HTTPS-only curl for generic feeds. Official ClamAV
databases require freshclam; if it is unavailable the update fails closed
and never downgrades to a direct CVD download. With no arguments the
defaults are fetched (small, recent-window feeds suitable for daily
refresh):
| Feed | Kind | Size | Source |
|---|---|---|---|
malwarebazaar-recent |
intel | ~50 KB | abuse.ch MalwareBazaar SHA-256 (48h) |
threatfox-sha256 |
intel | ~15 KB | abuse.ch ThreatFox file-IOC SHA-256s |
yara-forge-core |
rules | ~8 MB | YARA-Forge curated daily release |
clamav-daily |
clamav | ~50 MB | ClamAV daily CVD signatures |
Heavier optional feeds can be fetched by name:
| Feed | Size | Notes |
|---|---|---|
urlhaus-payloads |
~750 MB | abuse.ch URLhaus full payload corpus |
yara-forge-extended |
~tens MB | Broader YARA-Forge bundle, more FPs |
yara-forge-full |
~hundreds MB | Everything YARA-Forge ships |
clamav-main |
~150 MB | ClamAV base CVD signatures |
clamav-bytecode |
~1 MB | Accounted .cbc bytecode; hook/unpacker source is not executed |
jscan list-feeds # show catalog + cache freshness
jscan update # fetch all default feeds
jscan update threatfox-sha256 # refresh one feed
jscan update urlhaus-payloads # opt into a heavy feed
jscan update clamav-official # daily + main + bytecode
jscan update broad-coverage # abuse.ch + YARA-Forge full + ClamAV
jscan update clamav-main clamav-bytecode
The cache lives at ~/.jscan/cache/ with this layout. Set an absolute
JSCAN_CACHE_DIR to use a different private cache. If HOME is unavailable,
an explicit cache is mandatory; there is no shared temporary fallback.
~/.jscan/cache/
├── intel/ # *.txt — hash feeds in our intel format
├── rules/ # *.yar — YARA rule packs
├── clamav/ # *.cvd / *.cld — ClamAV database containers
└── state.txt # last-fetch timestamp + size per feed
scan auto-loads every intel/*.txt and rules/*.yar it finds, so a
freshly run update immediately benefits the next scan. Use
--no-auto-feeds to opt out and control loading explicitly via
--intel / --rules / --yara-rules.
ClamAV CVD/CLD files in the clamav/ cache are auto-loaded only when the file
and sidecar are user-owned mode-0600 regular files, FreshClam authenticated the
update, the recorded SHA-256/version/builder/build time still match, the signed
header MD5 matches the compressed payload, and freshness/functionality/rollback
checks pass. Explicit CVD/CLD paths require the same provenance; explicit raw
signature member files remain available for locally trusted fixtures.
Current usable formats are hdb, hsb, mdb, msb, fp, sfp,
ign, ign2, ndb, and the current official ldb logical-signature
corpus. PUA hash/NDB/LDB members can be loaded with
--clamav-pua. NDB signatures support literal bytes, byte/nibble
wildcards, fixed jumps, ranged/unbounded jumps, simple alternates,
empty alternate branches, isolated nibble bodies,
absolute offsets, floating offsets, EOF-relative offsets, EP-relative PE
offsets, PE/ELF section-relative offsets, PE version-resource VI
offsets, and large bounded gaps used by the official corpus. It also
applies target-type filters for PE, OLE2, normalized HTML, mail,
graphics, ELF, normalized ASCII/script text, Mach-O, PDF, Flash, and
Java where those formats can be identified from headers or scan context.
LDB support covers plain hex subsignatures,
NDB-style subsignature offsets, Target, FileSize, NumberOfSections,
boolean &/| expressions, and hit-count modifiers (=, ==, <,
>, A,min-max, plus X,Y distinct-signature thresholds). It reuses
the NDB body parser for ranged, alternate, and fixed-byte negated
subsignatures, and supports literal w/a/f modifiers plus
wide/case-insensitive regex-derived subsignatures with fixed gaps and
wildcards. LDB target handling includes Container/Intermediates for
zip-family, tar, gzip, MIME mail base64 and quoted-printable parts,
OLE2/MSOLE2 streams, decoded PDF ASCIIHex/ASCII85/RunLength/LZW streams
including /EarlyChange 0, PDF decode predictors, optional libz-backed PDF
Flate streams, and OOXML
archive contexts already extracted by the scanner, OOXML XML Word/XL
member aliases, root
PE/RTF/HTML file-type contexts, bounded lowercasing normalized HTML extraction
with tag-preserved, tag-stripped, and extracted JavaScript buffers,
bounded normalized ASCII text extraction, bounded ASCII script
normalization with common JavaScript string-escape decoding, bounded HTML
data:image/...;base64,... extraction, audited HandlerType filters for
PDF, graphics, zip, and script-like source, PE
version-resource VI: offsets, and a conservative slash-form PCRE
subset with triggered unanchored fallback for patterns without literal
prefilters. When available, optional PCRE2 and libclamav backends add
ClamAV-compatible PCRE constructs, anchored A, rolling r, and
encompassed e offset-window semantics, plus exact fuzzy_img#...#0
image fuzzy-hash matching. It also supports the current official binary
byte-compare subsignatures and legacy logical-expression suffix forms
that ClamAV accepts in old signatures. Unsupported ClamAV member formats
are counted in the startup log rather than silently treated as supported.
ClamAV FTM file-type metadata is loaded and used for root scan-context
classification and extracted archive-member aliases, and CDB container
metadata is evaluated for ZIP members, including encrypted entries,
POSIX tar members, and best-effort
bsdtar-listed archive members where names, real sizes, and ordinals
are available; ISO9660 members also report uncompressed member size as
the CDB compressed-size field. PDB/WDB phishing metadata is loaded for
conservative HTML displayed-domain mismatch detection with allow-list
suppression, including bare displayed domains, HTML entity, semicolonless
numeric and URL-punctuation named entities, percent-host, and scheme-relative
URL cleanup, escaped JavaScript/event and CSS URL contexts, plus
quoted-printable mail body extraction. CFG
dynamic-configuration rows are parsed and accounted as non-signature
metadata. CRB certificate trust/block rows are parsed and
blocked PE Authenticode certificate entries are enforced for PKCS#7
certificate tables when OpenSSL is available, with trusted code-signing or
certificate-signing CRB rows suppressing block hits from the same chain.
The .cbc bytecode path accounts source-recovered members from
bytecode.cvd when clambc --printsrc is available. The source-only
compiler recognizes simple standalone DEFINE_SIGNATURE sources,
including literal bytes, masked bytes, fixed jumps, ordered * /
{n-m}-style gaps, simple alternates, negated byte groups, and exact,
floating, EP-relative, and EOF-relative offsets. It can compile those
standalone matchers to WebAssembly and run them with Jerboa's bounded
WASM runtime. It also
parses simple logical_trigger() boolean expressions over
matches(Signatures.name) and count_match(Signatures.name) predicates
so supported source signatures can be checked as grouped preconditions
instead of loose independent matches. Current official bytecode source
members are hook/unpacker programs whose logical_trigger() expressions
are only prefilters for later bytecode logic, so they are accounted but
not executed as standalone detections. Opaque compiled bytecode is still
not executed; source-less .cbc members remain accounted gaps. Hook-only
source members that have no source-level signature and no virus-reporting
API call are accounted separately from unsupported signature source. See
clamav-parity.md for the parity roadmap. tools/clamav-compare.sh
runs local corpus comparison against clamscan; the comparison tool writes
detail files plus summary.json, auto-uses the jscan ClamAV cache when
CLAMSCAN_ARGS is unset, and --fail-on-delta makes it suitable for local
CI gates. tools/clamav-classify-deltas.sh layers triage on top of that
output and writes delta-classification.tsv / .json using an optional
manifest to mark deltas as parity gaps, intentional extra detections,
false positives, or policy differences. If a corpus is laid out as
CORPUS/samples plus CORPUS/manifest.tsv, the classifier auto-discovers
that parent manifest, which lets private local corpora live outside the
repository while still enforcing --fail-on-unclassified.
tools/clamav-safe-corpus.sh
creates a disposable EICAR-based smoke corpus for validating the harness
without real malware. tools/clamav-parity-corpus.sh creates an inert
synthetic ClamAV database plus archive/mail/PDF extraction samples that
both jscan and clamscan should detect identically.
tools/jscan-trigger-corpus.sh creates an inert signature-trigger corpus
for the bundled jscan-native rules, with a manifest that classifies those
jscan-only detections as intentional extras when compared with
clamscan. tools/jscan-benign-corpus.sh and
tools/jscan-benign-smoke.sh create and enforce clean near-miss fixtures
for false-positive regression checks against the bundled rule pack.
tools/clamav-official-counts.sh checks the current
official-cache load snapshot and fails if NDB/LDB deferrals reopen.
For bytecode source accounting, tools/clamav-cbc-signatures.sh extracts
source-recovered DEFINE_SIGNATURE entries from bytecode.cvd/.cld
or an unpacked .cbc directory and marks which signatures fit the
current source-only WASM signature subset or require hook/unpacker
semantics. tools/clamav-ldb-gaps.sh
prints a static category breakdown of remaining LDB gaps from cached or
unpacked ClamAV databases.
Scan-time archive recursion covers common zip-family containers
(.zip, .jar, .apk, .docx, .xlsx, .pptx), tar files,
gzip-compressed tar files, and plain gzip streams, including nested
archives up to a bounded recursion limit. When bsdtar is available,
jscan also performs bounded best-effort member traversal for 7Z, RAR,
CAB, ISO9660, DMG, and NSIS containers, and tags root DMG/NSIS/AutoIt/CAB/
ISO/7Z/RAR contexts for ClamAV LDB container rules. Findings inside an
archive are reported on the container path, matching clamscan's
path-level behavior.
Ordinary rule and YARA regexes are accepted only by Rust regex.
Backreferences, look-around, and other unsupported constructs are rejected at
rule-load time. ClamAV-only PCRE constructs use PCRE2 match/depth limits and
are enabled only inside scripts/jscan-bounded-worker.sh (or an equivalently
isolated service worker that deliberately sets JSCAN_ISOLATED_WORKER=1 after
applying limits). Archive recursion has per-container and per-file global
member, expanded-byte, member-name, and depth budgets. Filesystem traversal
streams through a 256-entry work queue, uses bounded descriptor-pinned
directory listings, and caps depth, files, entries, and aggregate path bytes.
YARA-Forge rules that use unsupported constructs (modules, wide,
filesize, hex jump ranges, etc.) are skipped at parse time with a
short stderr summary — the rest of the pack still loads. Expect to
keep a few hundred rules out of the several thousand in a YARA-Forge
release; the cherry-picked ones are still meaningful detection
coverage on top of the bundled rules.
Hash intel feeds
Intel files are plain text. Each non-empty, non-comment line carries a SHA-256 (64 hex chars) or SHA-1 (40 hex chars), optionally followed by a label after whitespace or a comma:
# MalwareBazaar dump 2026-05-01
275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f EICAR
3395856ce81f2b7382dee72602f798b642f14140 Trojan/Generic.x
When --intel FILE is passed, every scanned file is also hashed and
looked up in the database. Hits surface as findings with a synthetic
rule named after the matched algorithm. The lookup is O(1) per file
regardless of feed size.
YARA importer
--yara-rules FILE parses YARA syntax and converts each rule into the
internal data model. Supported:
meta:description,author,severity(info|low|medium|high|critical)strings: ASCII ($a = "..."), hex ($a = { 4d 5a ?? 90 }), regex ($a = /pat/), withnocaseandasciimodifierscondition:$a/not,and,or,(...),N of them,any of them,any of ($prefix*)- multiple rules per file,
//and/* */comments
Rejected with a clear error: import statements, hex jump ranges
([N-M]), hex alternation ((A|B)), the wide, xor, fullword,
base64, base64wide modifiers, and any module reference
(pe.imports, math.entropy, etc.). If you have a YARA rule pack you
want to ingest, expect a handful of rules to be rejected — that is by
design; the scanner refuses to silently misinterpret what it does not
fully support.
Config file
Stash your preferred scan defaults in ~/.jscan/config and they get
prepended to every jscan invocation. Each non-comment, non-blank
line is one CLI flag-and-value pair; whitespace splits tokens.
# ~/.jscan/config
--jobs 8
--severity medium
--exclude /Volumes/Backups
--exclude **/node_modules
--rules /etc/jscan/site-rules.scm
Resolution order: --config FILE > $JSCAN_CONFIG > ~/.jscan/config
legacy
$VIRUS_CONFIG/~/.virus/config> none. Explicit CLI flags still win for scalar options and accumulate for repeatable ones (--exclude,--intel,--rules,--yara-rules). Use--no-configto skip the file even if one exists.
Ignore files
Line-oriented globs. Blank lines and # comments are skipped. The
same syntax as --exclude. Either pass via --ignore-file FILE or
drop a .jscanignore at the root of the directory you are scanning.
Legacy .virusignore files are still honored.
# .jscanignore
node_modules
*.log
build/**/*.o
Bundled rules
34 hand-written rules covering: EICAR + encoded binary dropper heuristics
(base64-encoded ELF/PE); cryptominers (XMRig, generic
stratum-pool, Ethereum-family); reverse shells (bash, python, perl,
PHP, netcat, ruby); webshells (PHP eval-base64, China Chopper,
JSP/Runtime.exec); Linux malware (Mirai, Gafgyt, Diamorphine rootkit,
LD_PRELOAD rootkits); credential / data exfil (AWS keys, GitHub tokens,
SSH private keys, GCP service-account JSON, browser credential stores, crypto
wallets); persistence (suspicious macOS LaunchAgent, suspicious cron
fetch-and-pipe); shell-droppers (curl|sh, wget|sh, encoded
PowerShell, PowerShell download cradles); and exfil channels (Discord
webhooks, Telegram bots, Slack webhooks, Pastebin droppers).
Run jscan list-rules for the full list with one-line
descriptions. Rules are pure Jerboa data — see
virus/builtin-rules.ss to read or contribute.
License
MIT