Rule packs
Pro. For “how do I tune the rules?”A rule pack is a directory of detection data that Penthera verifies before it uses a single rule. This is how new knowledge about attacks (a new key format, a worm’s package list, a fresh prompt-injection trick) reaches Penthera without Penthera ever fetching anything at run time.
Trust model
Packs reach Penthera in two ways only:- Bundled in a release.
lib/rules/<name>/ships inside the npm package. Releases are staged on npm by CI and go live only when the maintainer approves them with 2FA (see.github/workflows/publish.yml). The publish workflow runsnpm run rules:check, which refuses to stage a release with a bundled pack that is not signed by a key inlib/rules/allowed_signers. So a rule change has to pass through the maintainer’s signing key, which never enters CI, as well as through the 2FA approval. - By path, from you.
--rules <dir>loads a pack from a directory you name. It must be signed by a key inlib/rules/allowed_signersor in a file you pass with--rules-signers <file>.
lib/rules/) has no network code at all, and a test asserts that.
Why no runtime fetching
A rule feed is a path from somebody else’s server into a tool that runs with your network access, and often with your credentials and your source code. Rule updates are exactly the kind of channel attackers go after: compromise the feed and every scanner that trusts it runs your rules, or reports your malware as clean. Securing such a channel takes signed metadata, key rotation, freshness guarantees and trust delegation (TUF, in-toto) [samuel2010survivable, torresarias2019intoto]. Penthera already has a channel with those properties for its code: the release. Shipping rules through that same channel adds no new way in, and the rules you ran are the rules in the version you installed. The cost is latency: a new IOC (indicator of compromise) reaches you when you upgrade, not within the hour. For the campaigns these packs target, an IOC is most useful in the days and weeks after disclosure, while compromised versions sit in lockfiles and npm caches. If you need something faster, write a pack yourself, sign it with your own key, and pass it with--rules and --rules-signers. Penthera never
decides on your behalf to trust a new source.
What verification checks
In order, and a failure refuses the whole pack (there is no partial load):- Signature.
pack.json.sigis an OpenSSH signature (ssh-keygen -Y sign, namespacepenthera-rules) over the exact bytes ofpack.json. The signing key must be in an allowed_signers file, withnamespaces(if set) includingpenthera-rulesandvalid-after/valid-beforerespected. Penthera verifies it in Node (ed25519 or RSA ≥ 2048), so it needs nossh-keygenat run time. - File hashes. Every rule file’s SHA-256 must match
pack.json. Rule files must be plain names in the pack directory: no paths, no.., no symlinks, size-capped. - Schema. Every rule is validated against its kind (below). Strings that
reach your terminal or report may not contain control characters, ANSI
escapes or bidi overrides. Template paths must stay on the scan target.
min_pentheramust not be newer than your Penthera. - ReDoS guard. Every regex must pass
lib/rules/regex-guard.js(below).
--allow-unsigned-rules. That flag prints a
warning on every run. It skips only the signature check. Hash, schema and ReDoS
failures are refused with or without it.
Bundled packs are hash-checked and schema-checked on every start. A failure
there means the installation is damaged, and Penthera stops rather than run
with half its rules.
Format
pack.json (written by scripts/rules/build-pack.mjs, never by hand):
{ "kind": "<kind>", "rules": [ … ] }. Rules are data only:
regexes, string lists, IOC lists. A pack cannot contain code, and nothing in a
pack is ever evaluated.
Every rule carries:
Kinds
runtime-advisory and runtime-eol name a product from a fixed list (php,
apache-httpd, nginx, nodejs, python, openssl; detection for each is
code in lib/advisories/products.js). A branch is a release line as that
vendor numbers them: 8.3 for PHP, 22 for Node.js, 1.1.1 or 3.0 for
OpenSSL. Every fixed version must be an exact upstream release on its line.
An advisory with a condition (an OS, a SAPI, a module a banner cannot show)
never produces more than potential. A runtime-eol line counts as ended
only when its eol date is on or before the rule’s own date, so the result
depends on the pack, never on the clock of the machine that runs the scan. See
the methodology’s runtime advisories
section for how evidence and verdicts work.
Rules from packs run after the built-in rules, so a pack can add detections but
cannot shadow or reorder the built-in ones. IOC matching is literal (string
equality and indexOf), so IOC lists cannot be made slow. ioc-package matches
the lockfile, so it reports a bad version even when it is not installed.
The ReDoS guard
Packs scan hostile input: dependency source, agent configs, HTTP responses. A pack with a regex that backtracks exponentially could hang every scan that loads it. V8’s regex engine backtracks, and its linear-time mode refuses ordinary bounded repeats like{64}, so lib/rules/regex-guard.js checks the
pattern’s structure when the pack loads and refuses:
- patterns longer than 512 characters, or with flags other than
imsu; - backreferences and lookaround;
- nested quantifiers:
(a+)+,(\w*\s?)*; - alternation under an unbounded quantifier:
(a|aa)+; - bounded repeats above 256;
- an unbounded quantifier that every start position can reach and rescan
(
\s*x,[a-z]+@,x[^\n]*\|onxxxx…); - chains of quantifiers over overlapping characters (
.*b.*,\s+\S*\s*), unless all of them are bounded and their product stays under 1,024.
[^\n]{0,80}. Rule 6 took its current form after seeded fuzzing found
quadratic patterns that the narrower “no leading quantifier” version let
through. The fuzz test
(tests/rule-packs.test.js) generates random patterns and requires everything
the guard accepts to finish in under 250 ms on 10 KB of input built to make it
backtrack. In practice they finish in milliseconds. Quadratic patterns take
seconds. Regexes from packs added with --rules also see at most 1 MB of input
per match.
The older, narrower check for --templates YAML (v1.1.1) moved into the same
module as hasNestedQuantifier(). It used to be a regex, which CodeQL flagged as
polynomial on the pattern string it inspects. It is now a linear scan that
refuses everything the old regex refused, plus nesting it missed (((a+))+), so
existing user templates keep working.
Bundled packs
Writing and signing a pack
npm run rules:build runs the script; from an
installed copy it is node "$(npm root -g)/penthera/scripts/rules/build-pack.mjs".
build-pack.mjs hashes the rule files, writes pack.json, validates the
result with the loader’s own code, and deletes a pack.json.sig that no longer
matches. created_at only changes when a rule file changed.
Signing a release (maintainer)
Bundled packs must be signed before a release can be staged:- Review the change:
git diff lib/rules/. The signature vouches for every rule in the pack. - Sign each changed pack locally, never in CI:
scripts/rules/sign-pack.sh lib/rules/<name> [~/.ssh/key]. The script rebuildspack.json, signs it withssh-keygen -Y sign -n penthera-rules, and runspenthera rules verify. npm run rules:checkmust pass. The publish workflow runs it before staging.- Commit
pack.json.sigwith the release.
lib/rules/allowed_signers. Changing
that file changes Penthera’s trust root, and gets the same review as a change
to the verifier. To rotate keys, add the new key, re-sign the bundled packs, and
remove the old key in a later release. Packs other people signed with the old
key keep working until it is removed. A hardware-backed key (sk-ssh-ed25519)
is not supported by the verifier yet. Use ed25519 or RSA.