Signature Rules
CSM uses YAML rules for real-time scanning and finding re-checks. Optional
YARA-X rules also run during deep scans and email attachment scanning. Rules
are stored in /opt/csm/rules/. An engine that loads zero rules (a mistyped
signatures.rules_dir, an empty rule sync) raises a realtime_rules_missing
finding at startup and after every reload, because both engines otherwise
treat an empty directory as a successful load and scan every write against
nothing.
Deep scans are rolling: each scheduled run resumes from a persisted cursor and scans as much as fits in its time budget, so the whole content set is covered across runs even when a single run cannot finish it. A warning finding is raised if no full pass has completed within 30 days.
The same rolling walk also feeds the JavaScript keystroke taint analyzer (js_keylogger_dataflow) and the PHP remote-source taint analyzer (php_remote_taint); see the deep checks reference for both. Each consumer keeps its own cursor and completion record, so none of them stalls the others: a missing or failed YARA backend does not hold up either taint analyzer, and the PHP analyzer being unavailable does not affect YARA or JavaScript coverage.
A rule declaring file_types: [".php"] is applied to every extension a stock PHP handler executes (.php2 through .php8, .phtml, .pht) and to .phps, because that source-view extension still contains PHP source. .phps stays outside the set of extensions CSM treats as executable, so this does not change which files the real-time dropper tracker considers runnable.
Both engines skip ZIP, gzip, bzip2, xz, 7z, and RAR containers, but only when the file name carries an archive extension as well as the archive signature. Matching compressed bytes or stored filenames produces false positives without inspecting the archived file, so real containers are scanned when they are extracted onto monitored storage instead. A file whose name a web server would execute is always scanned whatever its first bytes look like, because PHP echoes any leading bytes and runs the rest. Uncompressed tar files and executable PHP archives (PHAR) remain scannable, and filename-based phishing-kit archive detection is unchanged.
YAML Rules
The WordPress REST API exploit signature is a YAML-only heuristic. It requires a users-endpoint URL literal and a password query parameter or a nearby PHP, JSON or form-encoded password field, including payloads prepared before the URL. Endpoint prose alone does not qualify. This is bounded textual evidence, not PHP data-flow analysis or proof that a request is unauthorized.
rules:
- name: webshell_c99
severity: critical
category: webshell
file_types: [".php"]
patterns: ["c99shell", "c99_buff_prepare"]
min_match: 1
- name: phishing_login
severity: high
category: phishing
file_types: [".html", ".php"]
patterns: ["password.*submit", "credit.*card.*number"]
exclude_patterns: ["legitimate_form_handler"]
min_match: 2
Fields:
name- unique rule identifierseverity- critical, high, or warningcategory- webshell, backdoor, phishing, dropper, exploitfile_types- file extensions to match (or["*"]for all)patterns- case-insensitive literal stringsregexes- regex patterns, compiled case-insensitivelyexclude_patterns- literal patterns that suppress a match (false positive reduction)exclude_regexes- regex patterns that suppress a matchmin_match- minimum total number of matching literal and regex entries; each entry counts oncerequire_regex- require at least one regex among the matches counted towardmin_matchmax_file_bytes- skip this rule when the complete scanned file is larger than the byte limit; omitted or0is unboundedmax_file_bytes_exempt_regexes- high-confidence regexes that let the rule continue normal evaluation abovemax_file_bytes
A regex only runs on a file that contains the fixed text every match of it
must contain, compared without regard to case: for eval\s*\(\s*base64_decode
that is both eval and base64_decode. Write regexes around distinctive words
such as function names: a regex with no fixed text of two or more characters
runs over every file of its types, and realtime pays that cost on each write.
Required text can include escaped bytes such as \x00; these remain part of
the literal when checking whether a file could match.
When a regex includes a literal listed in patterns, the same content can
satisfy both entries. Use independent entries when a rule needs multiple pieces
of evidence. The bundled HTTP tunnel rule requires both socket creation and a
CONNECT request. The legacy PHP callback rule uses the same narrow signature in
YAML and YARA-X: a direct function call with a quoted parameter list, a variable,
null, a simple array lookup or a short helper call as its first argument.
The second argument is a decoder or request lookup, optionally preceded by one
concatenated literal. Quoted lists can contain commas, semicolons and escaped
quotes. Array indices accept a single quoted key or an unquoted scalar. Helper
arguments accept at most one literal among unquoted scalar operands, including
implode(',', $args). These bounded forms consume quoted operands whole and
exclude comments, interpolation and nested expressions, so delimiters inside
data cannot supply the body-source evidence. Double-quoted array keys, helper
literals and body prefixes must escape dollar signs; unescaped dollars require
interpolation analysis.
Shared positive and benign fixtures check both engines. Generated socket and
funchand wrappers and ordinary legacy callbacks stay silent under these rules.
The PHP goto-obfuscation rule requires three independent signals in both engines: a PHP opening tag, at least nine jumps to digit-bearing generated labels or eleven to alphabetic labels, and a decode call, execution call, dynamic include, or request input. Fixed-path bootstrap includes do not supply this evidence. Variable and array callback calls count even when the function name is constructed and the argument is a literal. Comments between a callable and its opening parenthesis do not hide the call. Line breaks and keyword case do not change the label counts. Long encoded strings and data URIs alone are not execution evidence. These are source-text heuristics, not PHP dataflow analysis; they cannot resolve arbitrary dynamically generated code or distinguish every benign use of these operations.
The PHP content heuristic that runs during scans applies the same evidence
rule: generated goto labels and descriptive goto labels both need a decode
call, execution call, dynamic include, or request input before they count as
an obfuscation indicator. call_user_func is deliberately not evidence in
either place, because plugin loaders dispatch their own callables through it.
The content heuristic uses the signature’s evidence expression, including
comment-separated calls, grouped and array callbacks, and case-sensitive PHP
superglobal names. A regression check guards against expression drift.
Legacy callback parser follow-up
The callback signature does not inspect quoted function bodies. Doing so needs
PHP string decoding, tokenization and expression analysis: for example,
assert($x > 0) is an ordinary boolean check, and "eval($x)" can be data.
The rules scan source text, so they do not promise general PHP comment or string
awareness, nor complete coverage of dynamically generated code.
The following cases were covered by the expanded regex and are deliberately outside the narrowed signature. They remain acceptance cases for a parser follow-up, not claims of current detection by this rule:
| Deferred case | Examples to restore |
|---|---|
| Constructed parameter lists | Concatenation, nested array indices, helper calls with multiple quoted operands, and chr(100/(1+1)) as the first argument; only the bounded simple forms above are covered |
| Comments inside constructed parameters | Comments in array indices or helper arguments, especially those containing closing delimiters or body-source names |
| Interpolated parameter operands | Double-quoted array keys or helper operands containing unescaped dollars, including interpolation with nested quoted keys |
| Comments between arguments | Block comments with embedded commas, and line comments before the body source |
| Constructed body expressions | Grouped concatenation, parenthesized decoders and trim(base64_decode($payload)); a single literal concatenated onto request input is covered |
| Interpolated bodies | A double-quoted body such as "return {$_POST['code']};", or a concatenated double-quoted prefix containing unescaped dollars |
| Literal executable bodies | eval($x) or string-capable assert($x), with statements, strings or comments before them; both outer quote styles and escaped quotes |
| Literal expression contexts | return, or, do, case, include, include_once, require, require_once, clone, yield from, comparisons, shifts and inequality before an execution sink |
| Literal lexical edges | Global assert, comment backslashes before * or */, and quoted operands before a comparison |
| Outer call contexts | Calls immediately following a ternary colon, case-label colon or comparison operator |
This work belongs in internal/phptaint, which already uses VKCOM/php-parser
and records the second argument of create_function as a sink. Extend that
analysis to decode and parse statically known callback bodies under its existing
budgets, distinguish boolean assertions from string execution, and report
unresolved dynamic bodies as analysis gaps. It currently feeds a separate
scheduled check; it is not a post-filter for YAML or YARA. Realtime coverage
would need explicit integration and latency tests, and standalone YARA would
still have the narrower coverage.
Acceptance requires restoring the deferred cases as positive parser fixtures, retaining the shared benign fixtures, checking both quote styles and comment forms, and passing the clean-corpus gate without new baseline entries. Do not expand another regex into a PHP tokenizer to recover these cases.
YARA-X Rules (Optional)
Build CSM with YARA-X support:
CGO_LDFLAGS="$(pkg-config --libs --static yara_x_capi)" go build -tags yara ./cmd/csm/
The rules directory and rule files must be owned by root or the scanner user and
must not be group- or world-writable. The standard service and its YARA worker
run as root; the installer uses 0750 for the directory and 0640 for shipped
rules. Custom rules must also remain readable by the scanner.
Place .yar or .yara files alongside YAML rules in /opt/csm/rules/. CSM compiles them at startup and uses them for:
- Real-time fanotify file scanning
- Scheduled deep-scan filesystem sweeps
- Email attachment scanning
Scans hand every file to YARA-X regardless of its name, so a rule may decide on the leading bytes rather than the extension. The bundled rule for PHP carried inside an image does exactly that: it requires a genuine PNG, JPEG, GIF, WebP, ICO, BMP or TIFF container, a PHP opening tag, and an execution or remote-fetch construct beside it. A picture is a working payload store, and nothing about its name says so.
Its other half is the loader: a PHP file that includes or requires a
non-executable file while reading request input. The extension list covers
images, archives and opaque data files; source partials such as .html,
.tpl, .txt and .svg are deliberately absent, because including those
can be ordinary templating. Literal targets must end the include statement;
an image named later in a rendered HTML expression is not an include target.
Encoded targets remain suspicious regardless of the hidden extension.
Findings include up to five distinct non-executable path literals near an
include or require. These are candidate payload references: proximity does
not prove variable identity, and concatenated paths may be partial. Extraction
advances through the content once and stops when the output cap is reached.
Scheduled YARA sweeps scan regular, non-empty files under configured
account_roots, or cPanel /home/*/public_html roots when no override is
configured. Files larger than thresholds.full_scan_max_file_mb, symlinks,
and special files are skipped. An unreadable or oversized file, or a scanner
backend error, emits yara_scan_incomplete and preserves findings from the
previous complete sweep. Scheduled findings and real-time fanotify findings
have separate ownership, so one path cannot purge the other’s results.
A real-time scan that cannot inspect a changed file emits
yara_realtime_scan_error instead, so a scanning outage stays separable from
the scheduled coverage report. The dashboard’s Components matrix shows this
finding as Fanotify’s last event; scheduled coverage reports do not advance
that event time. This error finding is suppressed while the file monitor is
stopping: the YARA backend is stopped before the file monitor has finished
draining, and a clean restart is not an outage.
Without the yara build tag, YARA rules are not loaded or evaluated.
Updating Rules
Before merging bundled YARA changes, run the rules against an unpacked corpus of clean WordPress core and plugin files:
YARA_FP_CORPUS=/path/to/corpus go test -tags yara ./internal/yara/ -run TestRepositoryRulesAgainstCleanCorpus -v
The same corpus measures the YAML rules that only realtime and finding re-check run:
YARA_FP_CORPUS=/path/to/corpus go test ./internal/signatures/ -run TestRepositoryYAMLRulesAgainstCleanCorpus -v
The same corpus also checks that no YAML regex is skipped on a file it matches:
YARA_FP_CORPUS=/path/to/corpus go test ./internal/signatures/ -run TestYAMLGatesSoundOnCleanCorpus -v
Both gates require at least 5,000 non-empty files within the default scheduled scan size limit. Rule-load, traversal, and read failures fail the relevant run instead of counting as clean; YARA backend errors do too.
The measured YARA baseline is empty. The YAML baseline records rules that already fire on clean plugin and core code and are named in the realtime-rule porting backlog. Tighten a noisy rule rather than excluding paths or filenames.
csm update-rules # download latest rules and reload the running daemon
csm update-rules now asks the daemon to reload through the control socket once the download completes. If the daemon is not running, the next start picks the files up automatically. kill -HUP $(pidof csm) still works.
Or from the web UI: Rules page > Reload Rules button.
Remote rule updates are now signature-verified. Any configuration that enables signatures.update_url or signatures.yara_forge.enabled must also set signatures.signing_key to the 64-character hex-encoded Ed25519 public key that verifies the downloaded .sig files. Both URLs must use https; a plain-http URL fails validation.
A valid signature proves who published a rules file, not that it is the current one. The YAML updater therefore also refuses a download whose version is lower than the installed file’s, or that carries fewer than half as many rules, and keeps the installed rules. The daemon emits a Critical finding for either refusal so a stale mirror or a replayed old release is visible instead of silently stripping detection. A missing or unparsable installed file is not compared, so a signed update remains the way to recover from a corrupt rules file.
For an intentional reduction below half the installed YAML rule count, set signatures.allow_rule_count_decrease: true, restart CSM, and run the signed update. Then return the setting to false and restart CSM again; this exception never permits a version downgrade.
Remote update URLs must use https and must not point at localhost, loopback, link-local, unspecified, or RFC1918 / ULA private addresses.
YARA Forge Integration
CSM can automatically fetch curated YARA rules from YARA Forge, which aggregates and quality-tests rules from 40+ public sources including signature-base, Elastic, Malpedia, and ESET.
Configuration
signatures:
signing_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
yara_forge:
enabled: true
tier: "core" # core (5K rules, low FP), extended (10K), full (12K)
update_interval: "168h" # weekly
download_url: "https://mirrors.pidginhost.com/csm/yara-forge/{version}/yara-forge-rules-{tier}.zip"
disabled_rules: # rule names to switch off, in Forge and in the shipped rules
- SUSP_Example_Rule
The project operates the signed mirror shown above. A ready-to-use drop-in is shipped at /usr/lib/csm/profiles/yara-forge.example.yaml; copy or include it under /etc/csm/conf.d/ to enable Forge without editing the main csm.yaml. The matching signing_key is the project Ed25519 public key (hex), published on the release signing page.
signing_key must be a hex string for the Ed25519 public key that matches the private key used to sign the remote Forge artifact. It is not a PEM block and not a file path.
YARA Forge’s upstream GitHub releases publish ZIP files, but not CSM detached signatures. CSM therefore requires yara_forge.download_url to point at a mirror you operate. The URL may contain {tier} and {version} placeholders. The detached signature must be available at the resolved ZIP URL plus .sig.
When the URL has a standalone {version} path segment, CSM first reads a plain-text latest pointer from that version directory’s parent, for example https://mirrors.pidginhost.com/csm/yara-forge/latest. If the pointer is not published, CSM falls back to the upstream GitHub release tag. Mirror network errors and server errors fail the update instead of falling back, because GitHub may name a release the mirror has not signed yet. CSM accepts only short tag tokens from the pointer; the ZIP signature still gates installation.
If you do not have a signed update source yet, disable remote updates instead:
signatures:
signing_key: ""
update_url: ""
yara_forge:
enabled: false
Tiers
| Tier | Rules | Size | False Positive Risk |
|---|---|---|---|
| core | ~5,000 | 1.6 MB | Low (quality >= 70, score >= 65) |
| extended | ~10,500 | 3.3 MB | Medium |
| full | ~11,600 | 3.7 MB | Higher (includes score >= 40) |
Update Flow
- CSM resolves the latest YARA Forge version from the mirror pointer, falling back to GitHub only when no pointer is published
- If different from the installed version, downloads the ZIP for the configured tier from
yara_forge.download_urland its detached signature - Verifies the download against
signatures.signing_key - Filters out any rules listed in
disabled_rules - Compile-tests the rules with YARA-X before installing
- Atomically replaces the previous Forge rules file
- Reloads the YARA scanner
Custom rules in malware.yar are never overwritten by the Forge fetcher.
Disabling Rules
If a rule produces false positives, add its name to disabled_rules in the config and restart the daemon:
signatures:
disabled_rules:
- SUSP_XOR_Encoded_URL
- php_goto_obfuscation
The list covers every rule CSM loads, not only YARA Forge: Forge rules are
stripped from the download, rules shipped in malware.yml are skipped when
the real-time engine loads them, and rules shipped in malware.yar are
stripped before the scheduled engine compiles them. The self-test measures
the ruleset that is left, so disabling a rule shows up as the coverage it
costs.
Names are matched in full, ignoring surrounding whitespace and letter case; prefixes and substrings do not match. Removing a YARA rule preserves neighboring rules, including when declarations share a line or literals contain braces. The YARA worker receives the daemon’s effective disabled list and configuration paths; rule reloads and worker crash recovery retain that list.
A valid ruleset whose rules are all disabled loads as an empty set, including on reload; stale rules are not retained. The self-test reports the resulting misses. An empty or invalid replacement still reports a load error.
csm validate warns about a name that matches no rule, because a typo here
otherwise reads as “that rule is off” while the rule keeps firing. Disabling
a rule is a last resort and a standing gap in coverage; prefer fixing the
rule.
Validation recognizes a disabled YAML rule even if its regular expression is invalid, and counts repeated names only once.
Signature settings require a daemon restart. SIGHUP does not apply a changed disabled list; rule-file reloads keep the current list.
How Rules Avoid False Positives
Signature rules require structural nesting, not co-presence of strings. Two dangerous function calls appearing in the same file but in unrelated code paths won’t trigger a rule. The call must directly wrap or chain with the other for a match.
YARA-X rules cannot express nesting, so multi-string rules state how their evidence is related. A bounded distance is used when closeness is part of the malicious shape. When valid malware can carry padding between its signals, PHP diagnostic records are kept as separate contexts so one record cannot borrow evidence from another. Anchoring works the same way: a CGI webshell rule requires its shebang at offset 0, because that is where the web server needs it. None of these controls refers to a file’s name or path.
Realtime signature auto-quarantine adds a safety gate: only webshell and dropper matches are eligible, and the file must be at least 512 bytes and either have Shannon entropy >= 5.5 or hex density > 20% plus an obfuscated-execution signal. Legitimate plugin code (well below 5.5 entropy) passes through; obfuscated malware (5.8+) is caught.
Alert Rate Limiting
Default: 30 operator alert dispatches/hour (configurable via max_per_hour). CRITICAL findings and threat-intel reputation sightings always get through by email or generic webhook regardless of the rate limit, and they do not count against it. Other lower-severity alerts are rate-limited, including when they are batched with a CRITICAL finding: once the budget is spent, only the urgent findings in that batch are sent.
A dispatch uses one slot only when email or a generic webhook successfully delivers routine findings. If only urgent findings reach a channel and routine delivery fails, the slot remains available for a later dispatch. Email-disabled findings do not reserve a slot unless a generic webhook will carry them. The phpanel webhook stream bypasses this budget.
Suppressions
Create suppression rules to silence known false positives:
- From the Findings page: click the suppress button on any finding. The path pattern is a glob; the dialog pre-fills the finding’s own file with
[,],*,?and\escaped, so the rule matches that file only - From the Findings page with several findings selected: bulk Suppress creates one rule per selected file
- From the Rules page: manage suppression rules directly
- Via API:
POST /api/v1/suppressions
A suppression rule hides matching findings from the Findings page, stops their email and webhook alerts, and stops file, process and account remediation for them. It does not stop IP blocking, challenge routing or attack scoring: a rule that mutes a whole check would otherwise leave every attacker that check reports unblocked. To exempt an address that was blocked by mistake, whitelist it on the Firewall page under Allow Rules or with csm firewall allow.
Incident auto-blocking, credential-spray containment and central threat intelligence also use suppressed findings. Database response may still block suspicious session IPs when enabled, but a suppressed database finding cannot trigger cleanup or session revocation. IP action notifications are separate findings; suppressing their check type mutes those notifications without stopping the action. These rules apply to startup, scheduled and real-time scans, control-socket runs with alerts enabled, and replay after restart.
To suppress email alerts for specific checks while keeping them visible in the web UI, use disabled_checks in your config:
alerts:
email:
disabled_checks:
- "email_spam_outbreak"
- "perf_memory"