Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions proxy-bin-l7/src/client/mock_server/malware_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub(super) fn web_svc() -> impl Service<Request, Output = Response, Error = Infa
.with_get("malware_maven.json", malware_maven)
.with_get("malware_open_vsx.json", malware_open_vsx)
.with_get("malware_skills_sh.json", malware_skills_sh)
.with_get("malware_packagist.json", malware_packagist)
.with_get("releases/vscode.json", released_vscode)
.with_get("releases/open_vsx.json", released_open_vsx)
.with_get("releases/npm.json", released_npm)
Expand All @@ -35,6 +36,7 @@ pub(super) fn web_svc() -> impl Service<Request, Output = Response, Error = Infa
.with_get("releases/nuget.json", released_nuget)
.with_get("releases/skills_sh.json", released_skills_sh)
.with_get("releases/chrome.json", released_chrome)
.with_get("releases/packagist.json", released_packagist)
}

pub const FRESH_VSCODE_EXTENSION_PUBLISHER: &str = "newpublisher";
Expand Down Expand Up @@ -250,3 +252,27 @@ async fn malware_open_vsx() -> impl IntoResponse {
reason: Reason::Malware,
}])
}

pub const MALWARE_PACKAGIST_VENDOR: &str = "safechain";
pub const MALWARE_PACKAGIST_PACKAGE: &str = "packagist-test";
pub const MALWARE_PACKAGIST_VERSION: &str = "1.0.0";
async fn malware_packagist() -> impl IntoResponse {
Json([ListDataEntry {
package_name: format!("{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}"),
version: PackageVersion::Semver(PragmaticSemver::new_semver(1, 0, 0)),
reason: Reason::Malware,
}])
}

pub const FRESH_PACKAGIST_VENDOR: &str = "safechain";
pub const FRESH_PACKAGIST_PACKAGE: &str = "fresh-packagist-pkg";
pub const FRESH_PACKAGIST_VERSION: &str = "2.0.0";
async fn released_packagist() -> impl IntoResponse {
// Timestamp far in the future (year ~2255) so this entry is always "recently released"
// relative to any realistic `now - 48h` cutoff used in tests.
Json([ReleasedPackageData {
package_name: format!("{FRESH_PACKAGIST_VENDOR}/{FRESH_PACKAGIST_PACKAGE}"),
version: PackageVersion::Semver(PragmaticSemver::parse(FRESH_PACKAGIST_VERSION).unwrap()),
released_on: 9_000_000_000,
}])
}
5 changes: 5 additions & 0 deletions proxy-bin-l7/src/client/mock_server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ mod assert_endpoint;
mod endpoint_protection_callbacks;
pub mod malware_list;
mod npm_registry;
mod packagist_registry;
mod pypi_registry;
mod vscode_marketplace;

Expand Down Expand Up @@ -128,6 +129,10 @@ fn new_mock_server() -> impl Service<Request, Output = Response, Error = Infalli
HttpMatcher::domain(Domain::from_static("pypi.org")),
self::pypi_registry::web_svc(),
)
.with_matcher(
HttpMatcher::domain(Domain::from_static("repo.packagist.org")),
self::packagist_registry::web_svc(),
)
.with_matcher(
HttpMatcher::domain(app_domain),
self::endpoint_protection_callbacks::web_svc(),
Expand Down
130 changes: 130 additions & 0 deletions proxy-bin-l7/src/client/mock_server/packagist_registry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use std::convert::Infallible;

use rama::{
Service,
http::{
Request, Response, StatusCode,
service::web::response::{IntoResponse, Json},
},
service::service_fn,
};
use serde_json::json;

use super::malware_list::{
FRESH_PACKAGIST_PACKAGE, FRESH_PACKAGIST_VENDOR, FRESH_PACKAGIST_VERSION,
MALWARE_PACKAGIST_PACKAGE, MALWARE_PACKAGIST_VENDOR, MALWARE_PACKAGIST_VERSION,
};

pub(super) fn web_svc() -> impl Service<Request, Output = Response, Error = Infallible> + Clone {
service_fn(handle)
}

/// Handles Packagist v2 metadata requests for the mock server.
///
/// Serves `/p2/vendor/package.json` responses in Composer 2.x minified format.
/// Known test packages carry specific version entries used by the e2e firewall tests;
/// all other paths return `200 OK` with an empty packages object.
async fn handle(req: Request) -> Result<Response, Infallible> {
let path = req.uri().path();

let malware_p2_path =
format!("/p2/{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}.json");
let fresh_p2_path = format!("/p2/{FRESH_PACKAGIST_VENDOR}/{FRESH_PACKAGIST_PACKAGE}.json");

if path == malware_p2_path {
// Version 1.0.0 is in the malware list; 0.9.0 is safe and old.
let body = json!({
"minified": "composer/2.0",
"packages": {
format!("{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}"): [
{
"name": format!("{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}"),
"description": "Test package for SafeChain malware blocking",
"version": MALWARE_PACKAGIST_VERSION,
"version_normalized": "1.0.0.0",
"dist": {
"url": "https://api.github.com/repos/test/test/zipball/abc",
"type": "zip",
"reference": "abc",
"shasum": ""
},
"source": {
"url": "https://github.com/test/test.git",
"type": "git",
"reference": "abc"
},
"time": "2020-06-01T00:00:00+00:00",
"require": {"php": "^8.0"}
},
{
"version": "0.9.0",
"version_normalized": "0.9.0.0",
"dist": {
"url": "https://api.github.com/repos/test/test/zipball/def",
"type": "zip",
"reference": "def",
"shasum": ""
},
"source": {
"url": "https://github.com/test/test.git",
"type": "git",
"reference": "def"
},
"time": "2020-01-01T00:00:00+00:00"
}
]
}
});
return Ok(Json(body).into_response());
}

if path == fresh_p2_path {
// Version 2.0.0 has a far-future `time` (always "too new"); 1.0.0 is old and safe.
let body = json!({
"minified": "composer/2.0",
"packages": {
format!("{FRESH_PACKAGIST_VENDOR}/{FRESH_PACKAGIST_PACKAGE}"): [
{
"name": format!("{FRESH_PACKAGIST_VENDOR}/{FRESH_PACKAGIST_PACKAGE}"),
"description": "Test package for SafeChain min-age blocking",
"version": FRESH_PACKAGIST_VERSION,
"version_normalized": "2.0.0.0",
"dist": {
"url": "https://api.github.com/repos/test/fresh/zipball/ghi",
"type": "zip",
"reference": "ghi",
"shasum": ""
},
"source": {
"url": "https://github.com/test/fresh.git",
"type": "git",
"reference": "ghi"
},
// Far-future timestamp so this is always considered "recently released".
"time": "2255-01-01T00:00:00+00:00",
"require": {"php": "^8.1"}
},
{
"version": "1.0.0",
"version_normalized": "1.0.0.0",
"dist": {
"url": "https://api.github.com/repos/test/fresh/zipball/jkl",
"type": "zip",
"reference": "jkl",
"shasum": ""
},
"source": {
"url": "https://github.com/test/fresh.git",
"type": "git",
"reference": "jkl"
},
"time": "2020-01-01T00:00:00+00:00"
}
]
}
});
return Ok(Json(body).into_response());
}

Ok(StatusCode::OK.into_response())
}
197 changes: 197 additions & 0 deletions proxy-bin-l7/src/test/e2e/test_proxy/firewall_packagist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use rama::{
http::{BodyExtractExt as _, StatusCode, service::client::HttpClientExt as _},
telemetry::tracing,
};

use crate::{
client::mock_server::malware_list::{
FRESH_PACKAGIST_PACKAGE, FRESH_PACKAGIST_VENDOR, FRESH_PACKAGIST_VERSION,
MALWARE_PACKAGIST_PACKAGE, MALWARE_PACKAGIST_VENDOR, MALWARE_PACKAGIST_VERSION,
},
test::e2e,
};

fn versions_in(body: &serde_json::Value, vendor: &str, package: &str) -> Vec<String> {
body["packages"][format!("{vendor}/{package}")]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|v| v["version"].as_str().map(str::to_owned))
.collect()
}

// --- malware version suppression ---

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_malware_version_removed_from_metadata() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

let resp = client
.get(format!(
"https://repo.packagist.org/p2/{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}.json"
))
.send()
.await
.unwrap();

assert_eq!(
StatusCode::OK,
resp.status(),
"proxy must not block metadata requests"
);
let body: serde_json::Value = resp.try_into_json().await.unwrap();
let versions = versions_in(&body, MALWARE_PACKAGIST_VENDOR, MALWARE_PACKAGIST_PACKAGE);

assert!(
!versions.contains(&MALWARE_PACKAGIST_VERSION.to_owned()),
"malware version {MALWARE_PACKAGIST_VERSION} must be absent from rewritten metadata; got: {versions:?}"
);
assert!(
versions.contains(&"0.9.0".to_owned()),
"safe version 0.9.0 must remain in rewritten metadata; got: {versions:?}"
);
}

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_malware_version_removed_via_http() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

let resp = client
.get(format!(
"http://repo.packagist.org/p2/{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}.json"
))
.send()
.await
.unwrap();

assert_eq!(StatusCode::OK, resp.status());
let body: serde_json::Value = resp.try_into_json().await.unwrap();
let versions = versions_in(&body, MALWARE_PACKAGIST_VENDOR, MALWARE_PACKAGIST_PACKAGE);

assert!(!versions.contains(&MALWARE_PACKAGIST_VERSION.to_owned()));
}

// --- min-age version suppression ---

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_new_package_version_removed_from_metadata() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

let resp = client
.get(format!(
"https://repo.packagist.org/p2/{FRESH_PACKAGIST_VENDOR}/{FRESH_PACKAGIST_PACKAGE}.json"
))
.send()
.await
.unwrap();

assert_eq!(
StatusCode::OK,
resp.status(),
"proxy must not block metadata requests"
);
let body: serde_json::Value = resp.try_into_json().await.unwrap();
let versions = versions_in(&body, FRESH_PACKAGIST_VENDOR, FRESH_PACKAGIST_PACKAGE);

assert!(
!versions.contains(&FRESH_PACKAGIST_VERSION.to_owned()),
"too-new version {FRESH_PACKAGIST_VERSION} must be absent; got: {versions:?}"
);
assert!(
versions.contains(&"1.0.0".to_owned()),
"older version 1.0.0 must remain; got: {versions:?}"
);
}

// --- clean package passthrough ---

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_clean_package_passes_through() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

// Any package not in the malware list or releases list passes through unchanged.
let resp = client
.get("https://repo.packagist.org/p2/vendor/clean-package.json")
.send()
.await
.unwrap();

assert_eq!(StatusCode::OK, resp.status());
}

// --- dev variant path ---

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_dev_variant_path_handled() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

// The ~dev.json path must also be intercepted and rewritten.
let resp = client
.get(format!(
"https://repo.packagist.org/p2/{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}~dev.json"
))
.send()
.await
.unwrap();

// Mock server returns 200 for unknown paths; the proxy should not block it.
assert_eq!(StatusCode::OK, resp.status());
}

// --- non-packagist path passthrough ---

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_unrelated_path_passes_through() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

// /packages.json is not a package-specific metadata endpoint; proxy should not rewrite it.
let resp = client
.get("https://repo.packagist.org/packages.json")
.send()
.await
.unwrap();

assert_eq!(StatusCode::OK, resp.status());
}

// --- de-minification: inherited fields survive ---

#[tokio::test]
#[tracing_test::traced_test]
async fn test_packagist_inherited_require_present_after_rewrite() {
let runtime = e2e::runtime::get().await;
let client = runtime.client_with_http_proxy().await;

// The malware version (1.0.0) carries `require`; 0.9.0 inherits it.
// After rewriting (removing 1.0.0), 0.9.0 must still expose `require`.
let resp = client
.get(format!(
"https://repo.packagist.org/p2/{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}.json"
))
.send()
.await
.unwrap();

assert_eq!(StatusCode::OK, resp.status());
let body: serde_json::Value = resp.try_into_json().await.unwrap();
let pkg_key = format!("{MALWARE_PACKAGIST_VENDOR}/{MALWARE_PACKAGIST_PACKAGE}");
let remaining = &body["packages"][&pkg_key][0];

assert_eq!(remaining["version"], "0.9.0");
assert!(
!remaining["require"].is_null(),
"require field must be inherited from the removed first entry; got: {remaining}"
);
}
1 change: 1 addition & 0 deletions proxy-bin-l7/src/test/e2e/test_proxy/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod firewall_maven;
mod firewall_npm;
mod firewall_nuget;
mod firewall_open_vsx;
mod firewall_packagist;
mod firewall_pypi;
mod firewall_skills_sh;
mod firewall_vscode;
Expand Down
Loading
Loading