From e085f9c9d577fce4cb3ab8e144dec915b78137fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 17 May 2026 22:35:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(security):=20#502=20=E2=80=94=20compile-ti?= =?UTF-8?q?me=20URL/host=20egress=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a compile-time HIR pass that refuses fetch(url) and net.connect(host) / net.createConnection(host) call sites whose literal URL/host isn't covered by `perry.allowedHosts` in the host package.json. Non-literal URLs are refused too unless `perry.allowDynamicHosts: true` is set — preserving the static "grep-the-binary-for-egress" guarantee. The check is opt-in: empty `allowedHosts` disables the pass entirely (existing builds compile unchanged). Once any pattern is set, the gate is strict. Migration path documented as "use #495's perry audit --sbom to discover what egress your binary currently performs, then populate allowedHosts to match." Pattern syntax: - exact host: "api.example.com" - subdomain wildcard: "*.cdn.example.com" - URL prefix: "https://api.acme.com/v1/*" - universal escape hatch: "*" Pre-existing semantics preserved: net.connect with no host argument (implicit localhost / unix socket) is not gated. Cross-platform: the gate runs in the platform-agnostic compile_command driver, so every backend (LLVM / WASM / ArkTS / HarmonyOS / Glance / SwiftUI / JS) inherits the protection from one choke point. Diagnostic surfaces every offending site in a single error so the user can fix them all at once. Capped at 12 entries to keep error output reasonable. Walker (`perry-hir::egress`): - Covers FetchWithOptions / FetchGetWithAuth / FetchPostWithAuth / NetCreateConnection / NetConnect (the highest-volume egress shapes). - http.get / https.request / WebSocket lower through general-shape NativeMethodCall and are deferred to a follow-up under the same pass shape. 13 unit tests in perry-hir::egress::tests cover: - empty allowlist disables the pass - exact host / subdomain wildcard / URL prefix / universal patterns - host extraction from full URLs with userinfo / port / path - bare-host argument against URL-prefix entry does NOT match - fetch literal refusal + matching pass - fetch dynamic URL blocked by default + allowed with opt-in - net.connect host checked - net.connect without host implicitly allowed End-to-end smoke (all four cases verified against the release binary): - no allowedHosts → no check (legacy behavior preserved) - allowedHosts set, host NOT in list → fails with diagnostic - non-literal URL, no allowDynamicHosts → fails - non-literal URL, allowDynamicHosts: true → compiles Acceptance: - [x] Host package.json `perry.allowedHosts: [...]` with glob/URL-prefix patterns - [x] All `fetch` / `net.connect` / `net.createConnection` call sites analyzed (http.get/https.request/WebSocket deferred — see module doc) - [x] Literal host not in allowlist → build fails at call site with clear message - [x] Non-literal host → build fails unless `perry.allowDynamicHosts: true` - [x] Pattern matching: glob-style host wildcards + URL prefix - [x] Stronger than runtime allowlists: violations caught before binary exists - [deferred] `perry audit --sbom` lists every literal egress in the build for review — graft in same shape as a `literal_hosts` key (follow-up; #495 ships the v1 manifest shape) --- crates/perry-hir/src/egress.rs | 564 +++++++++++++++++++++++++++ crates/perry-hir/src/lib.rs | 2 + crates/perry/src/commands/compile.rs | 125 ++++++ docs/src/SUMMARY.md | 1 + docs/src/cli/allowed-hosts.md | 134 +++++++ 5 files changed, 826 insertions(+) create mode 100644 crates/perry-hir/src/egress.rs create mode 100644 docs/src/cli/allowed-hosts.md diff --git a/crates/perry-hir/src/egress.rs b/crates/perry-hir/src/egress.rs new file mode 100644 index 0000000000..d01f0166bc --- /dev/null +++ b/crates/perry-hir/src/egress.rs @@ -0,0 +1,564 @@ +//! #502 — compile-time URL/host egress allowlist. +//! +//! Walks the HIR for every egress call site +//! (`fetch(url)` / `net.connect(host, port)` / `net.createConnection`), +//! cross-references the literal URL/host against the host's +//! `perry.allowedHosts` allowlist, and reports refusals via the +//! returned `EgressViolation` records. The driver consumes those and +//! aborts the build with a single diagnostic that names every +//! offending site at once — better UX than failing on the first one +//! and asking the user to re-run. +//! +//! The pass is opt-in: an empty allowlist means "feature disabled, +//! anything goes" rather than "default-deny". Default-deny would +//! break every existing build that calls `fetch(...)` without +//! migration; the issue's spirit is "host that *wants* the static +//! egress guarantee opts in by setting `allowedHosts`". Once set, +//! the gate is strict. +//! +//! ## Pattern syntax +//! +//! Each entry in `allowedHosts` is matched in order: +//! +//! - **Exact host**: `"api.example.com"` matches that hostname +//! on any scheme/port/path. +//! - **Subdomain wildcard**: `"*.cdn.example.com"` matches every +//! direct or transitive subdomain of `cdn.example.com`. The +//! bare suffix itself does NOT match — `*.foo.com` does not +//! match `foo.com`. +//! - **URL prefix**: `"https://api.acme.com/v1/*"` matches any +//! URL beginning with the literal prefix. Useful for restricting +//! which paths a dep can reach on a host you generally allow. +//! - **Universal**: `"*"` matches everything (escape hatch for +//! incremental migration). +//! +//! ## What gets recorded as a violation +//! +//! - **Literal URL not matching any pattern** → violation. +//! - **Non-literal URL/host** (variable, expression, template-with-vars) +//! → violation unless `allowDynamicHosts: true` is set in the +//! host `package.json`. The static guarantee — "grep-ing the +//! binary's egress is reliable" — depends on rejecting +//! non-literal hosts by default. +//! +//! ## What's NOT covered yet +//! +//! - `http.get(url)` / `https.request(...)` / `WebSocket(url)` +//! — these lower through the general-shape `NativeMethodCall` +//! variant which makes URL-arg extraction harder. The MVP covers +//! the highest-volume egress shape (`fetch` + `net.connect`) and +//! leaves the rest as a follow-up under the same shape. + +use crate::ir::{Expr, Module, Stmt}; +use crate::walker::walk_expr_children; + +/// One refused egress call site. The driver collects these from the +/// walker and emits a single diagnostic that lists every site at +/// once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EgressViolation { + /// Source file the call appears in. Matches the module path + /// passed to `audit_module_egress`. + pub source: String, + /// The lowered call-site shape — `"fetch"`, `"net.connect"`, + /// `"net.createConnection"`, etc. Used in the diagnostic so + /// reviewers know which entrypoint is at fault. + pub kind: &'static str, + /// The raw URL or host string when the argument was a string + /// literal; `None` when the argument was non-literal (variable, + /// expression, template with substitutions). + pub literal: Option, + /// Why this site is refused. + pub reason: EgressRefusalReason, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EgressRefusalReason { + /// Literal URL/host did not match any pattern in the allowlist. + LiteralNotAllowed, + /// URL/host was not a string literal and `allowDynamicHosts` + /// was false. + NonLiteralAndDynamicForbidden, +} + +/// Walk a single HIR module, collecting egress violations against +/// the host's allowlist. Pure analyser — returns the violations and +/// lets the caller decide how to surface them. +/// +/// `allowed_hosts.is_empty()` short-circuits: the entire pass is +/// disabled until the host opts in (see module docs). +pub fn audit_module_egress( + hir_module: &Module, + source: &str, + allowed_hosts: &[String], + allow_dynamic_hosts: bool, +) -> Vec { + if allowed_hosts.is_empty() { + return Vec::new(); + } + let mut ctx = WalkCtx { + source: source.to_string(), + allowed_hosts, + allow_dynamic_hosts, + violations: Vec::new(), + }; + for stmt in &hir_module.init { + visit_stmt(stmt, &mut ctx); + } + for func in &hir_module.functions { + for stmt in &func.body { + visit_stmt(stmt, &mut ctx); + } + } + for class in &hir_module.classes { + for method in &class.methods { + for stmt in &method.body { + visit_stmt(stmt, &mut ctx); + } + } + } + ctx.violations +} + +struct WalkCtx<'a> { + source: String, + allowed_hosts: &'a [String], + allow_dynamic_hosts: bool, + violations: Vec, +} + +fn visit_stmt(stmt: &Stmt, ctx: &mut WalkCtx) { + match stmt { + Stmt::Expr(e) => visit_expr(e, ctx), + Stmt::Let { init, .. } => { + if let Some(v) = init { + visit_expr(v, ctx); + } + } + Stmt::Return(Some(e)) => visit_expr(e, ctx), + Stmt::Return(None) | Stmt::Break | Stmt::Continue => {} + Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => {} + Stmt::Labeled { body, .. } => visit_stmt(body, ctx), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + visit_expr(condition, ctx); + for s in then_branch { + visit_stmt(s, ctx); + } + if let Some(else_b) = else_branch { + for s in else_b { + visit_stmt(s, ctx); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + visit_expr(condition, ctx); + for s in body { + visit_stmt(s, ctx); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + visit_stmt(init, ctx); + } + if let Some(c) = condition { + visit_expr(c, ctx); + } + if let Some(u) = update { + visit_expr(u, ctx); + } + for s in body { + visit_stmt(s, ctx); + } + } + Stmt::Throw(e) => visit_expr(e, ctx), + Stmt::Try { + body, + catch, + finally, + } => { + for s in body { + visit_stmt(s, ctx); + } + if let Some(catch_clause) = catch { + for s in &catch_clause.body { + visit_stmt(s, ctx); + } + } + if let Some(finally_b) = finally { + for s in finally_b { + visit_stmt(s, ctx); + } + } + } + Stmt::Switch { + discriminant, + cases, + } => { + visit_expr(discriminant, ctx); + for case in cases { + if let Some(test) = &case.test { + visit_expr(test, ctx); + } + for s in &case.body { + visit_stmt(s, ctx); + } + } + } + Stmt::PreallocateBoxes(_) => {} + } +} + +fn visit_expr(expr: &Expr, ctx: &mut WalkCtx) { + match expr { + Expr::FetchWithOptions { url, .. } => check_url(ctx, "fetch", url), + Expr::FetchGetWithAuth { url, .. } => check_url(ctx, "fetch (with auth)", url), + Expr::FetchPostWithAuth { url, .. } => check_url(ctx, "fetch POST (with auth)", url), + Expr::NetCreateConnection { host: Some(h), .. } => { + check_host(ctx, "net.createConnection", h) + } + Expr::NetConnect { host: Some(h), .. } => check_host(ctx, "net.connect", h), + // No host argument means localhost / unix socket — implicitly + // allowed; nothing the allowlist could meaningfully gate. + Expr::NetCreateConnection { host: None, .. } | Expr::NetConnect { host: None, .. } => {} + _ => {} + } + walk_expr_children(expr, &mut |child| visit_expr(child, ctx)); +} + +fn check_url(ctx: &mut WalkCtx, kind: &'static str, url: &Expr) { + match url { + Expr::String(s) => { + if !url_matches_allowlist(s, ctx.allowed_hosts) { + ctx.violations.push(EgressViolation { + source: ctx.source.clone(), + kind, + literal: Some(s.clone()), + reason: EgressRefusalReason::LiteralNotAllowed, + }); + } + } + _ if !ctx.allow_dynamic_hosts => { + ctx.violations.push(EgressViolation { + source: ctx.source.clone(), + kind, + literal: None, + reason: EgressRefusalReason::NonLiteralAndDynamicForbidden, + }); + } + _ => {} + } +} + +fn check_host(ctx: &mut WalkCtx, kind: &'static str, host: &Expr) { + match host { + Expr::String(s) => { + if !host_matches_allowlist(s, ctx.allowed_hosts) { + ctx.violations.push(EgressViolation { + source: ctx.source.clone(), + kind, + literal: Some(s.clone()), + reason: EgressRefusalReason::LiteralNotAllowed, + }); + } + } + _ if !ctx.allow_dynamic_hosts => { + ctx.violations.push(EgressViolation { + source: ctx.source.clone(), + kind, + literal: None, + reason: EgressRefusalReason::NonLiteralAndDynamicForbidden, + }); + } + _ => {} + } +} + +/// Does `url` (a full URL string) satisfy any of the allowlist +/// patterns? See module docs for pattern shapes. +pub fn url_matches_allowlist(url: &str, patterns: &[String]) -> bool { + let host = host_of_url(url).unwrap_or(url); + for pat in patterns { + if pat == "*" { + return true; + } + // URL-prefix pattern: `https://api.acme.com/v1/*` — the + // pattern contains a scheme + `/*` suffix. Match if `url` + // starts with the literal prefix. + if pat.contains("://") { + if let Some(prefix) = pat.strip_suffix("/*") { + if url.starts_with(prefix) { + return true; + } + continue; + } + // Exact URL match (no glob) — rare but representable. + if pat == url { + return true; + } + continue; + } + // Otherwise treat as a host pattern. + if host_matches_pattern(host, pat) { + return true; + } + } + false +} + +/// Does the bare host string `host` match any of the allowlist +/// patterns? Used for `net.connect(port, host)` where the argument +/// is already a hostname, not a URL. +pub fn host_matches_allowlist(host: &str, patterns: &[String]) -> bool { + for pat in patterns { + if pat == "*" { + return true; + } + // URL-shaped patterns (`https://.../*`) don't match a bare + // host argument — `net.connect("api.example.com")` against + // a `https://api.example.com/v1/*` allowlist entry should NOT + // match because the entry is path-bound. Reviewers expect + // path-bound entries to gate only path-bearing call sites. + if pat.contains("://") { + continue; + } + if host_matches_pattern(host, pat) { + return true; + } + } + false +} + +fn host_matches_pattern(host: &str, pat: &str) -> bool { + if pat == host { + return true; + } + if let Some(suffix) = pat.strip_prefix("*.") { + // `*.cdn.example.com` matches `foo.cdn.example.com`, + // `a.b.cdn.example.com`, etc. — but NOT the bare suffix. + return host.ends_with(&format!(".{}", suffix)); + } + false +} + +/// Extract the host component of a URL string. Best-effort: returns +/// `None` if the input doesn't look like an absolute URL with a +/// `scheme://host[...]` shape. Used by `url_matches_allowlist` to +/// reduce a full URL to its host before host-pattern matching. +fn host_of_url(url: &str) -> Option<&str> { + let after_scheme = url.split_once("://")?.1; + // Strip path/query/fragment + optional port. + let host_with_port = after_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(after_scheme); + let host = host_with_port + .rsplit_once('@') + .map(|(_, h)| h) + .unwrap_or(host_with_port); + let host = host.split(':').next().unwrap_or(host); + if host.is_empty() { + None + } else { + Some(host) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pats(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn empty_allowlist_disables_pass() { + let m = Module::new("test"); + let v = audit_module_egress(&m, "/repo/main.ts", &[], false); + assert!(v.is_empty()); + } + + #[test] + fn host_pattern_exact_match() { + assert!(host_matches_allowlist( + "api.example.com", + &pats(&["api.example.com"]), + )); + assert!(!host_matches_allowlist( + "api.example.com", + &pats(&["other.example.com"]), + )); + } + + #[test] + fn host_pattern_subdomain_wildcard() { + let allow = pats(&["*.cdn.example.com"]); + // Subdomains match. + assert!(host_matches_allowlist("a.cdn.example.com", &allow)); + assert!(host_matches_allowlist("a.b.cdn.example.com", &allow)); + // Bare suffix does NOT match. + assert!(!host_matches_allowlist("cdn.example.com", &allow)); + // Unrelated hosts don't match. + assert!(!host_matches_allowlist("evil.com", &allow)); + // Look-alike does NOT match (suffix must be preceded by `.`). + assert!(!host_matches_allowlist("evilcdn.example.com", &allow)); + } + + #[test] + fn url_pattern_extracts_host() { + let allow = pats(&["api.example.com"]); + assert!(url_matches_allowlist( + "https://api.example.com/v1/users", + &allow + )); + assert!(url_matches_allowlist( + "http://api.example.com:8080/v1/users", + &allow + )); + // Userinfo + port don't confuse the extractor. + assert!(url_matches_allowlist( + "https://user:pass@api.example.com:443/v1/x?y=1", + &allow + )); + // Different host fails. + assert!(!url_matches_allowlist("https://evil.com/v1/users", &allow)); + } + + #[test] + fn url_prefix_pattern() { + let allow = pats(&["https://api.acme.com/v1/*"]); + assert!(url_matches_allowlist( + "https://api.acme.com/v1/users", + &allow + )); + assert!(url_matches_allowlist( + "https://api.acme.com/v1/posts/42", + &allow + )); + // Different path fails (path-bound prefix). + assert!(!url_matches_allowlist( + "https://api.acme.com/v2/users", + &allow + )); + // Different scheme fails. + assert!(!url_matches_allowlist( + "http://api.acme.com/v1/users", + &allow + )); + } + + #[test] + fn universal_escape_hatch() { + let allow = pats(&["*"]); + assert!(url_matches_allowlist("https://anywhere.com/x", &allow)); + assert!(host_matches_allowlist("evil.com", &allow)); + } + + #[test] + fn url_pattern_doesnt_match_bare_host() { + // `net.connect("api.example.com")` against a URL-prefix + // allowlist entry should NOT match — the entry restricts + // paths, the call has no path. + let allow = pats(&["https://api.example.com/v1/*"]); + assert!(!host_matches_allowlist("api.example.com", &allow)); + } + + #[test] + fn fetch_literal_records_violation() { + let mut m = Module::new("test"); + m.init.push(Stmt::Expr(Expr::FetchWithOptions { + url: Box::new(Expr::String("https://evil.com/x".into())), + method: Box::new(Expr::String("GET".into())), + body: Box::new(Expr::Undefined), + headers: vec![], + })); + let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false); + assert_eq!(v.len(), 1); + assert_eq!(v[0].kind, "fetch"); + assert_eq!(v[0].literal.as_deref(), Some("https://evil.com/x")); + assert_eq!(v[0].reason, EgressRefusalReason::LiteralNotAllowed); + } + + #[test] + fn fetch_literal_matching_passes() { + let mut m = Module::new("test"); + m.init.push(Stmt::Expr(Expr::FetchWithOptions { + url: Box::new(Expr::String("https://api.example.com/x".into())), + method: Box::new(Expr::String("GET".into())), + body: Box::new(Expr::Undefined), + headers: vec![], + })); + let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false); + assert!(v.is_empty()); + } + + #[test] + fn fetch_dynamic_url_blocked_by_default() { + let mut m = Module::new("test"); + m.init.push(Stmt::Expr(Expr::FetchWithOptions { + url: Box::new(Expr::LocalGet(0)), + method: Box::new(Expr::String("GET".into())), + body: Box::new(Expr::Undefined), + headers: vec![], + })); + let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false); + assert_eq!(v.len(), 1); + assert_eq!( + v[0].reason, + EgressRefusalReason::NonLiteralAndDynamicForbidden + ); + assert!(v[0].literal.is_none()); + } + + #[test] + fn fetch_dynamic_url_allowed_when_opted_in() { + let mut m = Module::new("test"); + m.init.push(Stmt::Expr(Expr::FetchWithOptions { + url: Box::new(Expr::LocalGet(0)), + method: Box::new(Expr::String("GET".into())), + body: Box::new(Expr::Undefined), + headers: vec![], + })); + let v = audit_module_egress( + &m, + "/repo/main.ts", + &pats(&["api.example.com"]), + true, // allowDynamicHosts + ); + assert!(v.is_empty()); + } + + #[test] + fn net_connect_host_checked() { + let mut m = Module::new("test"); + m.init.push(Stmt::Expr(Expr::NetConnect { + port: Box::new(Expr::Number(443.0)), + host: Some(Box::new(Expr::String("evil.com".into()))), + connect_listener: None, + })); + let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false); + assert_eq!(v.len(), 1); + assert_eq!(v[0].kind, "net.connect"); + } + + #[test] + fn net_connect_no_host_implicit_localhost_allowed() { + let mut m = Module::new("test"); + m.init.push(Stmt::Expr(Expr::NetConnect { + port: Box::new(Expr::Number(8080.0)), + host: None, + connect_listener: None, + })); + let v = audit_module_egress(&m, "/repo/main.ts", &pats(&["api.example.com"]), false); + assert!(v.is_empty()); + } +} diff --git a/crates/perry-hir/src/lib.rs b/crates/perry-hir/src/lib.rs index b1fb19233f..afa9b86004 100644 --- a/crates/perry-hir/src/lib.rs +++ b/crates/perry-hir/src/lib.rs @@ -7,6 +7,7 @@ pub mod analysis; pub mod capability; pub(crate) mod destructuring; pub mod dynamic_import; +pub mod egress; pub(crate) mod enums; pub mod error; pub mod ir; @@ -28,6 +29,7 @@ pub use dynamic_import::{ for_each_dynamic_import_mut, resolve_import_path, resolve_import_path_with_consts, FlatExport, Resolution, DYNAMIC_IMPORT_PATH_CAP, }; +pub use egress::{audit_module_egress, EgressRefusalReason, EgressViolation}; pub use enums::fix_imported_enums; pub use ir::*; pub use js_transform::{ diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index cd8a2b5aa9..8df668d118 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -640,6 +640,20 @@ pub struct CompilationContext { /// one combined diagnostic naming every offending site so the /// reviewer can fix the whole surface at once. pub lockdown: bool, + /// #502: host-controlled URL/host egress allowlist + /// (`perry.allowedHosts: [...]` in `package.json`). When + /// non-empty, the compile-time egress pass walks every + /// `fetch(url)` / `net.connect(host, …)` call site and refuses + /// any literal URL/host that doesn't match a pattern in this + /// list. Empty = pass disabled (opt-in only — see + /// `perry-hir::egress` for the rationale). + pub allowed_hosts: Vec, + /// #502: explicit host opt-in to non-literal egress arguments + /// (`fetch(someVar)` and friends). Default false: a variable URL + /// would otherwise defeat the static `grep`-the-binary egress + /// guarantee. Source: `perry.allowDynamicHosts: true` in host + /// `package.json`. + pub allow_dynamic_hosts: bool, } impl std::fmt::Debug for CompilationContext { @@ -694,6 +708,8 @@ impl CompilationContext { emit_attest: false, emit_sandbox: false, lockdown: false, + allowed_hosts: Vec::new(), + allow_dynamic_hosts: false, } } } @@ -1265,6 +1281,25 @@ pub fn run_with_parse_cache( } } } + // #502: perry.allowedHosts — host-controlled + // egress allowlist. When set, every literal URL/host + // in `fetch(...)` and `net.connect(...)` call sites + // must match a pattern here (exact host, + // `*.subdomain.example.com`, `https://.../path/*` + // URL prefix, or `*` universal). When unset, the + // pass is disabled and existing builds compile + // unchanged. + if let Some(arr) = pkg + .get("perry") + .and_then(|p| p.get("allowedHosts")) + .and_then(|v| v.as_array()) + { + for entry in arr { + if let Some(s) = entry.as_str() { + ctx.allowed_hosts.push(s.to_string()); + } + } + } // #504: perry.emitAttest — emit binary attestation // sidecar at compile time. See docs/src/cli/emit-attest.md. if let Some(ea) = pkg @@ -1293,6 +1328,29 @@ pub fn run_with_parse_cache( { ctx.lockdown = ld; } + // #502: perry.allowedHosts — compile-time URL/host + // egress allowlist. Patterns: exact host, "*.foo.com" + // subdomain wildcard, "https://host/prefix*" URL + // prefix, or "*" universal escape hatch. Empty list + // disables the pass entirely. + if let Some(arr) = pkg + .get("perry") + .and_then(|p| p.get("allowedHosts")) + .and_then(|v| v.as_array()) + { + for entry in arr { + if let Some(s) = entry.as_str() { + ctx.allowed_hosts.push(s.to_string()); + } + } + } + if let Some(b) = pkg + .get("perry") + .and_then(|p| p.get("allowDynamicHosts")) + .and_then(|v| v.as_bool()) + { + ctx.allow_dynamic_hosts = b; + } } } } @@ -1814,6 +1872,73 @@ pub fn run_with_parse_cache( } } + // #502: compile-time URL/host egress allowlist. When the host + // has opted in via `perry.allowedHosts`, walk every HIR module + // and refuse any literal URL/host that doesn't match a pattern + // there. Non-literal URLs/hosts are refused unless + // `perry.allowDynamicHosts: true`. All violations across all + // modules are collected and surfaced in a single diagnostic so + // the user can fix every site at once. + if !ctx.allowed_hosts.is_empty() { + let mut all_violations: Vec = Vec::new(); + for (path, hir_module) in &ctx.native_modules { + let source = path.to_string_lossy().into_owned(); + let v = perry_hir::audit_module_egress( + hir_module, + &source, + &ctx.allowed_hosts, + ctx.allow_dynamic_hosts, + ); + all_violations.extend(v); + } + if !all_violations.is_empty() { + let mut detail = String::new(); + let limit = 12usize; + for v in all_violations.iter().take(limit) { + let lit = match v.literal.as_deref() { + Some(s) => format!("\"{}\"", s), + None => "".to_string(), + }; + let why = match v.reason { + perry_hir::EgressRefusalReason::LiteralNotAllowed => { + "literal host not in `perry.allowedHosts`" + } + perry_hir::EgressRefusalReason::NonLiteralAndDynamicForbidden => { + "non-literal URL/host (`perry.allowDynamicHosts: true` not set)" + } + }; + detail.push_str(&format!( + "\n - {}: {} → {} ({})", + v.source, v.kind, lit, why + )); + } + if all_violations.len() > limit { + detail.push_str(&format!( + "\n ... and {} more", + all_violations.len() - limit + )); + } + anyhow::bail!( + "egress allowlist refused {} call site(s):{detail}\n\ + \n\ + `perry.allowedHosts` provides a static guarantee that this binary's\n\ + outbound network surface matches the declared list. Refusing the build.\n\ + (#502)\n\ + \n\ + Options:\n\ + - Add the offending host(s) to `perry.allowedHosts` in your host\n\ + `package.json` (exact `\"api.example.com\"`, subdomain wildcard\n\ + `\"*.cdn.example.com\"`, or URL-prefix `\"https://.../path/*\"`).\n\ + - Set `\"*\"` in `allowedHosts` to disable host gating (escape hatch\n\ + that defeats the static guarantee — use only for migration).\n\ + - For non-literal URLs, set `perry.allowDynamicHosts: true` if the\n\ + computed shape is intentional. Code review then has to trust the\n\ + value of every variable that reaches `fetch(...)`.", + all_violations.len(), + ); + } + } + // #496: `--lockdown` mode — fail the build if any standard // arbitrary-code-execution surface is reachable. The check has // three parts and runs after `collect_modules` so every diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 87d1647595..04c9d1d774 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -143,6 +143,7 @@ - [`--emit-attest` (binary attestation sidecar)](cli/emit-attest.md) - [`--emit-sandbox`](cli/emit-sandbox.md) - [`--lockdown`](cli/lockdown.md) +- [Egress Allowlist (`allowedHosts`)](cli/allowed-hosts.md) - [perry.toml Reference](cli/perry-toml.md) --- diff --git a/docs/src/cli/allowed-hosts.md b/docs/src/cli/allowed-hosts.md new file mode 100644 index 0000000000..e460c032f6 --- /dev/null +++ b/docs/src/cli/allowed-hosts.md @@ -0,0 +1,134 @@ +# Compile-Time Egress Allowlist (`perry.allowedHosts`) + +Perry can verify, at compile time, that every outbound network call +in your binary targets a host you've explicitly approved. When the +host application opts in via `perry.allowedHosts` in `package.json`, +every literal URL/host in a `fetch(...)`, `net.connect(...)`, or +`net.createConnection(...)` call must match one of the listed +patterns — otherwise the build fails before producing a binary. + +**Zero runtime cost.** The check runs at compile time over the +lowered HIR. The resulting binary is the same size and shape as a +build without the gate. + +## Why a compile-time check + +Runtime allowlists are foot-shoots — a misconfiguration or a malicious +dep can bypass them. A compile-time check gives a stronger property: +`grep`-ing the binary's egress is reliable. If a dep tries to add a +new outbound host through a literal URL, the build fails and the +review catches it; if it tries to hide the host behind a variable, +the build still fails unless you've explicitly opted into dynamic +hosts. + +## Configuration + +In your host `package.json`: + +```json +{ + "perry": { + "allowedHosts": [ + "api.example.com", + "*.cdn.example.com", + "https://api.acme.com/v1/*" + ] + } +} +``` + +### Pattern syntax + +- **Exact host** — `"api.example.com"` matches that hostname on any + scheme/port/path. +- **Subdomain wildcard** — `"*.cdn.example.com"` matches every direct + or transitive subdomain. The bare suffix does NOT match — `*.foo.com` + does not match `foo.com`. +- **URL prefix** — `"https://api.acme.com/v1/*"` matches any URL + starting with that literal prefix. Path-bound entries only gate + path-bearing call sites — `net.connect("api.acme.com")` against a + URL-prefix entry does NOT match (use a host-style entry for that). +- **Universal** — `"*"` matches everything (escape hatch for + incremental migration; defeats the static guarantee). + +## Dynamic URLs / hosts + +Non-literal arguments — `fetch(someVar)`, `net.connect(port, hostVar)`, +template strings with substitutions — defeat the static `grep`-the-binary +guarantee. They're refused by default: + +```typescript,no-test +const url = "https://api.example.com/x"; +const resp = await fetch(url); // refused unless allowDynamicHosts: true +``` + +To allow them, set `perry.allowDynamicHosts: true`: + +```json +{ + "perry": { + "allowedHosts": ["api.example.com"], + "allowDynamicHosts": true + } +} +``` + +The code reviewer then has to trust the value of every variable that +reaches `fetch(...)` — explicit acknowledgment that the static +guarantee is being weakened. + +## Opt-in semantics + +If `perry.allowedHosts` is **not set**, the entire pass is disabled +and existing builds compile unchanged. The host opts in by setting +the array; once set, the gate is strict. + +This is intentionally not "default-deny on greenfield" — that would +break every existing build that calls `fetch(...)`. Migration path: + +1. Run the build once without the allowlist. +2. Inspect `.perry-cache/audit.json` (the [behavioral SBOM + (`#495`)](perry-audit-sbom.md)) and see what egress the binary + currently performs. +3. Populate `allowedHosts` with the surface you actually use. +4. Re-build. The gate now catches future regressions. + +## Diagnostic shape + +The build fails with one combined diagnostic naming every offending +site at once (better UX than failing on the first one and asking the +user to re-run): + +```text +Error: egress allowlist refused 2 call site(s): + - /repo/main.ts: fetch → "https://evil.com/leak" (literal host not in `perry.allowedHosts`) + - /repo/lib/foo.ts: net.connect → "x.evil.com" (literal host not in `perry.allowedHosts`) + +`perry.allowedHosts` provides a static guarantee that this binary's +outbound network surface matches the declared list. Refusing the build. (#502) + +Options: +- Add the offending host(s) to `perry.allowedHosts` ... +- Set `"*"` in `allowedHosts` to disable host gating ... +- For non-literal URLs, set `perry.allowDynamicHosts: true` ... +``` + +The list is capped at 12 entries so pathological builds don't produce +60-line errors; trailing sites are summarised as `... and N more`. + +## What's covered now + +This first cut covers the highest-volume egress shape: `fetch(...)` + +`net.connect(...)` / `net.createConnection(...)`. Other shapes — +`http.get(...)`, `https.request(...)`, `WebSocket(...)` — lower +through the general-shape `NativeMethodCall` HIR variant and will +graft onto the same pass in a follow-up. + +## See also + +- [`#502`](https://github.com/PerryTS/perry/issues/502) — design discussion. +- [`perry audit --sbom`](perry-audit-sbom.md) (#495) — discover what + egress your binary currently performs before populating the + allowlist. +- The wider supply-chain hardening series + ([`#495`–`#506`](https://github.com/PerryTS/perry/issues?q=is%3Aissue+label%3Aenhancement+security)).