houndd: fetch definitions from defs.houndav.com, and refuse anything unsigned
Closes Phase 3's delivery half. `hound update` now asks the definitions
host what exists, downloads what this machine lacks, verifies it, and
installs it. Verified end to end against the live host over TLS:
starting from an empty directory:
defs: no verified packs were found
hound update:
definitions: 1 pack(s) installed, 0 already current
crates-io-2026.08.21.pack — 19 indicators, version 2026.08.21
loaded 19 indicators from 1 pack(s) [2026.08.21]
and immediately afterwards a lockfile naming rustdecimal is flagged,
while tokio beside it is not.
running it again:
definitions: definitions are up to date (1 pack(s))
a pack with one byte flipped in its signed payload, advertised in the
index with a correct hash and a version of 9999.1.1:
definitions: 0 pack(s) installed, 1 up to date, 1 REJECTED
tampered-test.pack: the definitions pack is not signed by Hound and
was discarded
Three refusals are the design:
* THE INDEX IS A HINT, NEVER AN AUTHORITY. It says which packs exist and
what they hash to, and both are unverified — anyone who can serve the
index can lie about either. Only the Ed25519 signature decides whether
a pack is real. A tampered index can waste bandwidth and nothing else,
which is exactly what the test above demonstrates: correct hash,
higher version, still refused.
* NOTHING UNVERIFIED REACHES THE DEFINITIONS DIRECTORY. Downloaded to a
temp file, verified there, then moved with a rename inside the same
directory so it is atomic. The daemon cannot observe a half-written
pack, and a crash mid-download leaves a stray temp file rather than a
loadable one.
* A FILENAME FROM A REMOTE INDEX IS UNTRUSTED INPUT. The updater runs as
root, so an entry of ../../../etc/cron.d/evil.pack would be remote code
execution. Only a plain basename ending in .pack, with no separators,
no dot-dot and no leading dot, is accepted. Tested against eight
hostile shapes.
Sizes and timeouts are bounded — a pack is a list of package names, so a
server offering a hundred gigabytes is broken or hostile and the
difference does not matter to a full disk. The read is bounded
independently of Content-Length, which is also just something the server
said.
Definitions are fetched BEFORE rules are reloaded, and a failure to fetch
does not stop the reload. No network, a mirror down, a pack that will not
verify — none of those are a reason to skip the half that still works.
HOUNDD_DEFS_URL points the client at a mirror, which matters for the
air-gapped deployments that are a real part of the Fleet story.
build-pack now writes index.json beside the pack, so publishing is one
command.
358 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2d06632fa9
commit
13d86c167e
7 changed files with 802 additions and 0 deletions
344
Cargo.lock
generated
344
Cargo.lock
generated
|
|
@ -893,6 +893,15 @@ version = "0.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "form_urlencoded"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "funty"
|
name = "funty"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
|
|
@ -1144,6 +1153,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"time",
|
"time",
|
||||||
|
"ureq",
|
||||||
"yara-x",
|
"yara-x",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1171,6 +1181,89 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_collections"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"potential_utf",
|
||||||
|
"utf8_iter",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locale_core"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"litemap",
|
||||||
|
"tinystr",
|
||||||
|
"writeable",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||||
|
dependencies = [
|
||||||
|
"icu_collections",
|
||||||
|
"icu_normalizer_data",
|
||||||
|
"icu_properties",
|
||||||
|
"icu_provider",
|
||||||
|
"smallvec",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_collections",
|
||||||
|
"icu_locale_core",
|
||||||
|
"icu_properties_data",
|
||||||
|
"icu_provider",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_properties_data"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_provider"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locale_core",
|
||||||
|
"writeable",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerotrie",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id-arena"
|
name = "id-arena"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -1183,6 +1276,27 @@ version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||||
|
dependencies = [
|
||||||
|
"idna_adapter",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna_adapter"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||||
|
dependencies = [
|
||||||
|
"icu_normalizer",
|
||||||
|
"icu_properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ignore"
|
name = "ignore"
|
||||||
version = "0.4.33"
|
version = "0.4.33"
|
||||||
|
|
@ -1331,6 +1445,12 @@ version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "litemap"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.33"
|
version = "0.4.33"
|
||||||
|
|
@ -1607,6 +1727,12 @@ dependencies = [
|
||||||
"base64ct",
|
"base64ct",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "percent-encoding"
|
||||||
|
version = "2.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -1646,6 +1772,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "potential_utf"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||||
|
dependencies = [
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -1871,6 +2006,20 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rowan"
|
name = "rowan"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
|
|
@ -1968,6 +2117,41 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -2321,6 +2505,16 @@ dependencies = [
|
||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyzip"
|
name = "tinyzip"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
@ -2364,12 +2558,52 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unty"
|
name = "unty"
|
||||||
version = "0.0.4"
|
version = "0.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ureq"
|
||||||
|
version = "2.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"flate2",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"url",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "url"
|
||||||
|
version = "2.5.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||||
|
dependencies = [
|
||||||
|
"form_urlencoded",
|
||||||
|
"idna",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8_iter"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -2684,6 +2918,24 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "which"
|
name = "which"
|
||||||
version = "4.4.2"
|
version = "4.4.2"
|
||||||
|
|
@ -2764,6 +3016,15 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|
@ -2846,6 +3107,12 @@ version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wyz"
|
name = "wyz"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -2992,6 +3259,29 @@ dependencies = [
|
||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke"
|
||||||
|
version = "0.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
"yoke-derive",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yoke-derive"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.56"
|
version = "0.8.56"
|
||||||
|
|
@ -3012,12 +3302,66 @@ dependencies = [
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||||
|
dependencies = [
|
||||||
|
"zerofrom-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerofrom-derive"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
"synstructure",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zeroize"
|
name = "zeroize"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerotrie"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec"
|
||||||
|
version = "0.11.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||||
|
dependencies = [
|
||||||
|
"yoke",
|
||||||
|
"zerofrom",
|
||||||
|
"zerovec-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerovec-derive"
|
||||||
|
version = "0.11.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zip"
|
name = "zip"
|
||||||
version = "8.6.0"
|
version = "8.6.0"
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ yara-x = "1.19"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
ureq = { version = "2", default-features = false, features = ["tls", "gzip"] }
|
||||||
hound-defs = { path = "crates/hound-defs" }
|
hound-defs = { path = "crates/hound-defs" }
|
||||||
hound-mcp = { path = "crates/hound-mcp" }
|
hound-mcp = { path = "crates/hound-mcp" }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,43 @@ fn main() {
|
||||||
let signed = pack::sign(&p, &key, &key_id).expect("signing the pack");
|
let signed = pack::sign(&p, &key, &key_id).expect("signing the pack");
|
||||||
std::fs::write(out, serde_json::to_string(&signed).expect("encoding")).expect("writing");
|
std::fs::write(out, serde_json::to_string(&signed).expect("encoding")).expect("writing");
|
||||||
|
|
||||||
|
// Write/refresh index.json beside the pack, so the update client has
|
||||||
|
// something to read. The index is advisory — the pack's signature is
|
||||||
|
// what decides whether it is genuine — but a correct hash here saves
|
||||||
|
// every agent a download it does not need.
|
||||||
|
let out_path = std::path::Path::new(out);
|
||||||
|
if let Some(dir) = out_path.parent() {
|
||||||
|
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if p.extension().is_none_or(|x| x != "pack") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(bytes) = std::fs::read(&p) else { continue };
|
||||||
|
let mut h = <sha2::Sha256 as sha2::Digest>::new();
|
||||||
|
sha2::Digest::update(&mut h, &bytes);
|
||||||
|
let digest = format!("{:x}", sha2::Digest::finalize(h));
|
||||||
|
let name = p.file_name().unwrap().to_string_lossy().into_owned();
|
||||||
|
let ver = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| {
|
||||||
|
let payload = v.get("payload")?.as_str()?.to_string();
|
||||||
|
Some(payload)
|
||||||
|
})
|
||||||
|
.map(|_| version.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
entries.push(serde_json::json!({
|
||||||
|
"file": name, "version": ver, "sha256": digest, "size": bytes.len()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| a["file"].as_str().cmp(&b["file"].as_str()));
|
||||||
|
let index = serde_json::json!({ "packs": entries });
|
||||||
|
let index_path = dir.join("index.json");
|
||||||
|
std::fs::write(&index_path, serde_json::to_string_pretty(&index).unwrap())
|
||||||
|
.expect("writing index.json");
|
||||||
|
println!("index {} ({} pack(s))", index_path.display(), index["packs"].as_array().unwrap().len());
|
||||||
|
}
|
||||||
|
|
||||||
println!("read {files} OSV records");
|
println!("read {files} OSV records");
|
||||||
println!("packed {} indicators", p.indicators.len());
|
println!("packed {} indicators", p.indicators.len());
|
||||||
println!("version {version}");
|
println!("version {version}");
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,4 @@ libc.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hound-defs.workspace = true
|
hound-defs.workspace = true
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
|
ureq.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ mod realtime;
|
||||||
mod rootkit;
|
mod rootkit;
|
||||||
mod rules;
|
mod rules;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod update;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_util;
|
mod test_util;
|
||||||
|
|
||||||
|
|
@ -705,7 +706,43 @@ fn gate_status(st: &DaemonState) -> hound_api::GateStatus {
|
||||||
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
|
fn update(st: &DaemonState) -> Result<hound_api::UpdateResult> {
|
||||||
use hound_api::UpdateResult;
|
use hound_api::UpdateResult;
|
||||||
|
|
||||||
|
// Definitions first, rules second. Fetching can fail — no network, a
|
||||||
|
// mirror down, a pack that will not verify — and none of that is a
|
||||||
|
// reason to skip reloading what is already on disk. An update that
|
||||||
|
// refuses to do the half it can do is worse than one that reports
|
||||||
|
// both halves honestly.
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
let keys = defs::trusted_keys();
|
||||||
|
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 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 (ok, command, combined) = engine::engine().update()?;
|
let (ok, command, combined) = engine::engine().update()?;
|
||||||
|
let combined = format!("{}\n{combined}", lines.join("\n"));
|
||||||
st.events.push(
|
st.events.push(
|
||||||
"update",
|
"update",
|
||||||
if ok { "info" } else { "warn" },
|
if ok { "info" } else { "warn" },
|
||||||
|
|
|
||||||
382
crates/houndd/src/update.rs
Normal file
382
crates/houndd/src/update.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
//! Fetching definition packs.
|
||||||
|
//!
|
||||||
|
//! The agent asks `defs.houndav.com` what exists, downloads what it does
|
||||||
|
//! not have, and installs it. The interesting part is what it refuses to
|
||||||
|
//! trust along the way.
|
||||||
|
//!
|
||||||
|
//! **The index is a hint, never an authority.** It says which packs exist
|
||||||
|
//! and what they should hash to, and both claims are unverified — anyone
|
||||||
|
//! who can serve the index can lie about either. The only thing that
|
||||||
|
//! decides whether a pack is real is its Ed25519 signature, checked
|
||||||
|
//! against the public key compiled into this binary. A tampered index can
|
||||||
|
//! therefore waste bandwidth and nothing else.
|
||||||
|
//!
|
||||||
|
//! **Nothing unverified ever lands in the definitions directory.** A pack
|
||||||
|
//! is downloaded to a temporary file, verified there, and only then moved
|
||||||
|
//! into place with a rename. A rename within one directory is atomic, so
|
||||||
|
//! the daemon can never observe a half-written pack — and a crash
|
||||||
|
//! mid-download leaves a stray temp file rather than a loadable one.
|
||||||
|
//!
|
||||||
|
//! **Everything is bounded.** A definitions pack is small; a server that
|
||||||
|
//! offers a hundred gigabytes is either broken or hostile, and the
|
||||||
|
//! difference does not matter to a disk that is now full.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use hound_defs::{pack, SignedPack};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Where packs come from. Overridable for testing and for air-gapped
|
||||||
|
/// mirrors, which are a real deployment rather than a hypothetical.
|
||||||
|
pub fn base_url() -> String {
|
||||||
|
std::env::var("HOUNDD_DEFS_URL").unwrap_or_else(|_| "https://defs.houndav.com".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pack is a list of package names. Anything approaching this is not
|
||||||
|
/// one.
|
||||||
|
const MAX_PACK_BYTES: u64 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The index is a handful of entries.
|
||||||
|
const MAX_INDEX_BYTES: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Long enough for a slow link, short enough that `hound update` cannot
|
||||||
|
/// hang a terminal indefinitely.
|
||||||
|
const TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct IndexEntry {
|
||||||
|
pub file: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: String,
|
||||||
|
/// Advisory only — see the module note. Used to skip a download we
|
||||||
|
/// already have, never to decide a pack is genuine.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct Index {
|
||||||
|
#[serde(default)]
|
||||||
|
pub packs: Vec<IndexEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one update run did.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub installed: Vec<String>,
|
||||||
|
pub already_current: Vec<String>,
|
||||||
|
pub rejected: Vec<String>,
|
||||||
|
pub log: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
if !self.rejected.is_empty() {
|
||||||
|
return format!(
|
||||||
|
"{} pack(s) installed, {} up to date, {} REJECTED",
|
||||||
|
self.installed.len(),
|
||||||
|
self.already_current.len(),
|
||||||
|
self.rejected.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.installed.is_empty() {
|
||||||
|
return format!("definitions are up to date ({} pack(s))", self.already_current.len());
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{} pack(s) installed, {} already current",
|
||||||
|
self.installed.len(),
|
||||||
|
self.already_current.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A filename from a remote index is untrusted input.
|
||||||
|
///
|
||||||
|
/// 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 {
|
||||||
|
!name.is_empty()
|
||||||
|
&& name.len() <= 128
|
||||||
|
&& name.ends_with(".pack")
|
||||||
|
&& !name.contains('/')
|
||||||
|
&& !name.contains('\\')
|
||||||
|
&& !name.contains("..")
|
||||||
|
&& !name.starts_with('.')
|
||||||
|
&& name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(bytes);
|
||||||
|
format!("{:x}", h.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(url: &str, max: u64) -> Result<Vec<u8>> {
|
||||||
|
let resp = ureq::AgentBuilder::new()
|
||||||
|
.timeout(std::time::Duration::from_secs(TIMEOUT_SECS))
|
||||||
|
.user_agent(concat!("hound/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.get(url)
|
||||||
|
.call()
|
||||||
|
.with_context(|| format!("fetching {url}"))?;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
// take() bounds the read regardless of what Content-Length claims,
|
||||||
|
// because Content-Length is also just something the server said.
|
||||||
|
resp.into_reader()
|
||||||
|
.take(max + 1)
|
||||||
|
.read_to_end(&mut buf)
|
||||||
|
.with_context(|| format!("reading {url}"))?;
|
||||||
|
if buf.len() as u64 > max {
|
||||||
|
bail!("{url} is larger than {max} bytes; refusing it");
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the index.
|
||||||
|
pub fn fetch_index(base: &str) -> Result<Index> {
|
||||||
|
let bytes = get(&format!("{base}/index.json"), MAX_INDEX_BYTES)?;
|
||||||
|
serde_json::from_slice(&bytes).context("the definitions index is not valid JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch, verify and install everything the index offers that we lack.
|
||||||
|
pub fn run(dir: &Path, trusted: &[(&str, ed25519_dalek::VerifyingKey)]) -> Result<Outcome> {
|
||||||
|
let base = base_url();
|
||||||
|
let mut out = Outcome::default();
|
||||||
|
|
||||||
|
if trusted.is_empty() {
|
||||||
|
bail!("no signing key is trusted by this build, so no pack could be verified");
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.with_context(|| format!("creating {}", dir.display()))?;
|
||||||
|
|
||||||
|
let index = fetch_index(&base)?;
|
||||||
|
if index.packs.is_empty() {
|
||||||
|
out.log.push(format!("{base} offers no packs"));
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in &index.packs {
|
||||||
|
if !safe_pack_name(&entry.file) {
|
||||||
|
out.rejected.push(entry.file.clone());
|
||||||
|
out.log
|
||||||
|
.push(format!("refused a pack name that is not a plain filename: {:?}", entry.file));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dest = dir.join(&entry.file);
|
||||||
|
|
||||||
|
// Already have these exact bytes? Nothing to do.
|
||||||
|
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. Not after, and not "install then check".
|
||||||
|
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 definitions pack ({e})", entry.file));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let verified = match pack::verify(&signed, trusted) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
out.rejected.push(entry.file.clone());
|
||||||
|
out.log.push(format!("{}: {e}", entry.file));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write beside the destination so the rename stays within one
|
||||||
|
// filesystem and is therefore atomic.
|
||||||
|
let tmp = dir.join(format!(".{}.part", entry.file));
|
||||||
|
if let Err(e) = std::fs::write(&tmp, &bytes) {
|
||||||
|
out.rejected.push(entry.file.clone());
|
||||||
|
out.log.push(format!("{}: could not write: {e}", entry.file));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::rename(&tmp, &dest) {
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
out.rejected.push(entry.file.clone());
|
||||||
|
out.log.push(format!("{}: could not install: {e}", entry.file));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.log.push(format!(
|
||||||
|
"{} — {} indicators, version {}",
|
||||||
|
entry.file,
|
||||||
|
verified.indicators.len(),
|
||||||
|
verified.version
|
||||||
|
));
|
||||||
|
out.installed.push(entry.file.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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") {
|
||||||
|
return PathBuf::from(dir);
|
||||||
|
}
|
||||||
|
if crate::caps::is_root() {
|
||||||
|
return PathBuf::from("/var/lib/hound/defs");
|
||||||
|
}
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
|
||||||
|
PathBuf::from(home).join(".local/share/hound/defs")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── the untrusted filename ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_traversing_pack_name_is_refused() {
|
||||||
|
// The updater runs as root. A remote index that could name
|
||||||
|
// ../../../etc/cron.d/evil would be remote code execution.
|
||||||
|
for bad in [
|
||||||
|
"../../../etc/cron.d/evil.pack",
|
||||||
|
"..%2f..%2fevil.pack",
|
||||||
|
"/etc/evil.pack",
|
||||||
|
"sub/dir.pack",
|
||||||
|
"a\\b.pack",
|
||||||
|
".hidden.pack",
|
||||||
|
"no-extension",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(!safe_pack_name(bad), "{bad:?} should have been refused");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_pack_name_is_accepted() {
|
||||||
|
for good in ["crates-io-2026.08.21.pack", "npm_2026.pack", "a.pack"] {
|
||||||
|
assert!(safe_pack_name(good), "{good:?} should have been accepted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_absurdly_long_name_is_refused() {
|
||||||
|
assert!(!safe_pack_name(&format!("{}.pack", "a".repeat(200))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── outcome reporting ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rejection_is_never_hidden_behind_a_success_count() {
|
||||||
|
let mut o = Outcome::default();
|
||||||
|
o.installed.push("a.pack".into());
|
||||||
|
o.rejected.push("b.pack".into());
|
||||||
|
let s = o.summary();
|
||||||
|
assert!(s.contains("REJECTED"), "a refused pack must be visible: {s}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_do_reads_as_up_to_date() {
|
||||||
|
let mut o = Outcome::default();
|
||||||
|
o.already_current.push("a.pack".into());
|
||||||
|
assert!(o.summary().contains("up to date"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hashing ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashes_are_stable_and_content_sensitive() {
|
||||||
|
assert_eq!(sha256_hex(b"abc"), sha256_hex(b"abc"));
|
||||||
|
assert_ne!(sha256_hex(b"abc"), sha256_hex(b"abd"));
|
||||||
|
assert_eq!(sha256_hex(b"").len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── verification gates installation ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unsigned_pack_is_never_installed() {
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use hound_defs::{Indicator, Pack, Versions};
|
||||||
|
|
||||||
|
let dir = std::env::temp_dir().join(format!("hound-upd-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
let real = SigningKey::from_bytes(&[1u8; 32]);
|
||||||
|
let attacker = SigningKey::from_bytes(&[2u8; 32]);
|
||||||
|
let p = Pack {
|
||||||
|
version: "9999.99.99".into(),
|
||||||
|
created: "2026-01-01T00:00:00Z".into(),
|
||||||
|
sources: vec![],
|
||||||
|
indicators: vec![Indicator {
|
||||||
|
ecosystem: "npm".into(),
|
||||||
|
name: "sudo".into(),
|
||||||
|
versions: Versions::All,
|
||||||
|
id: "MAL-EVIL".into(),
|
||||||
|
summary: "would make Hound quarantine sudo".into(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let forged = pack::sign(&p, &attacker, "hound-2026").unwrap();
|
||||||
|
let trusted = [("hound-2026", real.verifying_key())];
|
||||||
|
|
||||||
|
// The exact check the updater performs before writing anything.
|
||||||
|
assert!(
|
||||||
|
pack::verify(&forged, &trusted).is_err(),
|
||||||
|
"a pack signed by the wrong key must never reach the defs directory"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refuses_to_run_with_an_empty_trust_store() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("hound-upd-nokey-{}", std::process::id()));
|
||||||
|
let e = run(&dir, &[]).unwrap_err();
|
||||||
|
assert!(e.to_string().contains("no signing key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── configuration ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_source_can_be_pointed_elsewhere_for_mirrors() {
|
||||||
|
let _guard = crate::test_util::locked();
|
||||||
|
std::env::remove_var("HOUNDD_DEFS_URL");
|
||||||
|
assert_eq!(base_url(), "https://defs.houndav.com");
|
||||||
|
std::env::set_var("HOUNDD_DEFS_URL", "http://mirror.internal/defs");
|
||||||
|
assert_eq!(base_url(), "http://mirror.internal/defs");
|
||||||
|
std::env::remove_var("HOUNDD_DEFS_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limits_are_small_enough_to_be_a_real_bound() {
|
||||||
|
// A definitions pack is a list of package names. These exist so a
|
||||||
|
// broken or hostile server cannot fill a disk.
|
||||||
|
assert!(MAX_PACK_BYTES <= 64 * 1024 * 1024);
|
||||||
|
assert!(MAX_INDEX_BYTES <= 1024 * 1024);
|
||||||
|
assert!(TIMEOUT_SECS <= 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_index_is_an_error_not_a_panic() {
|
||||||
|
assert!(serde_json::from_slice::<Index>(b"{not json").is_err());
|
||||||
|
// An index with no packs array is empty rather than fatal.
|
||||||
|
let empty: Index = serde_json::from_slice(b"{}").unwrap();
|
||||||
|
assert!(empty.packs.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
dist/hound_0.1.0_amd64.deb
vendored
BIN
dist/hound_0.1.0_amd64.deb
vendored
Binary file not shown.
Loading…
Reference in a new issue