diff --git a/proxy-lib/src/http/firewall/mod.rs b/proxy-lib/src/http/firewall/mod.rs index b41ba34c..0b7d44d4 100644 --- a/proxy-lib/src/http/firewall/mod.rs +++ b/proxy-lib/src/http/firewall/mod.rs @@ -167,6 +167,7 @@ impl Firewall { layered_client.clone(), data.clone(), remote_endpoint_config.clone(), + notifier.clone(), ) .await .context("create block rule: nuget")? diff --git a/proxy-lib/src/http/firewall/rule/nuget/min_package_age/catalog_list.rs b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/catalog_list.rs new file mode 100644 index 00000000..43474a83 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/catalog_list.rs @@ -0,0 +1,202 @@ +use rama::{ + error::{BoxError, ErrorContext}, + http::{Body, Response, Uri, body::util::BodyExt}, + telemetry::tracing, + utils::str::arcstr::ArcStr, +}; + +use crate::{ + http::{ + firewall::{ + events::{Artifact, MinPackageAgeEvent}, + notifier::EventNotifier, + rule::nuget::{NUGET_PRODUCT_KEY, NugetRemoteReleasedPackageList}, + }, + headers::make_response_uncacheable, + }, + package::{ + name_formatter::LowerCasePackageName, + version::{PackageVersion, PragmaticSemver}, + }, + utils::time::SystemTimestampMilliseconds, +}; + +const BASE_PATH: &str = "/v3/registration5-gz-semver2"; + +pub struct CatalogList { + pub notifier: Option, +} + +impl CatalogList { + pub fn match_uri(&self, uri: &Uri) -> Option { + uri.path() + .strip_prefix(BASE_PATH)? + .trim_start_matches('/') + .split_once('/') + .map(|(package_name, _)| package_name.into()) + } + + pub async fn remove_new_packages( + &self, + resp: Response, + package_name: ArcStr, + remote_released_packages_list: &NugetRemoteReleasedPackageList, + cutoff_secs: SystemTimestampMilliseconds, + ) -> Result { + let (mut parts, body) = resp.into_parts(); + + let bytes = body + .collect() + .await + .context("collect nuget index response body")? + .to_bytes(); + + let mut json: serde_json::Value = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(err) => { + tracing::debug!( + "nuget index response body is not valid JSON, passing through: {err}" + ); + return Ok(Response::from_parts(parts, Body::from(bytes))); + } + }; + + let mut removed_versions: Vec = vec![]; + + Self::handle_items_collection( + &mut json, + &mut removed_versions, + remote_released_packages_list, + cutoff_secs, + 0, + ); + + if removed_versions.is_empty() { + return Ok(Response::from_parts(parts, Body::from(bytes))); + } else { + if let Some(notifier) = &self.notifier { + let event = MinPackageAgeEvent { + ts_ms: SystemTimestampMilliseconds::now(), + artifact: Artifact { + product: NUGET_PRODUCT_KEY, + identifier: package_name.clone(), + display_name: Some(package_name), + version: None, + }, + suppressed_versions: removed_versions + .iter() + .filter_map(|v| v.parse().ok()) + .collect(), + }; + notifier.notify_min_package_age(event).await; + } + } + + let new_bytes = + serde_json::to_vec(&json).context("serialize modified nuget index response")?; + + make_response_uncacheable(&mut parts.headers); + + Ok(Response::from_parts(parts, Body::from(new_bytes))) + } + + fn handle_items_collection( + json: &mut serde_json::Value, + removed_versions: &mut Vec, + remote_released_packages_list: &NugetRemoteReleasedPackageList, + cutoff_secs: SystemTimestampMilliseconds, + depth: usize, + ) { + const MAX_DEPTH: usize = 5; + if depth > MAX_DEPTH { + return; + } + let Some(serde_json::Value::Array(items)) = json.get_mut("items") else { + return; + }; + + items.retain_mut(|item| { + if Self::has_type(item, "Package") { + return match Self::should_remove_package( + item, + remote_released_packages_list, + cutoff_secs, + ) { + Some(version) => { + removed_versions.push(version.to_string()); + false + } + None => true, + }; + } + + // Each collection can, depending on its type, contain a sub-items collection + // Loop over these as well to recursively find all Package entries + Self::handle_items_collection( + item, + removed_versions, + remote_released_packages_list, + cutoff_secs, + depth + 1, + ); + true + }); + + let new_count = items.len(); + if let Some(count) = json.get_mut("count") + && count.as_u64() != Some(new_count as u64) + { + *count = serde_json::Value::Number(new_count.into()); + } + } + + fn has_type(json: &serde_json::Value, type_name: &str) -> bool { + match json.get("@type") { + Some(serde_json::Value::String(s)) => s.eq_ignore_ascii_case(type_name), + Some(serde_json::Value::Array(arr)) => arr.iter().any(|v| { + v.as_str() + .is_some_and(|s| s.eq_ignore_ascii_case(type_name)) + }), + _ => false, + } + } + + fn should_remove_package( + package_json: &serde_json::Value, + remote_released_packages_list: &NugetRemoteReleasedPackageList, + cutoff_secs: SystemTimestampMilliseconds, + ) -> Option { + let serde_json::Value::Object(package) = package_json else { + return None; + }; + let Some(serde_json::Value::Object(catalog_entry)) = package.get("catalogEntry") else { + return None; + }; + let Some(serde_json::Value::String(package_name)) = catalog_entry.get("id") else { + return None; + }; + let Some(serde_json::Value::String(package_version)) = catalog_entry.get("version") else { + return None; + }; + + let version = match PragmaticSemver::parse(package_version) { + Ok(version_semver) => PackageVersion::Semver(version_semver), + Err(_) => PackageVersion::Unknown(package_version.into()), + }; + + let normalized_package_name = LowerCasePackageName::from(package_name); + + if remote_released_packages_list.is_recently_released( + &normalized_package_name, + Some(&version), + cutoff_secs, + ) { + tracing::info!( + "{package_name}@{package_version} was removed from the nuget meta response because it was recently released." + ); + Some(version) + } else { + None + } + } +} diff --git a/proxy-lib/src/http/firewall/rule/nuget/min_package_age/flat_version_list.rs b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/flat_version_list.rs new file mode 100644 index 00000000..a5be3873 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/flat_version_list.rs @@ -0,0 +1,123 @@ +use rama::{ + error::{BoxError, ErrorContext}, + http::{Body, Response, Uri, body::util::BodyExt}, + telemetry::tracing, + utils::str::arcstr::ArcStr, +}; + +use crate::{ + http::{ + firewall::{ + events::{Artifact, MinPackageAgeEvent}, + notifier::EventNotifier, + rule::nuget::{NUGET_PRODUCT_KEY, NugetRemoteReleasedPackageList}, + }, + headers::make_response_uncacheable, + }, + package::{ + name_formatter::LowerCasePackageName, + version::{PackageVersion, PragmaticSemver}, + }, + utils::time::SystemTimestampMilliseconds, +}; + +const BASE_PATH: &str = "/v3-flatcontainer"; + +pub struct FlatVersionList { + pub notifier: Option, +} + +impl FlatVersionList { + pub fn match_uri(&self, uri: &Uri) -> Option { + let (package_name, index_json) = uri + .path() + .strip_prefix(BASE_PATH)? + .trim_start_matches('/') + .split_once('/')?; + + if index_json.eq_ignore_ascii_case("index.json") { + Some(package_name.into()) + } else { + None + } + } + + pub async fn remove_new_packages( + &self, + resp: Response, + package_name: ArcStr, + released_package_list: &NugetRemoteReleasedPackageList, + cutoff_secs: SystemTimestampMilliseconds, + ) -> Result { + let (mut parts, body) = resp.into_parts(); + + let bytes = body + .collect() + .await + .context("collect nuget index response body")? + .to_bytes(); + + let mut json: serde_json::Value = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(err) => { + tracing::debug!( + "nuget index response body is not valid JSON, passing through: {err}" + ); + return Ok(Response::from_parts(parts, Body::from(bytes))); + } + }; + + let mut removed_versions: Vec = vec![]; + + if let Some(versions) = json.get_mut("versions").and_then(|v| v.as_array_mut()) { + versions.retain(|v| { + let Some(version_str) = v.as_str() else { + return true; + }; + let Ok(version) = PragmaticSemver::parse(version_str) else { + return true; + }; + + if released_package_list.is_recently_released( + &LowerCasePackageName::from(package_name.clone()), + Some(&PackageVersion::Semver(version)), + cutoff_secs, + ) { + removed_versions.push(version_str.to_string()); + tracing::info!("{package_name}@{version_str} was removed from the nuget meta response because it was recently released."); + false + } else { + true + } + }); + } + + if removed_versions.is_empty() { + return Ok(Response::from_parts(parts, Body::from(bytes))); + } else { + if let Some(notifier) = &self.notifier { + let event = MinPackageAgeEvent { + ts_ms: SystemTimestampMilliseconds::now(), + artifact: Artifact { + product: NUGET_PRODUCT_KEY, + identifier: package_name.clone(), + display_name: Some(package_name), + version: None, + }, + suppressed_versions: removed_versions + .iter() + .filter_map(|v| v.parse().ok()) + .collect(), + }; + notifier.notify_min_package_age(event).await; + } + } + + let new_bytes = + serde_json::to_vec(&json).context("serialize modified nuget index response")?; + + make_response_uncacheable(&mut parts.headers); + + Ok(Response::from_parts(parts, Body::from(new_bytes))) + } +} diff --git a/proxy-lib/src/http/firewall/rule/nuget/min_package_age/mod.rs b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/mod.rs new file mode 100644 index 00000000..485a9305 --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/mod.rs @@ -0,0 +1,90 @@ +use rama::{ + error::BoxError, + http::{ + Response, Uri, + headers::{ContentType, HeaderMapExt as _}, + }, +}; + +use crate::{ + http::{ + KnownContentType, + firewall::{ + notifier::EventNotifier, + rule::nuget::{ + NugetRemoteReleasedPackageList, + min_package_age::{catalog_list::CatalogList, flat_version_list::FlatVersionList}, + }, + }, + }, + utils::time::SystemTimestampMilliseconds, +}; + +mod catalog_list; +mod flat_version_list; +#[cfg(test)] +mod tests; + +pub(in crate::http::firewall) struct MinPackageAgeNuget { + remote_released_packages_list: NugetRemoteReleasedPackageList, + flat_version_list: FlatVersionList, + catalog_list: CatalogList, +} + +impl MinPackageAgeNuget { + pub fn new( + remote_released_packages_list: NugetRemoteReleasedPackageList, + notifier: Option, + ) -> MinPackageAgeNuget { + Self { + remote_released_packages_list, + flat_version_list: FlatVersionList { + notifier: notifier.clone(), + }, + catalog_list: CatalogList { notifier }, + } + } + + pub async fn remove_new_packages( + &self, + resp: Response, + req_uri: &Uri, + cut_off_secs: SystemTimestampMilliseconds, + ) -> Result { + if resp + .headers() + .typed_get::() + .clone() + .and_then(KnownContentType::detect_from_content_type_header) + != Some(KnownContentType::Json) + { + return Ok(resp); + } + + if let Some(package_name) = self.flat_version_list.match_uri(req_uri) { + return self + .flat_version_list + .remove_new_packages( + resp, + package_name, + &self.remote_released_packages_list, + cut_off_secs, + ) + .await; + } + + if let Some(package_name) = self.catalog_list.match_uri(req_uri) { + return self + .catalog_list + .remove_new_packages( + resp, + package_name, + &self.remote_released_packages_list, + cut_off_secs, + ) + .await; + } + + Ok(resp) + } +} diff --git a/proxy-lib/src/http/firewall/rule/nuget/min_package_age/tests.rs b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/tests.rs new file mode 100644 index 00000000..79ec764e --- /dev/null +++ b/proxy-lib/src/http/firewall/rule/nuget/min_package_age/tests.rs @@ -0,0 +1,367 @@ +use rama::http::{Body, BodyExtractExt as _, Response, Uri}; + +use crate::{ + package::{ + name_formatter::LowerCasePackageName, + released_packages_list::{ReleasedPackageData, RemoteReleasedPackagesList}, + }, + utils::time::{SystemDuration, SystemTimestampMilliseconds}, +}; + +use super::catalog_list::CatalogList; +use super::flat_version_list::FlatVersionList; + +// FlatVersionList matches GET /v3-flatcontainer/{package}/index.json + +#[test] +fn test_flat_version_list_match_uri_returns_package_name() { + let uri = Uri::from_static( + "https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging/index.json", + ); + assert_eq!( + FlatVersionList { notifier: None }.match_uri(&uri), + Some("microsoft.extensions.logging".into()) + ); +} + +#[test] +fn test_flat_version_list_match_uri_no_match_for_package_download() { + let uri = Uri::from_static( + "https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging/9.0.1/microsoft.extensions.logging.9.0.1.nupkg", + ); + assert_eq!(FlatVersionList { notifier: None }.match_uri(&uri), None); +} + +#[test] +fn test_flat_version_list_match_uri_no_match_for_wrong_base_path() { + let uri = Uri::from_static( + "https://api.nuget.org/v3/registration5-gz-semver2/microsoft.extensions.logging/index.json", + ); + assert_eq!(FlatVersionList { notifier: None }.match_uri(&uri), None); +} + +// CatalogList matches GET /v3/registration5-gz-semver2/{package}/... + +#[test] +fn test_catalog_list_match_uri_returns_package_name_for_index() { + let uri = Uri::from_static( + "https://api.nuget.org/v3/registration5-gz-semver2/microsoft.extensions.logging/index.json", + ); + assert_eq!( + CatalogList { notifier: None }.match_uri(&uri), + Some("microsoft.extensions.logging".into()) + ); +} + +#[test] +fn test_catalog_list_match_uri_returns_package_name_for_page_request() { + let uri = Uri::from_static( + "https://api.nuget.org/v3/registration5-gz-semver2/microsoft.extensions.logging/page/9.0.1/11.0.0-preview.3.26207.106.json", + ); + assert_eq!( + CatalogList { notifier: None }.match_uri(&uri), + Some("microsoft.extensions.logging".into()) + ); +} + +#[test] +fn test_catalog_list_match_uri_no_match_for_wrong_base_path() { + let uri = Uri::from_static( + "https://api.nuget.org/v3-flatcontainer/microsoft.extensions.logging/index.json", + ); + assert_eq!(CatalogList { notifier: None }.match_uri(&uri), None); +} + +// FlatVersionList::remove_new_packages + +fn make_json_response(body: &str) -> Response { + Response::builder() + .header("content-type", "application/json") + .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 removes_recent_version_from_flat_index() { + let body = serde_json::json!({ "versions": ["1.0.0", "2.0.0"] }).to_string(); + let list = make_released_packages(&[("my-package", "2.0.0", 1), ("my-package", "1.0.0", 72)]); + + let result = FlatVersionList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!(result_json["versions"], serde_json::json!(["1.0.0"])); +} + +#[tokio::test] +async fn keeps_all_versions_when_none_are_recent() { + let body = serde_json::json!({ "versions": ["1.0.0", "2.0.0"] }).to_string(); + let list = make_released_packages(&[("my-package", "1.0.0", 72), ("my-package", "2.0.0", 96)]); + + let result = FlatVersionList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!( + result_json["versions"], + serde_json::json!(["1.0.0", "2.0.0"]) + ); +} + +#[tokio::test] +async fn passthrough_invalid_json_body() { + let list = make_released_packages(&[]); + + let result = FlatVersionList { notifier: None } + .remove_new_packages( + make_json_response("not valid json {{{"), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + + assert_eq!( + result.try_into_string().await.unwrap(), + "not valid json {{{" + ); +} + +#[tokio::test] +async fn passthrough_json_without_versions_field() { + let body = serde_json::json!({ "other": "data" }).to_string(); + let list = make_released_packages(&[("my-package", "1.0.0", 1)]); + + let result = FlatVersionList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!(result_json["other"], "data"); + assert!(result_json.get("versions").is_none()); +} + +#[tokio::test] +async fn strips_cache_headers_when_versions_removed() { + let body = serde_json::json!({ "versions": ["1.0.0", "2.0.0"] }).to_string(); + let list = make_released_packages(&[("my-package", "2.0.0", 1)]); + let resp = Response::builder() + .header("content-type", "application/json") + .header("etag", "abc123") + .header("last-modified", "Wed, 01 Jan 2020 00:00:00 GMT") + .header("cache-control", "max-age=3600") + .body(Body::from(body)) + .unwrap(); + + let result = FlatVersionList { notifier: None } + .remove_new_packages(resp, "my-package".into(), &list, default_cutoff_ts()) + .await + .unwrap(); + + assert!(result.headers().get("etag").is_none()); + assert!(result.headers().get("last-modified").is_none()); + assert_eq!(result.headers().get("cache-control").unwrap(), "no-cache"); +} + +#[tokio::test] +async fn keeps_unparseable_version_strings() { + let body = serde_json::json!({ "versions": ["1.0.0", "not-a-semver"] }).to_string(); + let list = make_released_packages(&[("my-package", "1.0.0", 1)]); + + let result = FlatVersionList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!(result_json["versions"], serde_json::json!(["not-a-semver"])); +} + +// CatalogList::remove_new_packages + +fn package_item(id: &str, version: &str) -> serde_json::Value { + serde_json::json!({ + "@type": "Package", + "catalogEntry": { "id": id, "version": version } + }) +} + +#[tokio::test] +async fn catalog_list_removes_recent_package_from_direct_items() { + let body = serde_json::json!({ + "count": 2, + "items": [ + package_item("my-package", "1.0.0"), + package_item("my-package", "2.0.0"), + ] + }) + .to_string(); + let list = make_released_packages(&[("my-package", "2.0.0", 1), ("my-package", "1.0.0", 72)]); + + let result = CatalogList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!(result_json["count"], 1); + assert_eq!(result_json["items"].as_array().unwrap().len(), 1); + assert_eq!(result_json["items"][0]["catalogEntry"]["version"], "1.0.0"); +} + +#[tokio::test] +async fn catalog_list_removes_recent_package_from_nested_page() { + let body = serde_json::json!({ + "count": 1, + "items": [{ + "@type": "catalog:CatalogPage", + "count": 2, + "items": [ + package_item("my-package", "1.0.0"), + package_item("my-package", "2.0.0"), + ] + }] + }) + .to_string(); + let list = make_released_packages(&[("my-package", "2.0.0", 1), ("my-package", "1.0.0", 72)]); + + let result = CatalogList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!(result_json["count"], 1); + let page = &result_json["items"][0]; + assert_eq!(page["count"], 1); + assert_eq!(page["items"].as_array().unwrap().len(), 1); + assert_eq!(page["items"][0]["catalogEntry"]["version"], "1.0.0"); +} + +#[tokio::test] +async fn catalog_list_keeps_all_packages_when_none_are_recent() { + let body = serde_json::json!({ + "count": 2, + "items": [ + package_item("my-package", "1.0.0"), + package_item("my-package", "2.0.0"), + ] + }) + .to_string(); + let list = make_released_packages(&[("my-package", "1.0.0", 72), ("my-package", "2.0.0", 96)]); + + let result = CatalogList { notifier: None } + .remove_new_packages( + make_json_response(&body), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + let result_json: serde_json::Value = result.try_into_json().await.unwrap(); + + assert_eq!(result_json["count"], 2); + assert_eq!(result_json["items"].as_array().unwrap().len(), 2); +} + +#[tokio::test] +async fn catalog_list_passthrough_invalid_json_body() { + let list = make_released_packages(&[]); + + let result = CatalogList { notifier: None } + .remove_new_packages( + make_json_response("not valid json {{{"), + "my-package".into(), + &list, + default_cutoff_ts(), + ) + .await + .unwrap(); + + assert_eq!( + result.try_into_string().await.unwrap(), + "not valid json {{{" + ); +} + +#[tokio::test] +async fn catalog_list_strips_cache_headers() { + let body = serde_json::json!({ + "count": 1, + "items": [package_item("my-package", "2.0.0")] + }) + .to_string(); + let list = make_released_packages(&[("my-package", "2.0.0", 1)]); + let resp = Response::builder() + .header("content-type", "application/json") + .header("etag", "abc123") + .header("cache-control", "max-age=3600") + .body(Body::from(body)) + .unwrap(); + + let result = CatalogList { notifier: None } + .remove_new_packages(resp, "my-package".into(), &list, default_cutoff_ts()) + .await + .unwrap(); + + assert!(result.headers().get("etag").is_none()); + assert_eq!(result.headers().get("cache-control").unwrap(), "no-cache"); +} diff --git a/proxy-lib/src/http/firewall/rule/nuget/mod.rs b/proxy-lib/src/http/firewall/rule/nuget/mod.rs index 3b12594b..dd928c48 100644 --- a/proxy-lib/src/http/firewall/rule/nuget/mod.rs +++ b/proxy-lib/src/http/firewall/rule/nuget/mod.rs @@ -3,9 +3,11 @@ use std::fmt; use rama::{ Service, error::{BoxError, ErrorContext as _, extra::OpaqueError}, + extensions::ExtensionsRef, graceful::ShutdownGuard, http::{ Request, Response, Uri, + proto::RequestExtensions, ws::handshake::mitm::{WebSocketRelayDirection, WebSocketRelayOutput}, }, net::address::Domain, @@ -17,10 +19,16 @@ use crate::{ endpoint_protection::{ EcosystemKey, PackagePolicyDecision, PolicyEvaluator, RemoteEndpointConfig, }, - http::firewall::{ - domain_matcher::DomainMatcher, - events::{Artifact, BlockReason}, - rule::{BlockedRequest, RequestAction, Rule}, + http::{ + RequestMetaUri, + firewall::{ + domain_matcher::DomainMatcher, + events::{Artifact, BlockReason}, + notifier::EventNotifier, + rule::{ + BlockedRequest, RequestAction, Rule, nuget::min_package_age::MinPackageAgeNuget, + }, + }, }, package::{ malware_list::RemoteMalwareList, @@ -42,10 +50,13 @@ type NugetRemoteReleasedPackageList = RemoteReleasedPackagesList, policy_evaluator: Option>, } @@ -55,6 +66,7 @@ impl RuleNuget { remote_malware_list_https_client: C, sync_storage: SyncCompactDataStorage, remote_endpoint_config: Option, + notifier: Option, ) -> Result where C: Service + Clone + Send + 'static, @@ -84,7 +96,11 @@ impl RuleNuget { Ok(Self { target_domains: ["api.nuget.org", "www.nuget.org"].into_iter().collect(), remote_malware_list, - remote_released_packages_list, + remote_released_packages_list: remote_released_packages_list.clone(), + maybe_min_package_age: Some(MinPackageAgeNuget::new( + remote_released_packages_list, + notifier, + )), policy_evaluator, }) } @@ -189,7 +205,22 @@ impl Rule for RuleNuget { #[inline(always)] async fn evaluate_response(&self, resp: Response) -> Result { - Ok(resp) + let Some(min_package_age) = &self.maybe_min_package_age else { + return Ok(resp); + }; + let Some(req_uri) = resp + .extensions() + .get_ref::() + .and_then(|ext| ext.get_ref().map(|RequestMetaUri(uri)| uri.clone())) + else { + return Ok(resp); + }; + + let cutoff_secs = self.get_package_age_cutoff_ts(); + + min_package_age + .remove_new_packages(resp, &req_uri, cutoff_secs) + .await } #[inline(always)] @@ -200,6 +231,14 @@ impl Rule for RuleNuget { ) -> Result { Ok(data) } + + #[inline(always)] + fn match_http_response_payload_inspection_request( + &self, + _: super::HttpRequestMatcherView<'_>, + ) -> bool { + self.maybe_min_package_age.is_some() + } } impl RuleNuget {