diff --git a/CHANGELOG.md b/CHANGELOG.md index 65783f0b5..e805a3003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### Added - Added a standalone `fuzz/` package with initial structured fuzz targets for Tier 1 security predicates: IP block-set behavior, topic wildcard semantics, crypto wire encodings, capability resource patterns, and SSRF/local-egress helper predicates. The storage-heavy capability target and Wasmtime-heavy SSRF target are feature-gated so normal workspace builds stay unchanged. Closes #1086. +- Added parser and wire-format fuzz targets for capsule manifest TOML, runtime config TOML, admin/kernel JSON messages, and opt-in gateway request-body JSON parsing. The manifest and gateway targets are feature-gated so the cheap default fuzz target set does not pull the full capsule or HTTP gateway dependency graphs. Closes #1087. - Runtime capsule commands can now use the existing `elicit()` host API, not only install/upgrade lifecycle hooks. The same host-generated request id, principal-stamped request stream, same-principal response filter, bounded timeout, and cancellation path apply. Runtime E2E now proves a normal capsule command elicit ignores a wrong-principal response before accepting the caller's answer, and crash recovery now kills the daemon while an elicit waiter is in flight to prove the command fails bounded instead of hanging. Closes #1068. - Daemon capsule discovery now includes the configured workspace root's `.astrid/capsules` directory in addition to the default principal install directory, so capsules installed by local workspace flows are picked up when the daemon starts or reloads from that workspace. Closes #1068. - Added runtime e2e coverage gates for the built-in CLI, gateway HTTP routes, and first-party capsule commands, plus a dedicated fake OpenAI-compatible runtime harness and CI workflow that installs current capsule artifacts in an isolated `ASTRID_HOME`. Closes #1068. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 5ff25408b..979d4b6f3 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -13,18 +13,41 @@ cargo-fuzz = true arbitrary = { version = "1", features = ["derive"] } astrid-capabilities = { path = "../crates/astrid-capabilities", optional = true } astrid-capsule = { path = "../crates/astrid-capsule", features = ["fuzzing"], optional = true } +astrid-config = { path = "../crates/astrid-config", optional = true } astrid-core = { path = "../crates/astrid-core", optional = true } astrid-crypto = { path = "../crates/astrid-crypto", optional = true } astrid-events = { path = "../crates/astrid-events", optional = true } +astrid-gateway = { path = "../crates/astrid-gateway", optional = true } libfuzzer-sys = "0.4" +serde = { version = "1", optional = true } +serde_json = { version = "1", optional = true } +toml = { version = "0.9", optional = true } [features] -default = ["core-target", "crypto-target", "events-target"] +default = [ + "admin-json-target", + "config-target", + "core-target", + "crypto-target", + "events-target", +] +admin-json-target = ["core-target", "dep:serde", "dep:serde_json"] capabilities-target = ["dep:astrid-capabilities"] +capsule-target = ["dep:astrid-capsule", "dep:toml"] +config-target = ["dep:astrid-config", "dep:toml"] core-target = ["dep:astrid-core"] crypto-target = ["dep:astrid-crypto"] events-target = ["dep:astrid-events"] -ssrf-target = ["core-target", "dep:astrid-capsule"] +gateway-target = ["dep:astrid-gateway", "dep:serde", "dep:serde_json"] +ssrf-target = ["core-target", "capsule-target"] + +[[bin]] +name = "admin_json" +path = "fuzz_targets/admin_json.rs" +test = false +doc = false +bench = false +required-features = ["admin-json-target"] [[bin]] name = "capability_patterns" @@ -42,6 +65,22 @@ doc = false bench = false required-features = ["crypto-target"] +[[bin]] +name = "config_toml" +path = "fuzz_targets/config_toml.rs" +test = false +doc = false +bench = false +required-features = ["config-target"] + +[[bin]] +name = "gateway_request_json" +path = "fuzz_targets/gateway_request_json.rs" +test = false +doc = false +bench = false +required-features = ["gateway-target"] + [[bin]] name = "ip_blockset" path = "fuzz_targets/ip_blockset.rs" @@ -50,6 +89,14 @@ doc = false bench = false required-features = ["core-target"] +[[bin]] +name = "manifest_toml" +path = "fuzz_targets/manifest_toml.rs" +test = false +doc = false +bench = false +required-features = ["capsule-target"] + [[bin]] name = "ssrf_helpers" path = "fuzz_targets/ssrf_helpers.rs" diff --git a/fuzz/README.md b/fuzz/README.md index db5d75716..755fe8f71 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -16,7 +16,11 @@ Install `cargo-fuzz`, then run targets from the repository root: cargo fuzz run ip_blockset cargo fuzz run topic_patterns cargo fuzz run crypto_wire +cargo fuzz run admin_json +cargo fuzz run config_toml cargo fuzz run capability_patterns --features capabilities-target +cargo fuzz run manifest_toml --features capsule-target +cargo fuzz run gateway_request_json --features gateway-target cargo fuzz run ssrf_helpers --features ssrf-target ``` @@ -39,8 +43,10 @@ Fuzz targets should assert Astrid invariants, not only "does not panic": crate. The default target set covers cheap, deterministic Tier 1 predicates from -issue #1084. `capability_patterns` is feature-gated because the full -`astrid-capabilities` crate pulls storage dependencies, and `ssrf_helpers` is -feature-gated because it compiles the Wasmtime-backed `astrid-capsule` crate. -Parser, archive, admin-state, HTTP, CLI, and Wasm/WIT targets should be added -in follow-up slices. +issue #1084 plus the cheap parser/wire-format targets. `capability_patterns` +is feature-gated because the full `astrid-capabilities` crate pulls storage +dependencies, `manifest_toml` and `ssrf_helpers` are feature-gated because they +compile the Wasmtime-backed `astrid-capsule` crate, and +`gateway_request_json` is feature-gated because it pulls the HTTP gateway +dependency graph. Archive, admin-state, full HTTP boundary, CLI, and Wasm/WIT +targets should be added in follow-up slices. diff --git a/fuzz/fuzz_targets/admin_json.rs b/fuzz/fuzz_targets/admin_json.rs new file mode 100644 index 000000000..b84eb3ea4 --- /dev/null +++ b/fuzz/fuzz_targets/admin_json.rs @@ -0,0 +1,110 @@ +#![no_main] + +use astrid_core::kernel_api::{ + AdminKernelRequest, AdminKernelResponse, KernelRequest, KernelResponse, +}; +use libfuzzer_sys::fuzz_target; +use serde_json::Value; + +fuzz_target!(|data: &[u8]| { + let Ok(value) = serde_json::from_slice::(data) else { + return; + }; + + assert_unknown_admin_methods_fail_closed(&value); + + try_roundtrip::(&value, assert_admin_request_invariants); + try_roundtrip::(&value, assert_admin_response_invariants); + try_roundtrip::(&value, |_| {}); + try_roundtrip::(&value, |_| {}); +}); + +fn try_roundtrip(value: &Value, check: impl Fn(&T)) +where + T: serde::Serialize + serde::de::DeserializeOwned, +{ + let Ok(parsed) = serde_json::from_value::(value.clone()) else { + return; + }; + check(&parsed); + + let encoded = serde_json::to_value(&parsed).expect("parsed wire type must serialize"); + let reparsed: T = serde_json::from_value(encoded).expect("serialized wire type must reparse"); + check(&reparsed); +} + +fn assert_admin_request_invariants(req: &AdminKernelRequest) { + let encoded = serde_json::to_value(req).expect("admin request must serialize"); + assert!( + encoded.get("method").and_then(Value::as_str).is_some(), + "serialized admin requests must carry an explicit method tag" + ); + + if let Some(request_id) = &req.request_id { + assert_eq!( + encoded.get("request_id").and_then(Value::as_str), + Some(request_id.as_str()) + ); + } +} + +fn assert_admin_response_invariants(resp: &AdminKernelResponse) { + let encoded = serde_json::to_value(resp).expect("admin response must serialize"); + assert!( + encoded.get("status").and_then(Value::as_str).is_some(), + "serialized admin responses must carry an explicit status tag" + ); + + if let Some(request_id) = &resp.request_id { + assert_eq!( + encoded.get("request_id").and_then(Value::as_str), + Some(request_id.as_str()) + ); + } +} + +fn assert_unknown_admin_methods_fail_closed(value: &Value) { + let Some(method) = value + .as_object() + .and_then(|obj| obj.get("method")) + .and_then(Value::as_str) + else { + return; + }; + + if !KNOWN_ADMIN_METHODS.contains(&method) { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "unknown admin method {method:?} must not deserialize as an allowed request" + ); + } +} + +const KNOWN_ADMIN_METHODS: &[&str] = &[ + "AgentCreate", + "AgentDelete", + "AgentDisable", + "AgentEnable", + "AgentList", + "AgentModify", + "CapsGrant", + "CapsRevoke", + "CapsTokenList", + "CapsTokenMint", + "CapsTokenRevoke", + "GroupCreate", + "GroupDelete", + "GroupList", + "GroupModify", + "InviteIssue", + "InviteList", + "InviteRedeem", + "InviteRevoke", + "PairDeviceIssue", + "PairDeviceList", + "PairDeviceRedeem", + "PairDeviceRevoke", + "QuotaGet", + "QuotaSet", + "UsageGet", +]; diff --git a/fuzz/fuzz_targets/config_toml.rs b/fuzz/fuzz_targets/config_toml.rs new file mode 100644 index 000000000..de5cfa516 --- /dev/null +++ b/fuzz/fuzz_targets/config_toml.rs @@ -0,0 +1,25 @@ +#![no_main] + +use astrid_config::Config; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let Ok(src) = std::str::from_utf8(data) else { + return; + }; + + let value_result = toml::from_str::(src); + let config_result = toml::from_str::(src); + + if let Ok(config) = config_result { + assert!( + value_result.is_ok(), + "typed config deserialization implies syntactically valid TOML" + ); + + let serialized = toml::to_string(&config).expect("parsed config must serialize"); + let roundtrip: Config = + toml::from_str(&serialized).expect("serialized config must deserialize"); + let _ = astrid_config::validate::validate(&roundtrip); + } +}); diff --git a/fuzz/fuzz_targets/gateway_request_json.rs b/fuzz/fuzz_targets/gateway_request_json.rs new file mode 100644 index 000000000..78ff06ddf --- /dev/null +++ b/fuzz/fuzz_targets/gateway_request_json.rs @@ -0,0 +1,32 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use serde::de::DeserializeOwned; + +fuzz_target!(|data: &[u8]| { + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); + try_parse::(data); +}); + +fn try_parse(data: &[u8]) +where + T: DeserializeOwned, +{ + let _ = serde_json::from_slice::(data); +} diff --git a/fuzz/fuzz_targets/manifest_toml.rs b/fuzz/fuzz_targets/manifest_toml.rs new file mode 100644 index 000000000..3221f72dc --- /dev/null +++ b/fuzz/fuzz_targets/manifest_toml.rs @@ -0,0 +1,127 @@ +#![no_main] + +use astrid_capsule::manifest::{CapsuleManifest, PublishDef, SubscribeDef}; +use libfuzzer_sys::fuzz_target; +use toml::Value; + +fuzz_target!(|data: &[u8]| { + let Ok(src) = std::str::from_utf8(data) else { + return; + }; + + let value_result = toml::from_str::(src); + let manifest_result = toml::from_str::(src); + + if let Ok(manifest) = manifest_result { + assert!( + value_result.is_ok(), + "typed manifest deserialization implies syntactically valid TOML" + ); + + assert_eq!( + manifest.effective_ipc_publish_patterns().len(), + manifest.publishes.len() + ); + assert_eq!( + manifest.effective_ipc_subscribe_patterns().len(), + manifest.subscribes.len() + ); + + for publish in manifest.publishes.values() { + assert_at_most_one_publish_pin(publish); + } + + for subscribe in manifest.subscribes.values() { + assert_at_most_one_subscribe_pin(subscribe); + assert!( + subscribe.priority.is_none() || subscribe.handler.is_some(), + "handler-less subscribe entries must not carry priority" + ); + } + + for interceptor in manifest.effective_interceptors() { + let Some(subscribe) = manifest.subscribes.get(&interceptor.event) else { + panic!("effective interceptor must come from a subscribe entry"); + }; + assert!(subscribe.handler.is_some()); + } + } else if let Ok(value) = value_result { + assert_malformed_security_shapes_fail_closed(src, &value); + } +}); + +fn assert_at_most_one_publish_pin(def: &PublishDef) { + let pins = [ + def.version.as_ref(), + def.tag.as_ref(), + def.rev.as_ref(), + def.branch.as_ref(), + def.path.as_ref(), + ] + .into_iter() + .flatten() + .count(); + assert!(pins <= 1, "publish entry accepted multiple source pins"); +} + +fn assert_at_most_one_subscribe_pin(def: &SubscribeDef) { + let pins = [ + def.version.as_ref(), + def.tag.as_ref(), + def.rev.as_ref(), + def.branch.as_ref(), + def.path.as_ref(), + ] + .into_iter() + .flatten() + .count(); + assert!(pins <= 1, "subscribe entry accepted multiple source pins"); +} + +fn assert_malformed_security_shapes_fail_closed(src: &str, value: &Value) { + if section_has_multi_pin(value, "publish") || section_has_multi_pin(value, "subscribe") { + assert!( + toml::from_str::(src).is_err(), + "ambiguous source pins must fail manifest parsing" + ); + } + + if subscribe_has_priority_without_handler(value) { + assert!( + toml::from_str::(src).is_err(), + "handler-less subscribe priority must fail manifest parsing" + ); + } +} + +fn section_has_multi_pin(value: &Value, section: &str) -> bool { + value + .get(section) + .and_then(Value::as_table) + .is_some_and(|table| table.values().any(entry_has_multi_pin)) +} + +fn entry_has_multi_pin(value: &Value) -> bool { + let Some(table) = value.as_table() else { + return false; + }; + + ["version", "tag", "rev", "branch", "path"] + .into_iter() + .filter(|key| table.contains_key(*key)) + .count() + > 1 +} + +fn subscribe_has_priority_without_handler(value: &Value) -> bool { + value + .get("subscribe") + .and_then(Value::as_table) + .is_some_and(|table| { + table.values().any(|entry| { + entry.as_table().is_some_and(|entry| { + entry.contains_key("priority") && !entry.contains_key("handler") + }) + }) + }) +}