diff --git a/proxy-bin-l7/src/client/mock_server/endpoint_protection_callbacks.rs b/proxy-bin-l7/src/client/mock_server/endpoint_protection_callbacks.rs index 1eac561a..23659e15 100644 --- a/proxy-bin-l7/src/client/mock_server/endpoint_protection_callbacks.rs +++ b/proxy-bin-l7/src/client/mock_server/endpoint_protection_callbacks.rs @@ -388,6 +388,56 @@ async fn fetch_permissions(req: Request) -> impl IntoResponse { "rejected_packages": [] } }), + _ => default_ecosystem_policy.clone(), + }; + + let golang_policy = match token { + "policy-block-golang" => json!({ + "block_all_installs": true, + "request_installs": false, + "minimum_allowed_age_timestamp": null, + "exceptions": { + "allowed_packages": [], + "rejected_packages": [] + } + }), + "policy-allow-gorilla-mux-golang" => json!({ + "block_all_installs": false, + "request_installs": false, + "minimum_allowed_age_timestamp": null, + "exceptions": { + "allowed_packages": ["github.com/gorilla/mux"], // listed as malware in mock list + "rejected_packages": [] + } + }), + "policy-reject-gin-golang" => json!({ + "block_all_installs": false, + "request_installs": false, + "minimum_allowed_age_timestamp": null, + "exceptions": { + "allowed_packages": [], + "rejected_packages": ["github.com/gin-gonic/gin"] + } + }), + "policy-request-installs-golang" => json!({ + "block_all_installs": false, + "request_installs": true, + "minimum_allowed_age_timestamp": null, + "exceptions": { + "allowed_packages": [], + "rejected_packages": [] + } + }), + "policy-bypass-new-package-golang" => json!({ + "block_all_installs": false, + "request_installs": false, + // Cutoff set to far future (year ~2286): released_on (year ~2255) <= cutoff → not blocked + "minimum_allowed_age_timestamp": 9_999_999_999_i64, + "exceptions": { + "allowed_packages": [], + "rejected_packages": [] + } + }), _ => default_ecosystem_policy, }; @@ -404,7 +454,8 @@ async fn fetch_permissions(req: Request) -> impl IntoResponse { "nuget": nuget_policy, "chrome": chrome_policy, "open_vsx": open_vsx_policy, - "skills_sh": skills_sh_policy + "skills_sh": skills_sh_policy, + "golang": golang_policy } })) .into_response() diff --git a/proxy-bin-l7/src/client/mock_server/malware_list.rs b/proxy-bin-l7/src/client/mock_server/malware_list.rs index c99ac8d4..febade99 100644 --- a/proxy-bin-l7/src/client/mock_server/malware_list.rs +++ b/proxy-bin-l7/src/client/mock_server/malware_list.rs @@ -30,6 +30,7 @@ pub(super) fn web_svc() -> impl Service impl Service impl IntoResponse { reason: Reason::Malware, }]) } + +pub const FRESH_GOLANG_MODULE_NAME: &str = "github.com/fresh-org/fresh-module"; +pub const FRESH_GOLANG_MODULE_VERSION: &str = "1.0.0"; +async fn released_golang() -> impl IntoResponse { + // Use now() so the entry is always "recently released" relative to the default `now - 48h` + // cutoff, but NOT relative to the bypass policy's far-future cutoff (year ~2286). + Json([ReleasedPackageData { + package_name: FRESH_GOLANG_MODULE_NAME.to_owned(), + version: PackageVersion::Semver( + PragmaticSemver::parse(FRESH_GOLANG_MODULE_VERSION).unwrap(), + ), + released_on: SystemTimestampMilliseconds::now(), + }]) +} + +async fn malware_golang() -> impl IntoResponse { + Json([ListDataEntry { + package_name: "github.com/gorilla/mux".to_owned(), + version: PackageVersion::Semver(PragmaticSemver::new_semver(1, 8, 0)), + reason: Reason::Malware, + }]) +} diff --git a/proxy-bin-l7/src/test/e2e/test_proxy/firewall_go.rs b/proxy-bin-l7/src/test/e2e/test_proxy/firewall_go.rs new file mode 100644 index 00000000..0b7db38f --- /dev/null +++ b/proxy-bin-l7/src/test/e2e/test_proxy/firewall_go.rs @@ -0,0 +1,207 @@ +use rama::{ + http::{BodyExtractExt as _, StatusCode, service::client::HttpClientExt as _}, + telemetry::tracing, +}; + +use crate::{ + client::mock_server::malware_list::{FRESH_GOLANG_MODULE_NAME, FRESH_GOLANG_MODULE_VERSION}, + test::e2e, +}; + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_malware_blocked() { + let runtime = e2e::runtime::get().await; + let client = runtime.client_with_http_proxy().await; + + // github.com/gorilla/mux v1.8.0 is listed as malware in the mock list + let resp = client + .get("https://proxy.golang.org/github.com/gorilla/mux/@v/v1.8.0.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::FORBIDDEN, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_safe_package_allowed() { + let runtime = e2e::runtime::get().await; + let client = runtime.client_with_http_proxy().await; + + // gin is not in the malware list + let resp = client + .get("https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.1.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::OK, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_non_zip_allowed() { + let runtime = e2e::runtime::get().await; + let client = runtime.client_with_http_proxy().await; + + // .mod files are not intercepted — only .zip downloads are blocked + let resp = client + .get("https://proxy.golang.org/github.com/gorilla/mux/@v/v1.8.0.mod") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::OK, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_list_endpoint_allowed() { + let runtime = e2e::runtime::get().await; + let client = runtime.client_with_http_proxy().await; + + // /@v/list is metadata, not intercepted + let resp = client + .get("https://proxy.golang.org/github.com/gorilla/mux/@v/list") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::OK, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_allows_different_version() { + let runtime = e2e::runtime::get().await; + let client = runtime.client_with_http_proxy().await; + + // gorilla/mux v1.7.0 is not in the malware list (only v1.8.0 is) + let resp = client + .get("https://proxy.golang.org/github.com/gorilla/mux/@v/v1.7.0.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::OK, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_package_allowed_by_endpoint_policy_exception() { + let runtime = e2e::runtime::spawn_with_agent_identity( + "policy-allow-gorilla-mux-golang", + "mock_device", + &[], + ) + .await; + let client = runtime.client_with_http_proxy().await; + + // gorilla/mux v1.8.0 is malware, but the allowed_packages exception overrides the malware check + let resp = client + .get("https://proxy.golang.org/github.com/gorilla/mux/@v/v1.8.0.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::OK, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_package_blocked_by_endpoint_policy_block_all() { + let runtime = + e2e::runtime::spawn_with_agent_identity("policy-block-golang", "mock_device", &[]).await; + let client = runtime.client_with_http_proxy().await; + + // gin is not malware, but block_all_installs blocks it + let resp = client + .get("https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.1.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::FORBIDDEN, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_package_blocked_by_endpoint_policy_rejected_package() { + let runtime = + e2e::runtime::spawn_with_agent_identity("policy-reject-gin-golang", "mock_device", &[]) + .await; + let client = runtime.client_with_http_proxy().await; + + // gin is in rejected_packages — blocked even though it's not malware + let resp = client + .get("https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.1.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::FORBIDDEN, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_package_blocked_by_endpoint_policy_request_installs() { + let runtime = e2e::runtime::spawn_with_agent_identity( + "policy-request-installs-golang", + "mock_device", + &[], + ) + .await; + let client = runtime.client_with_http_proxy().await; + + // gin is not malware, but request_installs requires approval for all installs + let resp = client + .get("https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.1.zip") + .send() + .await + .unwrap(); + + assert_eq!(StatusCode::FORBIDDEN, resp.status()); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_new_package_blocked() { + let runtime = e2e::runtime::get().await; + let client = runtime.client_with_http_proxy().await; + + let module = FRESH_GOLANG_MODULE_NAME; + let ver = FRESH_GOLANG_MODULE_VERSION; + let url = format!("https://proxy.golang.org/{module}/@v/v{ver}.zip"); + + let resp = client.get(url).send().await.unwrap(); + + assert_eq!(StatusCode::FORBIDDEN, resp.status()); + + let payload = resp.try_into_string().await.unwrap(); + assert!( + payload.to_lowercase().contains("24 hours") || payload.to_lowercase().contains("vetted"), + "expected blocked response to mention 24-hour vetting, got: {payload}" + ); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn test_go_new_package_not_blocked_via_policy_cutoff() { + let runtime = e2e::runtime::spawn_with_agent_identity( + "policy-bypass-new-package-golang", + "mock_device", + &[], + ) + .await; + let client = runtime.client_with_http_proxy().await; + + let module = FRESH_GOLANG_MODULE_NAME; + let ver = FRESH_GOLANG_MODULE_VERSION; + let url = format!("https://proxy.golang.org/{module}/@v/v{ver}.zip"); + + let resp = client.get(url).send().await.unwrap(); + + assert_eq!(StatusCode::OK, resp.status()); +} diff --git a/proxy-bin-l7/src/test/e2e/test_proxy/mod.rs b/proxy-bin-l7/src/test/e2e/test_proxy/mod.rs index a1399691..5991464d 100644 --- a/proxy-bin-l7/src/test/e2e/test_proxy/mod.rs +++ b/proxy-bin-l7/src/test/e2e/test_proxy/mod.rs @@ -1,6 +1,7 @@ mod no_firewall; mod firewall_chrome; +mod firewall_go; mod firewall_maven; mod firewall_npm; mod firewall_nuget; diff --git a/proxy-lib/src/http/firewall/mod.rs b/proxy-lib/src/http/firewall/mod.rs index 57b14d58..c44df611 100644 --- a/proxy-lib/src/http/firewall/mod.rs +++ b/proxy-lib/src/http/firewall/mod.rs @@ -51,8 +51,8 @@ use crate::{ http::firewall::{ notifier::EventNotifier, rule::{ - DynRule, npm::min_package_age::MinPackageAge, - vscode::min_package_age::MinPackageAgeVSCode, + DynRule, golang::min_package_age::MinPackageAgeGolang, + npm::min_package_age::MinPackageAge, vscode::min_package_age::MinPackageAgeVSCode, }, }, storage::SyncCompactDataStorage, @@ -226,6 +226,16 @@ impl Firewall { .await .context("create block rule: open vsx")? .into_dyn(), + self::rule::golang::RuleGolang::try_new( + guard.clone(), + layered_client.clone(), + data.clone(), + remote_endpoint_config.clone(), + Some(MinPackageAgeGolang::new(notifier.clone())), + ) + .await + .context("create block rule: golang")? + .into_dyn(), self::rule::skills_sh::RuleSkillsSh::try_new( guard, layered_client, diff --git a/proxy-lib/src/http/firewall/rule/golang/min_package_age/mod.rs b/proxy-lib/src/http/firewall/rule/golang/min_package_age/mod.rs new file mode 100644 index 00000000..52664979 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/min_package_age/mod.rs @@ -0,0 +1,122 @@ +use std::str::FromStr; + +use rama::{ + error::{BoxError, ErrorContext as _}, + http::{Body, Response, body::util::BodyExt as _}, + telemetry::tracing, + utils::str::arcstr::ArcStr, +}; + +use crate::{ + http::{ + firewall::{ + events::{Artifact, MinPackageAgeEvent}, + notifier::EventNotifier, + }, + headers::make_response_uncacheable, + }, + package::{ + released_packages_list::RemoteReleasedPackagesList, + version::{PackageVersion, PragmaticSemver}, + }, + utils::time::SystemTimestampMilliseconds, +}; + +#[derive(Debug, Clone)] +pub(in crate::http::firewall) struct MinPackageAgeGolang { + notifier: Option, +} + +impl MinPackageAgeGolang { + pub fn new(notifier: Option) -> Self { + Self { notifier } + } + + pub async fn rewrite_list_response( + &self, + resp: Response, + module_name: &str, + released_packages: &RemoteReleasedPackagesList, + cutoff_ts: SystemTimestampMilliseconds, + ) -> Result { + let (mut parts, body) = resp.into_parts(); + + let bytes = body + .collect() + .await + .context("collect golang list response body")? + .to_bytes(); + + let text = match std::str::from_utf8(&bytes) { + Ok(t) => t, + Err(_) => { + tracing::debug!("golang list response body is not valid UTF-8, passing through"); + return Ok(Response::from_parts(parts, Body::from(bytes))); + } + }; + + let mut suppressed: Vec = Vec::new(); + let mut kept: Vec<&str> = Vec::new(); + + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let version_str = line.strip_prefix('v').unwrap_or(line); + match PragmaticSemver::from_str(version_str) { + Ok(parsed) => { + let version = PackageVersion::Semver(parsed); + let name = super::GolangPackageName::from(module_name); + if released_packages.is_recently_released(&name, Some(&version), cutoff_ts) { + suppressed.push(version); + } else { + kept.push(line); + } + } + Err(_) => { + kept.push(line); + } + } + } + + if suppressed.is_empty() { + return Ok(Response::from_parts(parts, Body::from(bytes))); + } + + tracing::info!( + module = %module_name, + suppressed_versions = ?suppressed, + "Go module list rewritten: suppressed too-young versions" + ); + + make_response_uncacheable(&mut parts.headers); + + let new_body = kept.join("\n") + "\n"; + + self.notify_rewrite(module_name, suppressed).await; + + Ok(Response::from_parts(parts, Body::from(new_body))) + } + + async fn notify_rewrite(&self, module_name: &str, suppressed: Vec) { + let Some(notifier) = &self.notifier else { + return; + }; + let identifier: ArcStr = module_name.into(); + let event = MinPackageAgeEvent { + ts_ms: SystemTimestampMilliseconds::now(), + artifact: Artifact { + product: "golang".into(), + identifier: identifier.clone(), + display_name: None, + version: None, + }, + suppressed_versions: suppressed, + }; + notifier.notify_min_package_age(event).await; + } +} + +#[cfg(test)] +mod tests; diff --git a/proxy-lib/src/http/firewall/rule/golang/min_package_age/tests.rs b/proxy-lib/src/http/firewall/rule/golang/min_package_age/tests.rs new file mode 100644 index 00000000..a873f7d8 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/min_package_age/tests.rs @@ -0,0 +1,183 @@ +use rama::http::{Body, BodyExtractExt as _}; + +use crate::{ + package::{ + name_formatter::LowerCasePackageName, + released_packages_list::{ReleasedPackageData, RemoteReleasedPackagesList}, + }, + utils::time::{SystemDuration, SystemTimestampMilliseconds}, +}; + +use super::*; + +fn make_list_response(body: &str) -> Response { + Response::builder() + .header("content-type", "text/plain; charset=UTF-8") + .body(Body::from(body.to_owned())) + .unwrap() +} + +fn make_list_response_with_cache(body: &str) -> Response { + Response::builder() + .header("content-type", "text/plain; charset=UTF-8") + .header("cache-control", "public, max-age=60") + .header("etag", "abc123") + .header("last-modified", "Wed, 01 Jan 2020 00:00:00 GMT") + .body(Body::from(body.to_owned())) + .unwrap() +} + +fn make_released_packages( + entries: &[(&str, &str, u64)], +) -> RemoteReleasedPackagesList { + let now_ts = SystemTimestampMilliseconds::now(); + RemoteReleasedPackagesList::from_entries_for_tests( + entries + .iter() + .map(|(package_name, version, hours_ago)| ReleasedPackageData { + package_name: (*package_name).to_owned(), + version: version.parse().unwrap(), + released_on: now_ts - SystemDuration::hours(*hours_ago as u16), + }) + .collect(), + now_ts, + ) +} + +fn default_cutoff_ts() -> SystemTimestampMilliseconds { + SystemTimestampMilliseconds::now() - SystemDuration::hours(48) +} + +#[tokio::test] +async fn filters_recently_released_versions() { + let body = "v1.0.0\nv2.0.0\n"; + let list = make_released_packages(&[ + ("github.com/gorilla/mux", "1.0.0", 72), // old — keep + ("github.com/gorilla/mux", "2.0.0", 1), // new — suppress + ]); + + let result = MinPackageAgeGolang::new(None) + .rewrite_list_response( + make_list_response(body), + "github.com/gorilla/mux", + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + + assert_eq!(result.headers().get("cache-control").unwrap(), "no-cache"); + let text = result.try_into_string().await.unwrap(); + assert!(text.contains("v1.0.0"), "old version should be kept"); + assert!(!text.contains("v2.0.0"), "new version should be suppressed"); +} + +#[tokio::test] +async fn passthrough_when_nothing_filtered() { + let body = "v1.0.0\nv1.1.0\n"; + let list = make_released_packages(&[ + ("github.com/gorilla/mux", "1.0.0", 72), + ("github.com/gorilla/mux", "1.1.0", 96), + ]); + let resp = make_list_response_with_cache(body); + + let result = MinPackageAgeGolang::new(None) + .rewrite_list_response(resp, "github.com/gorilla/mux", &list, default_cutoff_ts()) + .await + .unwrap(); + + // cache headers must be preserved when nothing was filtered + assert_eq!(result.headers().get("etag").unwrap(), "abc123"); + assert_eq!( + result.headers().get("cache-control").unwrap(), + "public, max-age=60" + ); + let text = result.try_into_string().await.unwrap(); + assert!(text.contains("v1.0.0")); + assert!(text.contains("v1.1.0")); +} + +#[tokio::test] +async fn filters_deep_multi_segment_module_path() { + // Mirrors a real malware-list entry: + // { "package_name": "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/es", + // "version": "v1.3.84", "released_on": 1776804062 } + let body = "v1.3.83\nv1.3.84\n"; + let list = make_released_packages(&[ + ( + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/es", + "1.3.83", + 72, + ), + ( + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/es", + "1.3.84", + 1, + ), + ]); + + let result = MinPackageAgeGolang::new(None) + .rewrite_list_response( + make_list_response(body), + "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/es", + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + + let text = result.try_into_string().await.unwrap(); + assert!(text.contains("v1.3.83"), "old version should be kept"); + assert!( + !text.contains("v1.3.84"), + "new version should be suppressed" + ); +} + +#[tokio::test] +async fn keeps_unparseable_version_lines() { + let body = "v1.0.0\nnot-a-version\n"; + let list = make_released_packages(&[("github.com/gorilla/mux", "1.0.0", 72)]); + + let result = MinPackageAgeGolang::new(None) + .rewrite_list_response( + make_list_response(body), + "github.com/gorilla/mux", + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + + let text = result.try_into_string().await.unwrap(); + assert!(text.contains("v1.0.0")); + assert!( + text.contains("not-a-version"), + "unparseable lines must be kept" + ); +} + +#[tokio::test] +async fn strips_cache_headers_only_when_modified() { + let body = "v1.0.0\nv2.0.0\n"; + let list = make_released_packages(&[ + ("github.com/gorilla/mux", "1.0.0", 72), + ("github.com/gorilla/mux", "2.0.0", 1), + ]); + let resp = make_list_response_with_cache(body); + + let result = MinPackageAgeGolang::new(None) + .rewrite_list_response(resp, "github.com/gorilla/mux", &list, default_cutoff_ts()) + .await + .unwrap(); + + assert!( + result.headers().get("etag").is_none(), + "etag should be stripped" + ); + assert!( + result.headers().get("last-modified").is_none(), + "last-modified should be stripped" + ); + assert_eq!(result.headers().get("cache-control").unwrap(), "no-cache"); +} diff --git a/proxy-lib/src/http/firewall/rule/golang/mod.rs b/proxy-lib/src/http/firewall/rule/golang/mod.rs new file mode 100644 index 00000000..19707ff9 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/mod.rs @@ -0,0 +1,253 @@ +use std::fmt; + +use rama::{ + Service, + error::{BoxError, ErrorContext as _, extra::OpaqueError}, + extensions::ExtensionsRef as _, + graceful::ShutdownGuard, + http::{Request, Response, StatusCode, Uri}, + net::address::Domain, + telemetry::tracing, + utils::str::arcstr::{ArcStr, arcstr}, +}; + +use crate::{ + endpoint_protection::{ + EcosystemKey, PackagePolicyDecision, PolicyEvaluator, RemoteEndpointConfig, + }, + http::{ + RequestMetaUri, + firewall::{ + domain_matcher::DomainMatcher, + events::{Artifact, BlockReason}, + }, + }, + package::{ + malware_list::RemoteMalwareList, name_formatter::LowerCasePackageName, + released_packages_list::RemoteReleasedPackagesList, version::PackageVersion, + }, + storage::SyncCompactDataStorage, + utils::time::{SystemDuration, SystemTimestampMilliseconds}, +}; + +type GolangPackageName = LowerCasePackageName; +const GOLANG_ECOSYSTEM_KEY: EcosystemKey = EcosystemKey::from_static("golang"); + +#[cfg(feature = "pac")] +use crate::http::firewall::pac::PacScriptGenerator; + +use super::{BlockedRequest, HttpRequestMatcherView, HttpResponseMatcherView, RequestAction, Rule}; + +mod parser; +use parser::{GoPackage, is_zip_download, parse_package_from_path}; + +pub mod min_package_age; +use min_package_age::MinPackageAgeGolang; + +pub(in crate::http::firewall) struct RuleGolang { + target_domains: DomainMatcher, + remote_malware_list: RemoteMalwareList, + remote_released_packages_list: RemoteReleasedPackagesList, + policy_evaluator: Option>, + maybe_min_package_age: Option, +} + +impl RuleGolang { + pub(in crate::http::firewall) async fn try_new( + guard: ShutdownGuard, + remote_malware_list_https_client: C, + sync_storage: SyncCompactDataStorage, + remote_endpoint_config: Option, + min_package_age: Option, + ) -> Result + where + C: Service + Clone, + { + let remote_malware_list = RemoteMalwareList::try_new( + guard.clone(), + Uri::from_static("https://malware-list.aikido.dev/malware_golang.json"), + sync_storage.clone(), + remote_malware_list_https_client.clone(), + ) + .await + .context("create remote malware list for golang block rule")?; + + let remote_released_packages_list = RemoteReleasedPackagesList::try_new( + guard.clone(), + Uri::from_static("https://malware-list.aikido.dev/releases/golang.json"), + sync_storage, + remote_malware_list_https_client, + ) + .await + .context("create remote released packages list for golang block rule")?; + + let policy_evaluator = remote_endpoint_config + .clone() + .map(|config| PolicyEvaluator::new(guard, GOLANG_ECOSYSTEM_KEY.clone(), config)); + + Ok(Self { + target_domains: ["proxy.golang.org"].into_iter().collect(), + remote_malware_list, + remote_released_packages_list, + policy_evaluator, + maybe_min_package_age: min_package_age, + }) + } +} + +impl fmt::Debug for RuleGolang { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RuleGolang").finish() + } +} + +impl Rule for RuleGolang { + #[inline(always)] + fn match_domain(&self, domain: &Domain) -> bool { + self.target_domains.is_match(domain) + } + + #[cfg(feature = "pac")] + #[inline(always)] + fn collect_pac_domains(&self, generator: &mut PacScriptGenerator) { + for domain in self.target_domains.iter() { + generator.write_domain(&domain); + } + } + + async fn evaluate_request(&self, req: Request) -> Result { + let path = req.uri().path(); + if !is_zip_download(path) { + return Ok(RequestAction::Allow(req)); + } + self.evaluate_zip_request(req).await + } + + fn match_http_response_payload_inspection_request( + &self, + req: HttpRequestMatcherView<'_>, + ) -> bool { + self.maybe_min_package_age.is_some() + && parser::parse_module_from_list_path(req.uri.path()).is_some() + } + + fn match_http_response_payload_inspection_response( + &self, + resp: HttpResponseMatcherView<'_>, + ) -> bool { + resp.status == StatusCode::OK + } + + async fn evaluate_response(&self, resp: Response) -> Result { + let Some(min_package_age) = &self.maybe_min_package_age else { + return Ok(resp); + }; + let Some(module_name) = resp + .extensions() + .get_ref::() + .and_then(|RequestMetaUri(uri)| parser::parse_module_from_list_path(uri.path())) + else { + return Ok(resp); + }; + min_package_age + .rewrite_list_response( + resp, + &module_name, + &self.remote_released_packages_list, + self.get_package_age_cutoff_ts(), + ) + .await + } +} + +impl RuleGolang { + const DEFAULT_MIN_PACKAGE_AGE: SystemDuration = SystemDuration::days(2); + + fn get_package_age_cutoff_ts(&self) -> SystemTimestampMilliseconds { + self.policy_evaluator + .as_ref() + .map(|c| c.package_age_cutoff_ts(Self::DEFAULT_MIN_PACKAGE_AGE)) + .unwrap_or_else(|| SystemTimestampMilliseconds::now() - Self::DEFAULT_MIN_PACKAGE_AGE) + } + + fn blocked_artifact(package: &GoPackage) -> Artifact { + Artifact { + product: arcstr!("golang"), + identifier: ArcStr::from(package.fully_qualified_name.as_str()), + display_name: None, + version: Some(PackageVersion::Semver(package.version.clone())), + } + } + + fn is_package_listed_as_malware(&self, package: &GoPackage) -> bool { + let name = GolangPackageName::from(package.fully_qualified_name.as_str()); + self.remote_malware_list + .has_entries_with_version(&name, &PackageVersion::Semver(package.version.clone())) + } + + async fn evaluate_zip_request(&self, req: Request) -> Result { + let path = req.uri().path().trim_start_matches('/'); + + let Some(package) = parse_package_from_path(path) else { + tracing::debug!("Golang url: {path} is not a module zip download: passthrough"); + return Ok(RequestAction::Allow(req)); + }; + + tracing::debug!( + http.url.path = %path, + package.name = %package.fully_qualified_name, + package.version = %package.version, + "Go module zip download request" + ); + + if let Some(policy_evaluator) = self.policy_evaluator.as_ref() { + let name = GolangPackageName::from(package.fully_qualified_name.as_str()); + let decision = policy_evaluator.evaluate_package_install(&name); + + match decision { + PackagePolicyDecision::Allow => { + return Ok(RequestAction::Allow(req)); + } + PackagePolicyDecision::Defer => {} + decision => { + return Ok(RequestAction::Block(BlockedRequest::blocked( + req, + Self::blocked_artifact(&package), + super::block_reason_for(decision), + ))); + } + } + } + + if self.is_package_listed_as_malware(&package) { + tracing::warn!("Blocked malware from {}", package.fully_qualified_name); + return Ok(RequestAction::Block(BlockedRequest::blocked( + req, + Self::blocked_artifact(&package), + BlockReason::Malware, + ))); + } + + let cutoff_ts = self.get_package_age_cutoff_ts(); + let name = GolangPackageName::from(package.fully_qualified_name.as_str()); + if self.remote_released_packages_list.is_recently_released( + &name, + Some(&PackageVersion::Semver(package.version.clone())), + cutoff_ts, + ) { + tracing::info!( + http.url.path = %path, + package = %package.fully_qualified_name, + "blocked golang zip download: package released too recently" + ); + return Ok(RequestAction::Block(BlockedRequest::blocked( + req, + Self::blocked_artifact(&package), + BlockReason::NewPackage, + ))); + } + + tracing::debug!("Golang url: {path} does not contain malware: passthrough"); + Ok(RequestAction::Allow(req)) + } +} diff --git a/proxy-lib/src/http/firewall/rule/golang/module_escape.rs b/proxy-lib/src/http/firewall/rule/golang/module_escape.rs new file mode 100644 index 00000000..2bb7b982 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/module_escape.rs @@ -0,0 +1,26 @@ +/// Reverses Go's module path escaping (`golang.org/x/mod/module.EscapePath`). +/// +/// Go encodes each uppercase letter as `!` + lowercase so module caches work on +/// case-insensitive filesystems — e.g. `AikidoSec` → `!aikido!sec`. `!a`–`!z` are +/// each replaced by the corresponding uppercase letter; any `!` not followed by a +/// lowercase letter is passed through unchanged. +pub(super) fn go_module_unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '!' + && let Some(&next) = chars.peek() + && next.is_ascii_lowercase() + { + out.push(next.to_ascii_uppercase()); + chars.next(); + continue; + } + out.push(c); + } + out +} + +#[cfg(test)] +#[path = "module_escape_tests.rs"] +mod tests; diff --git a/proxy-lib/src/http/firewall/rule/golang/module_escape_tests.rs b/proxy-lib/src/http/firewall/rule/golang/module_escape_tests.rs new file mode 100644 index 00000000..77a2945b --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/module_escape_tests.rs @@ -0,0 +1,103 @@ +use super::go_module_unescape; + +#[test] +fn empty_string() { + assert_eq!(go_module_unescape(""), ""); +} + +#[test] +fn no_uppercase() { + // Common case — all-lowercase paths are not modified at all + assert_eq!(go_module_unescape("gorilla"), "gorilla"); +} + +#[test] +fn all_lowercase_proxy_path() { + assert_eq!( + go_module_unescape("github.com/gorilla/mux"), + "github.com/gorilla/mux" + ); +} + +#[test] +fn single_leading_uppercase() { + // "Azure" → escaped as "!azure" + assert_eq!(go_module_unescape("!azure"), "Azure"); +} + +#[test] +fn multiple_uppercases() { + // "AikidoSec" → escaped as "!aikido!sec" + assert_eq!(go_module_unescape("!aikido!sec"), "AikidoSec"); +} + +#[test] +fn uppercase_in_org_only() { + // "GoogleCloudPlatform/cloudsql-proxy" → "!google!cloud!platform/cloudsql-proxy" + assert_eq!( + go_module_unescape("!google!cloud!platform/cloudsql-proxy"), + "GoogleCloudPlatform/cloudsql-proxy" + ); +} + +#[test] +fn uppercase_in_repo_only() { + assert_eq!(go_module_unescape("org/!my!repo"), "org/MyRepo"); +} + +#[test] +fn mixed_segments_only_first_encoded() { + // "Azure/azure-sdk-for-go" — org is capitalized, repo is not + assert_eq!( + go_module_unescape("!azure/azure-sdk-for-go"), + "Azure/azure-sdk-for-go" + ); +} + +#[test] +fn consecutive_uppercases() { + // "ABC" → "!a!b!c" + assert_eq!(go_module_unescape("!a!b!c"), "ABC"); +} + +#[test] +fn sirupsen_logrus() { + // Real-world: "Sirupsen/logrus" is a well-known module with an uppercase org + assert_eq!(go_module_unescape("!sirupsen/logrus"), "Sirupsen/logrus"); +} + +#[test] +fn full_proxy_path_with_escaping() { + // Full path as it appears after percent-decoding the proxy URL + assert_eq!( + go_module_unescape("github.com/!sirupsen/logrus"), + "github.com/Sirupsen/logrus" + ); +} + +// --- Invalid / defensive cases --- + +#[test] +fn invalid_escape_bang_at_end() { + // Trailing `!` with nothing after it — passed through unchanged + assert_eq!(go_module_unescape("foo!"), "foo!"); +} + +#[test] +fn invalid_escape_bang_followed_by_uppercase() { + // `!!bar` — first `!` is not a valid escape (followed by `!`, not a-z) so it passes + // through; the second `!b` is a valid escape and becomes `B`. + // Go never produces `!!` in real paths, but each `!` is processed independently. + assert_eq!(go_module_unescape("foo!!bar"), "foo!Bar"); +} + +#[test] +fn invalid_escape_bang_followed_by_digit() { + // `!1` — not a valid Go escape sequence, passed through + assert_eq!(go_module_unescape("foo!1bar"), "foo!1bar"); +} + +#[test] +fn invalid_escape_bang_followed_by_space() { + assert_eq!(go_module_unescape("foo! bar"), "foo! bar"); +} diff --git a/proxy-lib/src/http/firewall/rule/golang/parser.rs b/proxy-lib/src/http/firewall/rule/golang/parser.rs new file mode 100644 index 00000000..78f9ce4d --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/parser.rs @@ -0,0 +1,78 @@ +use std::str::FromStr; + +use rama::{net::uri::util::percent_encoding, telemetry::tracing}; + +use crate::package::version::PragmaticSemver; + +#[path = "module_escape.rs"] +mod module_escape; +use module_escape::go_module_unescape; + +pub(super) struct GoPackage { + pub(super) fully_qualified_name: String, + pub(super) version: PragmaticSemver, +} + +pub(super) fn is_zip_download(path: &str) -> bool { + path.ends_with(".zip") && path.contains("/@v/") +} + +/// Parses a Go module proxy zip URL path (`/{module}/@v/{version}.zip`) into a normalized +/// module name and version. Reverses both encoding layers (percent-encoding + Go module escaping) +/// and lowercases for malware-list lookup. +pub(super) fn parse_package_from_path(path: &str) -> Option { + let path = path.trim_matches('/'); + + let (module_path_raw, rest) = path.split_once("/@v/")?; + let version_raw = rest.strip_suffix(".zip")?; + + if module_path_raw.is_empty() || version_raw.is_empty() { + return None; + } + + let module_name = normalize_module_path(module_path_raw); + + // Go versions always carry a 'v' prefix in the proxy URL; strip it for semver parsing + let version_str = version_raw.strip_prefix('v').unwrap_or(version_raw); + let version = PragmaticSemver::from_str(version_str) + .inspect_err(|err| { + tracing::debug!( + "failed to parse golang module ({module_name}) version (raw = {version_raw}): err = {err}" + ); + }) + .ok()?; + + Some(GoPackage { + fully_qualified_name: module_name, + version, + }) +} + +/// Parses a Go module proxy list URL path into a normalized module name. +/// +/// Expected path shape: `/{module_path}/@v/list` +pub(super) fn parse_module_from_list_path(path: &str) -> Option { + let path = path.trim_matches('/'); + let module_path_raw = path.strip_suffix("/@v/list")?; + if module_path_raw.is_empty() { + return None; + } + Some(normalize_module_path(module_path_raw)) +} + +/// Normalizes a raw module path segment from a Go proxy URL to a canonical lowercase name. +/// +/// Reverses both encoding layers applied by the Go toolchain: +/// 1. Percent-decode (`%21` → `!`) +/// 2. Module-unescape (`!x` → uppercase `X`, per `golang.org/x/mod/module.UnescapePath`) +/// +/// Then lowercases the result for consistent malware-list lookup. +fn normalize_module_path(raw: &str) -> String { + let percent_decoded = percent_encoding::percent_decode_str(raw).decode_utf8_lossy(); + let unescaped = go_module_unescape(&percent_decoded); + unescaped.to_ascii_lowercase() +} + +#[cfg(test)] +#[path = "parser_tests.rs"] +mod tests; diff --git a/proxy-lib/src/http/firewall/rule/golang/parser_tests.rs b/proxy-lib/src/http/firewall/rule/golang/parser_tests.rs new file mode 100644 index 00000000..1156c880 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/golang/parser_tests.rs @@ -0,0 +1,184 @@ +use crate::package::version::PragmaticSemver; + +use super::{parse_module_from_list_path, parse_package_from_path}; + +fn assert_parsed(path: &str, expected_name: &str, expected_version: &str) { + let pkg = parse_package_from_path(path) + .unwrap_or_else(|| panic!("expected package to parse: {path}")); + assert_eq!(pkg.fully_qualified_name, expected_name, "path: {path}"); + assert_eq!( + pkg.version, + PragmaticSemver::parse(expected_version).unwrap(), + "path: {path}" + ); +} + +// --- parse_package_from_path tests (zip downloads) --- + +#[test] +fn test_parse_simple_module() { + assert_parsed( + "github.com/gorilla/mux/@v/v1.8.0.zip", + "github.com/gorilla/mux", + "1.8.0", + ); +} + +#[test] +fn test_parse_with_leading_slash() { + assert_parsed( + "/github.com/gorilla/mux/@v/v1.8.0.zip", + "github.com/gorilla/mux", + "1.8.0", + ); +} + +#[test] +fn test_parse_deep_module_path() { + assert_parsed( + "github.com/aikidosec/firewall-go/cmd/zen-go/@v/v1.0.0.zip", + "github.com/aikidosec/firewall-go/cmd/zen-go", + "1.0.0", + ); +} + +#[test] +fn test_parse_percent_encoded_uppercase() { + // %21 is !, and !x encodes uppercase X in Go's module proxy protocol + // github.com/%21aikido%21sec → percent-decode → github.com/!aikido!sec + // → module-unescape → github.com/AikidoSec → lowercase → github.com/aikidosec + assert_parsed( + "github.com/%21aikido%21sec/firewall-go/cmd/zen-go/@v/v1.0.0.zip", + "github.com/aikidosec/firewall-go/cmd/zen-go", + "1.0.0", + ); +} + +#[test] +fn test_parse_golang_org_module() { + assert_parsed( + "golang.org/x/sys/@v/v0.15.0.zip", + "golang.org/x/sys", + "0.15.0", + ); +} + +#[test] +fn test_parse_prerelease_version() { + assert_parsed( + "github.com/foo/bar/@v/v1.0.0-alpha.1.zip", + "github.com/foo/bar", + "1.0.0-alpha.1", + ); +} + +#[test] +fn test_reject_mod_file() { + assert!( + parse_package_from_path("github.com/gorilla/mux/@v/v1.8.0.mod").is_none(), + "should not parse .mod files" + ); +} + +#[test] +fn test_reject_info_file() { + assert!( + parse_package_from_path("github.com/gorilla/mux/@v/v1.8.0.info").is_none(), + "should not parse .info files" + ); +} + +#[test] +fn test_reject_list_endpoint() { + assert!( + parse_package_from_path("github.com/gorilla/mux/@v/list").is_none(), + "should not parse /@v/list" + ); +} + +#[test] +fn test_reject_no_at_v_segment() { + assert!( + parse_package_from_path("github.com/gorilla/mux/v1.8.0.zip").is_none(), + "should not parse paths without /@v/" + ); +} + +#[test] +fn test_parse_pseudo_version() { + // Pseudo-versions are used for commits not tagged with a semver + assert_parsed( + "github.com/some/pkg/@v/v0.0.0-20231215000000-abc1234def56.zip", + "github.com/some/pkg", + "0.0.0-20231215000000-abc1234def56", + ); +} + +#[test] +fn test_parse_non_github_domain() { + // Malware entries can use arbitrary domains, not just github.com + assert_parsed( + "github.web.gylab.com/mrz1836/go-pandadoc/@v/v1.0.1.zip", + "github.web.gylab.com/mrz1836/go-pandadoc", + "1.0.1", + ); +} + +// --- parse_module_from_list_path tests --- + +#[test] +fn test_parse_list_path() { + assert_eq!( + parse_module_from_list_path("github.com/gorilla/mux/@v/list"), + Some("github.com/gorilla/mux".to_owned()) + ); +} + +#[test] +fn test_parse_list_path_leading_slash() { + assert_eq!( + parse_module_from_list_path("/github.com/gorilla/mux/@v/list"), + Some("github.com/gorilla/mux".to_owned()) + ); +} + +#[test] +fn test_parse_list_path_deep_module() { + assert_eq!( + parse_module_from_list_path("github.com/aikidosec/firewall-go/cmd/zen-go/@v/list"), + Some("github.com/aikidosec/firewall-go/cmd/zen-go".to_owned()) + ); +} + +#[test] +fn test_parse_list_path_percent_encoded() { + // %21 → !, then lowercased + assert_eq!( + parse_module_from_list_path("github.com/%21Aikido/pkg/@v/list"), + Some("github.com/!aikido/pkg".to_owned()) + ); +} + +#[test] +fn test_reject_list_path_no_module() { + assert!( + parse_module_from_list_path("/@v/list").is_none(), + "should return None when module path is empty" + ); +} + +#[test] +fn test_reject_non_list_path() { + assert!( + parse_module_from_list_path("github.com/gorilla/mux/@v/v1.8.0.zip").is_none(), + "should not parse zip paths as list" + ); +} + +#[test] +fn test_reject_list_path_wrong_suffix() { + assert!( + parse_module_from_list_path("github.com/gorilla/mux/@v/v1.8.0.mod").is_none(), + "should not parse non-list paths" + ); +} diff --git a/proxy-lib/src/http/firewall/rule/mod.rs b/proxy-lib/src/http/firewall/rule/mod.rs index e00a2332..ecbbfd39 100644 --- a/proxy-lib/src/http/firewall/rule/mod.rs +++ b/proxy-lib/src/http/firewall/rule/mod.rs @@ -50,6 +50,7 @@ pub(crate) fn block_reason_for(decision: PackagePolicyDecision) -> BlockReason { pub use super::pac::PacScriptGenerator; pub mod chrome; +pub mod golang; pub mod hijack; pub mod maven; pub mod npm;