Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 identifier
  • severity - critical, high, or warning
  • category - webshell, backdoor, phishing, dropper, exploit
  • file_types - file extensions to match (or ["*"] for all)
  • patterns - case-insensitive literal strings
  • regexes - regex patterns, compiled case-insensitively
  • exclude_patterns - literal patterns that suppress a match (false positive reduction)
  • exclude_regexes - regex patterns that suppress a match
  • min_match - minimum total number of matching literal and regex entries; each entry counts once
  • require_regex - require at least one regex among the matches counted toward min_match
  • max_file_bytes - skip this rule when the complete scanned file is larger than the byte limit; omitted or 0 is unbounded
  • max_file_bytes_exempt_regexes - high-confidence regexes that let the rule continue normal evaluation above max_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 caseExamples to restore
Constructed parameter listsConcatenation, 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 parametersComments in array indices or helper arguments, especially those containing closing delimiters or body-source names
Interpolated parameter operandsDouble-quoted array keys or helper operands containing unescaped dollars, including interpolation with nested quoted keys
Comments between argumentsBlock comments with embedded commas, and line comments before the body source
Constructed body expressionsGrouped concatenation, parenthesized decoders and trim(base64_decode($payload)); a single literal concatenated onto request input is covered
Interpolated bodiesA double-quoted body such as "return {$_POST['code']};", or a concatenated double-quoted prefix containing unescaped dollars
Literal executable bodieseval($x) or string-capable assert($x), with statements, strings or comments before them; both outer quote styles and escaped quotes
Literal expression contextsreturn, or, do, case, include, include_once, require, require_once, clone, yield from, comparisons, shifts and inequality before an execution sink
Literal lexical edgesGlobal assert, comment backslashes before * or */, and quoted operands before a comparison
Outer call contextsCalls 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

TierRulesSizeFalse Positive Risk
core~5,0001.6 MBLow (quality >= 70, score >= 65)
extended~10,5003.3 MBMedium
full~11,6003.7 MBHigher (includes score >= 40)

Update Flow

  1. CSM resolves the latest YARA Forge version from the mirror pointer, falling back to GitHub only when no pointer is published
  2. If different from the installed version, downloads the ZIP for the configured tier from yara_forge.download_url and its detached signature
  3. Verifies the download against signatures.signing_key
  4. Filters out any rules listed in disabled_rules
  5. Compile-tests the rules with YARA-X before installing
  6. Atomically replaces the previous Forge rules file
  7. 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"