Stage 3 passed on throughput — 27,339 events, zero rescues, Caddy unmoved — and then the soak found what the load test could not. Two more false positives, both the same shape as the ones before: * EICAR matched an 8.5 MB rustc incremental-compilation cache, because the test source being compiled contains the literal. Hound moved it to quarantine mid-build and rustc panicked. The standard defines the EICAR file as exactly that 68-byte string, optionally padded to 128, so the rule now says filesize <= 128. * The webshell rule matched a 4.3 MB AI session transcript, because the conversation had been discussing webshells and therefore contained "<?php", the eval pattern and "$_POST". The transcript was moved to the vault and its history lost. A webshell is a PHP file: small, and opening with a PHP tag. Now filesize < 1MB and $php in (0..4096). The interesting part is why the second one happened at all. After the first, I added a test asserting that a large file containing rule strings is not a threat — and hand-listed the strings. I listed the miner's and the rootkit's and forgot "<?php". The test passed and the transcript was quarantined anyway. So the test now extracts every string literal from the rule pack itself and builds the haystack from those. A rule added tomorrow is covered without anybody remembering to cover it. It also asserts the extractor actually found the strings, because a parser that silently returns nothing would make the whole thing vacuous. Both fixes have a paired test that the detection still works: a real 68-byte EICAR file is caught, padded to 128 it is caught, and a real webshell is caught. Worth recording, because it is not a bug: six houndd tests failed while the gate was armed. Hound quarantined the EICAR fixtures the test suite had just written — correct behaviour, colliding with a suite that creates real malware samples. Running the antivirus's own tests on a gated machine needs thought; the tests are not wrong and neither is the gate. The definitions chain now works end to end: pack built from OSV, signed with the release key, published to /srv/houndav/defs, installed, and verified on load against the public half compiled into the agent — "defs: 19 indicators from 1 pack(s) [2026.08.21]". The public key is in the source on purpose. The agent is open source and anybody should be able to check that the definitions they received are the ones we published. 300 tests pass. Gate is off pending these fixes being soaked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
4.6 KiB
Text
124 lines
4.6 KiB
Text
/*
|
|
* Hound built-in starter pack.
|
|
*
|
|
* Deliberately tiny and deliberately tight. Every rule here requires
|
|
* several independent strings before it fires, because a false positive
|
|
* in an antivirus is worse than a miss — one rule that quarantines a
|
|
* system binary ends the product.
|
|
*
|
|
* The real corpus lands in Phase 3 (the signed Hound Linux pack, gated
|
|
* behind the goodware CI regression suite). This pack exists so a fresh
|
|
* install detects *something* before it has ever contacted the network.
|
|
*/
|
|
|
|
rule EICAR_Test_File
|
|
{
|
|
meta:
|
|
name = "EICAR-Test-Signature"
|
|
severity = "info"
|
|
desc = "Industry-standard antivirus test file. Harmless."
|
|
strings:
|
|
$eicar = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
|
|
condition:
|
|
// The standard defines the EICAR file as exactly this 68-byte
|
|
// string, optionally padded with whitespace to at most 128 bytes.
|
|
// Without the size bound this rule matches any file that merely
|
|
// CONTAINS the string — and on a live server it quarantined an
|
|
// 8.5 MB rustc incremental-compilation cache, because the test
|
|
// source being compiled contained the literal. That killed the
|
|
// build with a compiler panic.
|
|
//
|
|
// Anyone whose source, logs or documentation mention EICAR has
|
|
// the same problem, which is most security work.
|
|
filesize <= 128 and $eicar
|
|
}
|
|
|
|
rule Linux_Coinminer_XMRig
|
|
{
|
|
meta:
|
|
name = "Linux.Coinminer.XMRig"
|
|
severity = "critical"
|
|
desc = "XMRig cryptocurrency miner. Requires pool protocol plus two config keys."
|
|
strings:
|
|
$pool1 = "stratum+tcp://" ascii
|
|
$pool2 = "stratum+ssl://" ascii
|
|
$cfg1 = "donate-level" ascii
|
|
$cfg2 = "rig-id" ascii
|
|
$cfg3 = "randomx" ascii nocase
|
|
$name = "xmrig" ascii nocase
|
|
condition:
|
|
// ELF magic is not optional here.
|
|
//
|
|
// Without it this rule matches any TEXT that mentions mining:
|
|
// a blog post, a support ticket, a threat-intelligence report,
|
|
// or — as happened on a live server — an AI session transcript
|
|
// in which somebody was writing this very rule. Malware is a
|
|
// program; a document about malware is not.
|
|
uint32(0) == 0x464c457f
|
|
and ($pool1 or $pool2) and 2 of ($cfg*) and $name
|
|
}
|
|
|
|
rule Linux_Webshell_PHP_Eval
|
|
{
|
|
meta:
|
|
name = "Linux.Webshell.PHP-Eval"
|
|
severity = "critical"
|
|
desc = "PHP webshell: request-driven eval of decoded input."
|
|
strings:
|
|
$php = "<?php"
|
|
$eval1 = /eval\s*\(\s*(base64_decode|gzinflate|str_rot13|gzuncompress)\s*\(/
|
|
$src1 = "$_POST"
|
|
$src2 = "$_GET"
|
|
$src3 = "$_REQUEST"
|
|
$src4 = "$_COOKIE"
|
|
condition:
|
|
// A webshell is a PHP file: small, and opening with a PHP tag.
|
|
// Without those bounds this matched a 4.3 MB AI session
|
|
// transcript on a live server — the conversation happened to
|
|
// discuss webshells, so it contained "<?php", the eval pattern
|
|
// and "$_POST". The transcript was moved to quarantine and its
|
|
// history lost.
|
|
filesize < 1MB
|
|
and $php in (0..4096)
|
|
and $eval1
|
|
and 1 of ($src*)
|
|
}
|
|
|
|
rule Linux_Rootkit_Preload
|
|
{
|
|
meta:
|
|
name = "Linux.Rootkit.Preload"
|
|
severity = "critical"
|
|
desc = "LD_PRELOAD userland rootkit: hooks libc lookup calls and hides itself."
|
|
strings:
|
|
$dlsym = "dlsym" ascii
|
|
$libc = "RTLD_NEXT" ascii
|
|
$hook1 = "readdir64" ascii
|
|
$hook2 = "readdir" ascii
|
|
$hook3 = "lxstat" ascii
|
|
$hook4 = "fopen" ascii
|
|
$hide1 = "ld.so.preload" ascii
|
|
$hide2 = "/proc/net/tcp" ascii
|
|
condition:
|
|
uint32(0) == 0x464c457f // ELF magic
|
|
and $dlsym and $libc
|
|
and 2 of ($hook*)
|
|
and 1 of ($hide*)
|
|
}
|
|
|
|
/*
|
|
* REMOVED: Linux_Backdoor_ReverseShell_ELF
|
|
*
|
|
* It required an ELF containing "/bin/sh" plus four of
|
|
* {dup2, socket, connect, inet_addr, execve}. That is a perfect
|
|
* description of a reverse shell and also a perfect description of
|
|
* /usr/bin/sudo, which the goodware test caught immediately. Any
|
|
* dynamically linked network-capable binary imports those symbols
|
|
* legitimately, so no threshold tweak saves this rule — it would only
|
|
* move the false positive to a different binary on a different distro.
|
|
*
|
|
* Catching reverse shells properly needs either ELF structure (statically
|
|
* linked, tiny, no libc) or the behaviour itself, which is Phase 7's job.
|
|
* Left out rather than shipped loose: a rule that quarantines sudo is
|
|
* worse than no rule at all.
|
|
*/
|