diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index 7113fa392..b1cb2254e 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -352,15 +352,10 @@ fn registry_host(source: &str) -> Option { let url = source .strip_prefix("sparse+") .or_else(|| source.strip_prefix("registry+"))?; - let (_, rest) = url.split_once("://")?; - let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())]; - let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - let host = if hostport.starts_with('[') { - &hostport[..=hostport.find(']')?] - } else { - hostport.split(':').next()? - }; - (!host.is_empty()).then(|| host.to_ascii_lowercase()) + if !url.contains("://") { + return None; + } + socket_patch_core::utils::redact::url_hostname(url).map(str::to_ascii_lowercase) } /// The `-` name of a `…/registry/src/-` source dir, diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs index 6cb95883c..bad589efa 100644 --- a/crates/socket-patch-cli/src/update_notifier.rs +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -146,7 +146,10 @@ pub struct Notifier { fn debug_log(debug: bool, message: &str) { if debug { - eprintln!("[socket-patch update] {message}"); + eprintln!( + "[socket-patch update] {}", + socket_patch_core::utils::redact::redact_urls_in(message) + ); } } diff --git a/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs b/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs index 1d48d1e72..d62680ff6 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_wheel_metadata_order.rs @@ -393,10 +393,10 @@ async fn wheel_metadata_failures_fold_in_dep_order() { for s in doc["redirect"]["skipped"].as_array().unwrap() { if s["reason"] == "python_metadata_unavailable" { let detail = s["detail"].as_str().unwrap(); - assert!( - !detail.contains(&server.uri()), - "the hosted URL is redacted from the detail: {detail}" - ); + // The detail quotes the URL through the shared redactor (this + // fixture's `/wheels/` URL carries no grant token; core's + // `hosted_skip_details_never_carry_the_grant_token` pins it). + assert!(!detail.is_empty(), "the detail names the failure: {doc:#}"); } } // The two good wheels still redirect; the refused two stay upstream. diff --git a/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs b/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs index 30ab5207b..b4736c7e4 100644 --- a/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs +++ b/crates/socket-patch-cli/tests/scan/hosted_yarn_berry_manifest.rs @@ -235,9 +235,10 @@ async fn unfetchable_served_manifest_skips_the_patch() { assert_eq!(skipped.len(), 1, "{doc:#}"); assert_eq!(skipped[0]["purl"], PURL, "{doc:#}"); let detail = skipped[0]["detail"].as_str().unwrap(); + // The URL is quoted with its grant-token level redacted. assert!( - !detail.contains(&server.uri()), - "the hosted URL is redacted: {detail}" + !detail.contains(TOKEN) && detail.contains(&format!("//{UUID}/")), + "the grant token is redacted: {detail}" ); assert_eq!(doc["redirect"]["redirected"], 0, "{doc:#}"); assert_eq!( diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index d12a0821c..9f824a83d 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -21,8 +21,9 @@ use crate::api::vendor_prefetch::VendorPrefetch; pub use crate::api::vendor_prefetch::VendorPrefetchGuard; use crate::constants::USER_AGENT as USER_AGENT_VALUE; use crate::utils::digest::is_hex; -use crate::utils::env_compat::{is_debug_enabled, is_offline_env, proxy_url_from_env}; +use crate::utils::env_compat::{is_offline_env, proxy_url_from_env}; use crate::utils::notice::{notice_once, Notice}; +use crate::utils::redact::{redact_url, redact_urls_in}; use crate::utils::socket_cli_config; use crate::utils::target::is_uuid_shaped; @@ -38,10 +39,12 @@ static MULTI_ORG_SHOWN: AtomicBool = AtomicBool::new(false); /// act on ("Connection refused", a DNS or TLS failure). Causes already /// spelled out by an outer message are skipped. fn network_error_detail(e: &reqwest::Error) -> String { - let mut msg = e.to_string(); + // reqwest's text names the request URL: a grant URL, or one with + // userinfo from `--api-url` / a proxy, is quoted redacted. + let mut msg = redact_urls_in(&e.to_string()).into_owned(); let mut source = std::error::Error::source(e); while let Some(cause) = source { - let part = cause.to_string(); + let part = redact_urls_in(&cause.to_string()).into_owned(); if !part.is_empty() && !msg.contains(&part) { msg.push_str(": "); msg.push_str(&part); @@ -95,9 +98,14 @@ fn status_error(head: &str, status: StatusCode, text: &str) -> String { } } -/// Log debug messages when debug mode is enabled. +/// Log debug messages when debug mode is enabled. Every URL in `message` +/// is redacted first: debug lines quote grant URLs and `--api-url`s, and +/// debug output is routinely pasted into CI logs and bug reports. fn debug_log(message: &str) { - if is_debug_enabled() && !defer_debug_line(message) { + let Some(message) = crate::utils::env_compat::debug_message(message) else { + return; + }; + if !defer_debug_line(&message) { eprintln!("[socket-patch debug] {}", message); } } @@ -1694,7 +1702,8 @@ impl ApiClient { if !(url.starts_with("https://") || url.starts_with("http://")) { return ( ServeDownload::Failed(ApiError::Other(format!( - "refusing non-http(s) artifact URL `{url}`" + "refusing non-http(s) artifact URL `{}`", + redact_url(url) ))), None, ); @@ -1843,8 +1852,14 @@ pub(crate) struct DeferredAttempt { fn artifact_download_result(outcome: ServeDownload, url: &str) -> Result, ApiError> { match outcome { ServeDownload::Ok(bytes) => Ok(bytes), - ServeDownload::NotFound => Err(ApiError::Other(format!("artifact not found: {url}"))), - ServeDownload::Pending => Err(ApiError::Other(format!("artifact still building: {url}"))), + ServeDownload::NotFound => Err(ApiError::Other(format!( + "artifact not found: {}", + redact_url(url) + ))), + ServeDownload::Pending => Err(ApiError::Other(format!( + "artifact still building: {}", + redact_url(url) + ))), ServeDownload::Failed(e) => Err(e), } } @@ -2831,7 +2846,8 @@ impl ApiClient { ) -> Result, ApiError> { if !(url.starts_with("https://") || url.starts_with("http://")) { return Err(ApiError::Other(format!( - "refusing non-http(s) artifact URL `{url}`" + "refusing non-http(s) artifact URL `{}`", + redact_url(url) ))); } let attempts = self.vendor_retry.attempts.max(1); @@ -2883,13 +2899,19 @@ impl ApiClient { StatusCode::OK => {} StatusCode::NOT_FOUND | StatusCode::GONE => { return ( - Err(ApiError::Other(format!("artifact not found: {url}"))), + Err(ApiError::Other(format!( + "artifact not found: {}", + redact_url(url) + ))), None, ) } StatusCode::REQUEST_TIMEOUT => { return ( - Err(ApiError::Other(format!("artifact still building: {url}"))), + Err(ApiError::Other(format!( + "artifact still building: {}", + redact_url(url) + ))), None, ) } @@ -5121,6 +5143,36 @@ mod vendor_package_tests { } } + /// The artifact errors quote the grant URL redacted: neither the grant + /// token nor userinfo reaches the error a caller shows. + #[tokio::test] + async fn download_artifact_errors_never_carry_a_credential() { + const GRANT: &str = "grant-level"; + const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + let grant_path = format!("/patch/pypi/a/1.0.0/{GRANT}/{UUID}/a.whl"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(grant_path.as_str())) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let client = auth_client(server.uri()); + for url in [ + format!("ftp://u:pw@h.example{grant_path}"), + format!( + "{}{grant_path}", + server.uri().replace("http://", "http://u:pw@") + ), + ] { + let msg = match client.download_artifact(&url).await { + Err(ApiError::Other(msg)) => msg, + other => panic!("expected Other, got {other:?}"), + }; + assert!(!msg.contains(GRANT) && !msg.contains("u:pw"), "{msg}"); + assert!(msg.contains(&format!("//{UUID}/")), "{msg}"); + } + } + /// A promised secondary that 404s must ERROR ("artifact not found"), /// and a still-building 408 maps to "artifact still building" — the /// arms encoding the no-soft-skip policy for promised artifacts. diff --git a/crates/socket-patch-core/src/crawlers/jvm_cache.rs b/crates/socket-patch-core/src/crawlers/jvm_cache.rs index a90cec65f..adecae1b7 100644 --- a/crates/socket-patch-core/src/crawlers/jvm_cache.rs +++ b/crates/socket-patch-core/src/crawlers/jvm_cache.rs @@ -138,9 +138,7 @@ pub fn push_classified(roots: &mut Vec, root: JvmCacheRoot) -> boo /// A `SOCKET_DEBUG` line from the JVM cache discovery. pub(crate) fn debug_log(message: &str) { - if crate::utils::env_compat::is_debug_enabled() { - eprintln!("[socket-patch debug] {message}"); - } + crate::utils::env_compat::debug_log("debug", message); } /// One installed-artifact cache to crawl. diff --git a/crates/socket-patch-core/src/formats/composer/hosted.rs b/crates/socket-patch-core/src/formats/composer/hosted.rs index 8fcdfb6f1..44b7ce002 100644 --- a/crates/socket-patch-core/src/formats/composer/hosted.rs +++ b/crates/socket-patch-core/src/formats/composer/hosted.rs @@ -258,8 +258,9 @@ pub(crate) fn rewrite_composer_lock( }; let rewritten = (!already_redirected).then_some(rewritten); // Drops the entry's `source` wherever it sits and the dist's - // `mirrors` (see `composer_source`); the edit spans both, so the - // ledger's fragment revert restores them byte-for-byte. + // `mirrors` (see `composer_source`); the edit spans both, so a + // ledger fragment revert restores them byte-for-byte (the upstream + // restore does not bring `transport-options` back; it warns). let span = composer_source::DistSpan { entry_start, entry_end, diff --git a/crates/socket-patch-core/src/formats/composer/mod.rs b/crates/socket-patch-core/src/formats/composer/mod.rs index d45156ceb..6e3f8f3e9 100644 --- a/crates/socket-patch-core/src/formats/composer/mod.rs +++ b/crates/socket-patch-core/src/formats/composer/mod.rs @@ -22,6 +22,24 @@ use crate::utils::digest::sha1_hex; use crate::vendor::lock_inventory::{http_url, LockIntegrity, LockfileEntry, SourceKind}; use crate::vendor::path::{parse_vendor_path, VendorPathParts}; +/// The lock-entry members that belong to the package's ORIGINAL repository +/// and must not survive a rewrite that points the entry somewhere else +/// (the hosted patch URL, a vendored path dist): +/// +/// * `source`: the upstream VCS checkout Composer falls back to (see +/// [`source`]); +/// * `transport-options`: Composer copies the repository's `options` +/// (`http.header` auth tokens, `ssl` client certificates, `http.proxy`) +/// into every entry it resolves from it, and applies them to that entry's +/// dist download. Kept on a redirected entry, they send a private +/// registry's credentials to the new host (#399). +/// +/// Both backends drop exactly these: the hosted splice +/// ([`source::apply_dist_edit`]) and the vendored rebuild +/// ([`crate::vendor::composer_lock`]'s `rewrite_lock_entry`, which then +/// writes its own `transport-options`). +pub(crate) const ORIGIN_BOUND_ENTRY_KEYS: [&str; 2] = ["source", "transport-options"]; + // ── entry model ── /// One entry of a parsed `composer.lock` (see [`composer_lock_packages`]). diff --git a/crates/socket-patch-core/src/formats/composer/source.rs b/crates/socket-patch-core/src/formats/composer/source.rs index 98e459ae2..03ab2fc20 100644 --- a/crates/socket-patch-core/src/formats/composer/source.rs +++ b/crates/socket-patch-core/src/formats/composer/source.rs @@ -39,10 +39,11 @@ pub(crate) struct Member { pub value_end: usize, } -/// What to do with the entry's top-level `source` member. +/// What to do with one origin-bound member of the entry +/// ([`super::ORIGIN_BOUND_ENTRY_KEYS`]). #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum SourcePlan { - /// The entry has no top-level `source`. + /// The entry has no such top-level member. None, /// Delete this inclusive byte range (the member plus one adjoining comma). Remove(RangeInclusive), @@ -236,14 +237,21 @@ pub(super) fn member_removal_range( Some(start..=member.value_end) } -/// Plan the drop of the top-level `source` member of the entry object -/// spanning `object_open` to `entry_end`. -pub(super) fn plan_source_drop(content: &str, object_open: usize, entry_end: usize) -> SourcePlan { +/// Plan the drop of the top-level `key` member (one of +/// [`super::ORIGIN_BOUND_ENTRY_KEYS`]) of the entry object spanning +/// `object_open` to `entry_end`. A `source` that is not an object is +/// [`SourcePlan::Kept`]; `transport-options` goes whatever its shape. +pub(super) fn plan_member_drop( + content: &str, + object_open: usize, + entry_end: usize, + key: &str, +) -> SourcePlan { let members = top_level_members(content, object_open, entry_end); - let Some(index) = members.iter().position(|m| m.key == "source") else { + let Some(index) = members.iter().position(|m| m.key == key) else { return SourcePlan::None; }; - if content.as_bytes()[members[index].value_start] != b'{' { + if key == "source" && content.as_bytes()[members[index].value_start] != b'{' { return SourcePlan::Kept; } match member_removal_range(content, &members, index) { @@ -252,6 +260,12 @@ pub(super) fn plan_source_drop(content: &str, object_open: usize, entry_end: usi } } +/// [`plan_member_drop`] for the entry's `source`. +#[cfg(test)] +fn plan_source_drop(content: &str, object_open: usize, entry_end: usize) -> SourcePlan { + plan_member_drop(content, object_open, entry_end, "source") +} + /// `block` (a whole `"dist": {…}` member) without its top-level `mirrors`; /// `None` when it has none. pub(super) fn strip_dist_mirrors(block: &str) -> Option { @@ -283,12 +297,16 @@ pub(super) struct DistSpan { /// Splice the redirected dist (or, when `rewritten_dist` is `None` because /// the dist is already redirected, the current one) into the entry, dropping -/// the entry's top-level `source` and the dist's `mirrors` (warned about when -/// the lock's dist had them, whether or not `rewritten_dist` still does). -/// The recorded edit spans the dist block AND the removed `source` member, -/// so the ledger's fragment revert restores both byte-for-byte. `None` — no -/// edit, no ledger growth — when nothing changes (an idempotent re-run over -/// a healed lock). +/// the dist's `mirrors` and the entry's origin-bound members +/// ([`super::ORIGIN_BOUND_ENTRY_KEYS`]: `source`, `transport-options`), +/// each warned about when the lock had it, whether or not `rewritten_dist` +/// still does. The recorded edit spans the dist block AND every removed +/// member, so a ledger fragment revert restores them byte-for-byte. The +/// upstream restore (`rollback`/`remove` without a ledger) rebuilds only +/// `dist` and `source` from packagist: `transport-options` do not come back, +/// which the warning says and the restore warns about again. +/// `None` — no edit, no ledger growth — when nothing changes (an idempotent +/// re-run over a healed lock). pub(super) fn apply_dist_edit( content: &mut String, span: DistSpan, @@ -314,41 +332,87 @@ pub(super) fn apply_dist_edit( ), }); } - let plan = match entry_object_start(content, entry_start) { - Some(object_open) => plan_source_drop(content, object_open, entry_end), + let object_open = entry_object_start(content, entry_start); + let plan = |text: &str, end: usize, key: &str| match object_open { + Some(open) => plan_member_drop(text, open, end, key), None => SourcePlan::None, }; - if plan == SourcePlan::Kept { - warnings.push(RewriteWarning { - code: "redirect_composer_source_kept".into(), - detail: format!( - "{composer_name}'s source is not an object and was left in place; a failed \ - hosted download may fall back to it" - ), - }); - } - let removal = match plan { - SourcePlan::Remove(range) => Some(range), - SourcePlan::None | SourcePlan::Kept => None, - }; - let (span_start, span_end) = match &removal { - Some(r) => (dist_start.min(*r.start()), dist_end.max(*r.end())), - None => (dist_start, dist_end), - }; - let original = content[span_start..=span_end].to_string(); - let mut pieces: Vec<(usize, usize, &str)> = vec![(dist_start, dist_end, dist.as_str())]; - if let Some(r) = &removal { - pieces.push((*r.start(), *r.end(), "")); + // The span every change falls in: the dist plus each member's own + // removal range in the original text. + let (mut span_start, mut span_end) = (dist_start, dist_end); + for key in super::ORIGIN_BOUND_ENTRY_KEYS { + match plan(content, entry_end, key) { + SourcePlan::Remove(r) => { + span_start = span_start.min(*r.start()); + span_end = span_end.max(*r.end()); + if key == "transport-options" { + warnings.push(RewriteWarning { + code: "redirect_composer_transport_options_removed".into(), + detail: format!( + "{composer_name}'s transport-options were removed; they carry \ + the original repository's download options (auth headers, \ + client certificates, proxy) and Composer would send them to \ + the hosted patch host; rollback cannot restore them, so re-lock \ + {composer_name} at its locked version after removing the patch \ + (a plain `composer update` may also move it and its dependents)" + ), + }); + } + } + SourcePlan::Kept => warnings.push(RewriteWarning { + code: "redirect_composer_source_kept".into(), + detail: format!( + "{composer_name}'s source is not an object and was left in place; a failed \ + hosted download may fall back to it" + ), + }), + SourcePlan::None => {} + } } - pieces.sort_by(|a, b| b.0.cmp(&a.0)); - let mut replacement = original.clone(); - for (start, end, text) in pieces { - replacement.replace_range(start - span_start..=end - span_start, text); + // Every change falls inside the entry object (from its `{`, or the dist + // when there is none, to its closing `}`), so the edits are made on a + // copy of that region alone and spliced back once: the work per + // redirected package is bounded by its entry, not by the lock. + let lo = object_open.map_or(dist_start, |open| open.min(dist_start)); + let region = &content[lo..=entry_end]; + // Splice the dist, then drop the members one at a time, re-scanning + // after each so two adjacent removals never leave a dangling comma. + let mut updated = region.to_string(); + updated.replace_range(dist_start - lo..=dist_end - lo, &dist); + for key in super::ORIGIN_BOUND_ENTRY_KEYS { + let end = (entry_end - lo + updated.len()).saturating_sub(region.len()); + let drop = match object_open { + Some(open) => plan_member_drop(&updated, open - lo, end, key), + None => SourcePlan::None, + }; + if let SourcePlan::Remove(r) = drop { + updated.replace_range(r, ""); + } } - if replacement == original { + if updated == region { return None; } - content.replace_range(span_start..=span_end, &replacement); + // Widen the span to cover every byte that changed (a removal that took + // the comma before it reaches past its own planned range). + let prefix = region + .bytes() + .zip(updated.bytes()) + .take_while(|(a, b)| a == b) + .count(); + let max_suffix = region.len().min(updated.len()) - prefix; + let suffix = region + .bytes() + .rev() + .zip(updated.bytes().rev()) + .take(max_suffix) + .take_while(|(a, b)| a == b) + .count(); + let span_start = span_start.min(lo + prefix); + let span_end = span_end.max(lo + region.len() - suffix - 1); + let new_end = span_end + updated.len() - region.len(); + let original = content[span_start..=span_end].to_string(); + let replacement = updated[span_start - lo..=new_end - lo].to_string(); + content.replace_range(lo..=entry_end, &updated); Some(FileEdit { path: "composer.lock".into(), kind: "redirect_composer_dist".into(), @@ -485,6 +549,69 @@ mod tests { ); } + /// #399 / B02: the repository's `transport-options` (auth headers, + /// client certs, proxy) are dropped with `source`, wherever the two sit + /// relative to each other and to the dist, with valid JSON left behind, + /// one warning, and an edit whose `original` restores both. + #[test] + fn transport_options_are_dropped_with_source_in_any_layout() { + let to = r#""transport-options": {"http": {"header": ["X-Private-Token: s3cret"]}}"#; + let src = r#""source": {"url": "g"}"#; + for members in [ + vec!["NAME", "DIST", src, to], + vec!["NAME", src, to, "DIST"], + vec![to, "NAME", "DIST", src], + vec!["NAME", to, "DIST"], + vec!["NAME", "DIST", to], + ] { + let render = |dist: &str| { + let body: Vec = members + .iter() + .map(|m| match *m { + "NAME" => "\"name\": \"p/q\"".to_string(), + "DIST" => format!("\"dist\": {{\"url\": \"{dist}\"}}"), + other => other.to_string(), + }) + .collect(); + format!("[{{{}}}]", body.join(", ")) + }; + let mut content = render("a"); + let original = content.clone(); + let span = span_of(&content); + let mut warnings = Vec::new(); + let edit = apply_dist_edit( + &mut content, + span, + Some("\"dist\": {\"url\": \"b\"}"), + "p/q", + &mut warnings, + ) + .unwrap(); + let parsed: Value = serde_json::from_str(&content) + .unwrap_or_else(|e| panic!("{members:?}: {e}: {content}")); + let entry = parsed[0].as_object().unwrap(); + let mut keys: Vec<&str> = entry.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, vec!["dist", "name"], "{members:?}"); + assert!(!content.contains("s3cret"), "{content}"); + assert_eq!(entry["dist"]["url"], "b", "{members:?}"); + let codes: Vec<&str> = warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!(codes, vec!["redirect_composer_transport_options_removed"]); + // The fragment revert: swapping `new` back for `original` + // restores the lock byte for byte. + let (Some(Value::String(was)), Some(Value::String(now))) = (edit.original, edit.new) + else { + panic!("string fragments"); + }; + assert_eq!(content.replacen(&now, &was, 1), original, "{members:?}"); + // A re-run over the healed entry is a no-op. + let span = span_of(&content); + let mut again = Vec::new(); + assert!(apply_dist_edit(&mut content, span, None, "p/q", &mut again).is_none()); + assert!(again.is_empty()); + } + } + #[test] fn an_unchanged_span_records_no_edit() { let mut content = "[{\"name\": \"p/q\", \"dist\": {\"url\": \"a\"}}]".to_string(); diff --git a/crates/socket-patch-core/src/formats/gem/mirror.rs b/crates/socket-patch-core/src/formats/gem/mirror.rs index bbc7ebf63..09f69a2bd 100644 --- a/crates/socket-patch-core/src/formats/gem/mirror.rs +++ b/crates/socket-patch-core/src/formats/gem/mirror.rs @@ -54,14 +54,9 @@ fn normalize_source(source: &str) -> String { } fn source_host(source: &str) -> Option<&str> { - let (_, rest) = source.split_once("://")?; - let authority = rest.split(['/', '?', '#']).next()?; - let host_port = authority.rsplit('@').next()?; - if host_port.starts_with('[') { - Some(&host_port[..=host_port.find(']')?]) - } else { - host_port.split(':').next() - } + source + .contains("://") + .then(|| crate::utils::redact::url_hostname(source))? } /// `Settings.key_for` normalizes HTTP(S) keys before re-encoding them. A diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index e1f023d25..1b0c8fe32 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -39,6 +39,7 @@ use crate::patch::redirect::{ }; use crate::utils::pnpm_workspace::governing_workspace_file; use crate::utils::purl::purl_parts; +use crate::utils::redact::url_host; use crate::vendor::lock_inventory::{bun_text_lock_drives, MemoryEntry, ProjectView}; use super::guidance::{ @@ -51,7 +52,7 @@ use super::guidance::{ pnpm_trust_legacy_detail, pnpm_trust_manual_guidance, pnpm_trust_not_needed_detail, pnpm_trust_policy_preamble, pnpm_trust_rush_detail, pnpm_trust_workspace_unreadable_detail, pnpm_trust_workspace_unsupported_detail, read_npmrc_for_allow_remote, read_workspace_for_trust, - url_host, TrustPlan, NPM_LOCKS, NPM_REPLACE_REGISTRY_HOST_CODE, PNPM_TRUST_RUSH_MIXED_NOTE, + TrustPlan, NPM_LOCKS, NPM_REPLACE_REGISTRY_HOST_CODE, PNPM_TRUST_RUSH_MIXED_NOTE, PNPM_TRUST_TRADEOFF_AND_CAUTION, PNPM_WORKSPACE_REL, REDIRECT_PNPM_WORKSPACE_TRUST_EDIT_KIND, }; use super::vlt::bun_lockb_present; @@ -1066,14 +1067,36 @@ pub fn wheel_targets<'a>( out } +/// `text` about one hosted artifact made safe to show: every URL in it +/// through [`crate::utils::redact::redact_urls_in`], and then the grant +/// token of `artifact_url` wherever it is still spelled as a path level. +/// The second pass is what knowing the artifact adds: its token is the +/// level before `patch_uuid`, so a URL served under a root the shape-based +/// redactor does not recognise (a custom `--api-url` server) or with a +/// non-canonical patch id is still covered. +pub fn redact_artifact_text(text: &str, artifact_url: &str, patch_uuid: &str) -> String { + let text = crate::utils::redact::redact_urls_in(text); + match crate::patch::redirect::grant_token_path_segment(artifact_url, patch_uuid) { + Some(token) if token != crate::utils::redact::REDACTED => text.replace( + &format!("/{token}/"), + &format!("/{}/", crate::utils::redact::REDACTED), + ), + _ => text.into_owned(), + } +} + /// The skip recorded for a pypi dep whose wheel metadata could not be -/// fetched (the grant token in `detail` is redacted to ``). +/// fetched (`detail` redacted by [`redact_artifact_text`]). pub fn wheel_metadata_unavailable(dep: &DepOverride, detail: &str) -> SkippedPatch { SkippedPatch { purl: format!("pkg:pypi/{}@{}", dep.name, dep.version), uuid: dep.patch_uuid.clone(), reason: "python_metadata_unavailable".to_string(), - detail: Some(detail.replace(&dep.artifact_url, "")), + detail: Some(redact_artifact_text( + detail, + &dep.artifact_url, + &dep.patch_uuid, + )), } } @@ -1109,8 +1132,7 @@ pub fn yarn_berry_manifest_targets<'a>( } /// The skip recorded for an npm dep whose served `package.json` could not -/// be fetched (the grant token in `detail` is redacted to ``). +/// be fetched (`detail` redacted by [`redact_artifact_text`]). pub fn npm_manifest_unavailable(dep: &DepOverride, detail: &str) -> SkippedPatch { SkippedPatch { purl: format!( @@ -1120,7 +1142,11 @@ pub fn npm_manifest_unavailable(dep: &DepOverride, detail: &str) -> SkippedPatch ), uuid: dep.patch_uuid.clone(), reason: "npm_manifest_unavailable".to_string(), - detail: Some(detail.replace(&dep.artifact_url, "")), + detail: Some(redact_artifact_text( + detail, + &dep.artifact_url, + &dep.patch_uuid, + )), } } @@ -2789,6 +2815,67 @@ mod tests { use super::*; use crate::vendor::lock_inventory::MemoryProject; + const GRANT: &str = "GRANTTOKEN0123"; + const PATCH_UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + + fn grant_dep(ecosystem: &str, artifact_url: String) -> DepOverride { + DepOverride { + ecosystem: ecosystem.into(), + name: "left-pad".into(), + namespace: None, + version: "1.3.0".into(), + token: GRANT.into(), + patch_uuid: PATCH_UUID.into(), + artifact_url, + registry_override: None, + integrity: crate::patch::redirect::Integrity::default(), + } + } + + /// The hosted skip details reach `--json`: neither the grant token nor + /// any userinfo survives, however reqwest re-renders the URL (a + /// trailing `/`, a different quoting) and under whatever root the + /// server serves it (a custom `--api-url` with no `/patch/` level). + #[test] + fn hosted_skip_details_never_carry_the_grant_token() { + for (url, uuid) in [ + ( + format!("https://patch.socket.dev/patch/npm/left-pad/1.3.0/{GRANT}/{PATCH_UUID}/left-pad-1.3.0.tgz"), + PATCH_UUID, + ), + ( + format!("https://u:pw@api.corp.example/serve/{GRANT}/{PATCH_UUID}/left-pad-1.3.0.tgz"), + PATCH_UUID, + ), + // A non-canonical patch id: the shape-based redactor cannot + // tell its level is a uuid, the dep can. + ( + format!("https://patch.socket.dev/patch/npm/left-pad/1.3.0/{GRANT}/patch-42/x.tgz"), + "patch-42", + ), + ] { + let mut dep = grant_dep("npm", url.clone()); + dep.patch_uuid = uuid.into(); + let detail = format!( + "error sending request for url ({url}?x=1): connection refused (proxy https://p:pw@proxy:3128)" + ); + for skip in [ + npm_manifest_unavailable(&dep, &detail), + wheel_metadata_unavailable(&dep, &detail), + ] { + let got = skip.detail.unwrap(); + assert!(!got.contains(GRANT), "{url}: {got}"); + assert!(!got.contains("u:pw") && !got.contains("p:pw"), "{got}"); + assert!(got.contains("connection refused"), "{got}"); + } + } + let dep = grant_dep("npm", format!("https://h/serve/{GRANT}/{PATCH_UUID}/a.tgz")); + assert_eq!( + redact_artifact_text("no url here", &dep.artifact_url, &dep.patch_uuid), + "no url here" + ); + } + fn reference(value: serde_json::Value) -> PackageVendorResult { serde_json::from_value(value).unwrap() } diff --git a/crates/socket-patch-core/src/hosted/guidance.rs b/crates/socket-patch-core/src/hosted/guidance.rs index a850f1251..3c60dbf70 100644 --- a/crates/socket-patch-core/src/hosted/guidance.rs +++ b/crates/socket-patch-core/src/hosted/guidance.rs @@ -2,24 +2,6 @@ //! and npm `allow-remote` auto-config planners and every warning text they //! emit, shared verbatim by the disk and in-memory engines. -/// `scheme://[user[:pass]@]host[:port]/…` → `host[:port]`, NEVER userinfo. -/// For user-facing messages that name where a lockfile now points — the -/// hosted artifact host follows `--api-url`, so hardcoding `patch.socket.dev` -/// would misname it in custom-server environments. The port is kept (it is -/// part of the authority the lock records); credentials are stripped: a -/// credentialed artifact URL (`https://user:secret@host/…`) must never leak -/// `user:secret` into the warning text or the persisted `--json` envelope — -/// both land in CI logs. Split by hand because this crate has no URL-parser -/// dependency (reqwest is dev-only here); per RFC 3986 a raw `@` in the -/// authority can ONLY be the userinfo terminator (it is percent-encoded -/// everywhere else), so the tail after the LAST `@` is exactly host[:port]. -pub fn url_host(url: &str) -> Option<&str> { - let rest = url.split_once("://").map_or(url, |(_, r)| r); - let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); - let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - (!host.is_empty()).then_some(host) -} - /// Repo-relative path of the pnpm workspace manifest the trustLockfile /// auto-config edits (the same file the vendor backend's override surface /// uses). diff --git a/crates/socket-patch-core/src/hosted/vlt.rs b/crates/socket-patch-core/src/hosted/vlt.rs index 58035f759..834d182d6 100644 --- a/crates/socket-patch-core/src/hosted/vlt.rs +++ b/crates/socket-patch-core/src/hosted/vlt.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::constants::npm_family::{BUN_LOCKB, VLT_HIDDEN_LOCK_REL, VLT_LOCK, VLT_STORE_DIR}; use crate::formats::governing_locks::{npm_locks_outside, NpmLockFamily}; use crate::patch::redirect::vlt_preflight::{self, ArtifactProbe, OFFLINE_REASON}; -use crate::patch::redirect::{redact_grant_token, vlt, DepOverride}; +use crate::patch::redirect::{vlt, DepOverride}; use crate::vendor::lock_inventory::{MemoryEntry, ProjectView}; /// The warning code (and skip reason) of a dep whose artifact vlt would @@ -178,7 +178,11 @@ pub fn judge( ); out.warnings.push(crate::hosted::engine::warning( ARTIFACT_UNVERIFIABLE, - redact_grant_token(&detail, &dep.artifact_url, &dep.patch_uuid), + crate::hosted::engine::redact_artifact_text( + &detail, + &dep.artifact_url, + &dep.patch_uuid, + ), )); if everywhere { out.withheld_everywhere diff --git a/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs index a0c2e8355..efce6d91b 100644 --- a/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/composer_equivalence_tests.rs @@ -96,6 +96,13 @@ fn entry(i: usize, rng: &mut Rng) -> String { fields.push(dist_block(i, rng)); } } + // Origin-bound download options, which the rewrite drops with `source` + // (chosen by index, not the rng, so every other case keeps its input). + if i % 11 == 5 { + fields.push(format!( + "\"transport-options\": {{\n{I} \"http\": {{ \"header\": [\"Authorization: Bearer t{i}\"] }}\n{I}}}" + )); + } if rng.chance(40) { fields.push(format!( "\"authors\": [\n{I} {{\n{I} \"name\": \"{}\",\n{I} \"email\": \"a@b.c\"\n{I} }}\n{I}]", @@ -249,6 +256,7 @@ fn in_place_composer_rewrite_matches_golden() { "redirect_composer_no_dist", "redirect_composer_no_dist_url", "redirect_composer_dist_mirrors_removed", + "redirect_composer_transport_options_removed", ] { assert!(codes.contains(code), "no case reached {code}: {codes:?}"); } diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index b916e1196..00e16edc6 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -6117,27 +6117,6 @@ pub fn grant_token_path_segment(url: &str, patch_uuid: &str) -> Option { (!token.is_empty()).then(|| token.to_string()) } -/// What [`redact_grant_token`] puts where a hosted URL's grant token was. -pub const REDACTED_GRANT_TOKEN: &str = ""; - -/// `text` with every `//` pair of `url` spelled -/// `//`: the grant token is the path level just -/// before the patch-uuid level ([`grant_token_path_segment`]), and it -/// authorizes the org's download, so a warning, detail or log line that -/// quotes a hosted artifact URL (the URL itself, or an error that echoes -/// it) keeps the host, every other path level, the uuid, the leaf and any -/// query, and loses only the token. `text` comes back unchanged when `url` -/// has no uuid level or nothing precedes it. -pub fn redact_grant_token(text: &str, url: &str, patch_uuid: &str) -> String { - match grant_token_path_segment(url, patch_uuid) { - Some(token) => text.replace( - &format!("/{token}/{patch_uuid}"), - &format!("/{REDACTED_GRANT_TOKEN}/{patch_uuid}"), - ), - None => text.to_string(), - } -} - /// Public host of Socket's patch server: the origin every production hosted /// artifact / registry URL is served from (`https://patch.socket.dev/patch/…`, /// `…/patch-registry/…`), and the root of the Go module namespace @@ -15363,56 +15342,6 @@ mod tests { ); } - /// `redact_grant_token` replaces only the token level before the patch - /// uuid, in the URL and in any text quoting it (an error echoing the - /// URL included), keeping host, uuid, leaf and query; a URL with no - /// token level leaves the text as it was. - #[test] - fn redact_grant_token_hides_only_the_token_level() { - let uuid = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; - let token = "0f1e2d3c-4b5a-4968-8776-655443322110"; - let url = format!( - "https://patch.socket.dev/patch/npm/left-pad/1.3.0/{token}/{uuid}/left-pad-1.3.0.tgz?x=1" - ); - let redacted = format!( - "https://patch.socket.dev/patch/npm/left-pad/1.3.0//{uuid}/left-pad-1.3.0.tgz?x=1" - ); - assert_eq!( - redact_grant_token(&url, &url, uuid), - redacted, - "the URL alone" - ); - let text = format!("vlt would fail to verify {url}: fetch error GET {url}: reset"); - let want = - format!("vlt would fail to verify {redacted}: fetch error GET {redacted}: reset"); - assert_eq!(redact_grant_token(&text, &url, uuid), want, "every quote"); - assert!( - !redact_grant_token(&text, &url, uuid).contains(token), - "no token left" - ); - let registry = format!("https://patch.socket.dev/patch-registry/npm/{token}/{uuid}"); - assert_eq!( - redact_grant_token(®istry, ®istry, uuid), - format!("https://patch.socket.dev/patch-registry/npm//{uuid}"), - "a trailing uuid level" - ); - for untouched in [ - "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz".to_string(), - format!("https://patch.socket.dev/{uuid}/left-pad-1.3.0.tgz"), - ] { - assert_eq!( - redact_grant_token(&untouched, &untouched, uuid), - untouched, - "no token level" - ); - } - assert_eq!( - redact_grant_token(&url, &url, ""), - url, - "no uuid, nothing to anchor on" - ); - } - /// `grant_token_path_segment` recovers the grant token from the hosted /// URL shapes the reference endpoint hands back (the path level before /// the patch uuid) and answers `None` — never a host or empty segment — diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs b/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs index b96873dcd..c2ed53faf 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/composer.rs @@ -32,6 +32,7 @@ use crate::crawlers::composer_crawler::normalize_version; use crate::formats::composer::hosted::{ find_composer_entry, find_composer_member, json_string_field, ComposerEntry, }; +use crate::utils::redact::url_hostname; const COMPOSER_LOCK: &str = "composer.lock"; /// The `notification-url` composer records for packagist packages. @@ -127,6 +128,35 @@ fn declares_custom_repositories(composer_json: Option<&str>) -> Result) -> bool { + let Some(doc) = composer_json.and_then(|t| serde_json::from_str::(t).ok()) else { + return false; + }; + let packagist_with_options = |r: &Value| { + r.get("options").is_some_and(|o| !o.is_null()) + && r.get("url") + .and_then(Value::as_str) + .and_then(url_hostname) + .is_some_and(|host| { + let host = host.to_ascii_lowercase(); + host == "packagist.org" || host.ends_with(".packagist.org") + }) + }; + match doc.get("repositories") { + Some(Value::Array(a)) => a.iter().any(packagist_with_options), + Some(Value::Object(o)) => o.values().any(packagist_with_options), + _ => false, + } +} + /// The packagist version entry that locked `locked` (exact pretty version /// first, then composer's leading-`v` normalization). fn pick_version<'v>(versions: &'v [Value], locked: &str) -> Result<&'v Value, String> { @@ -176,6 +206,7 @@ pub(crate) async fn restore( }; let composer_json = view.read("composer.json").await.ok().flatten(); let custom_repos = declares_custom_repositories(composer_json.as_deref()); + let repo_options = packagist_declares_options(composer_json.as_deref()); let mut hits: Vec = Vec::new(); for pin in pins { @@ -358,6 +389,18 @@ pub(crate) async fn restore( content.replace_range(d_start..=d_end, &format!("{source_text}{dist_text}")); changed = true; result.handled.insert(hit.uuid.clone()); + if repo_options { + result.warnings.push(( + "upstream_composer_transport_options_not_restored", + format!( + "{label}: composer.json gives packagist repository options, but the \ + transport-options the hosted rewrite removed from this lock entry \ + cannot be restored; re-lock {} at {} to record them again (a plain \ + `composer update` may also move it and its dependents to newer versions)", + hit.name, hit.locked_version + ), + )); + } } if changed { view.write(COMPOSER_LOCK, content); @@ -454,6 +497,33 @@ mod tests { assert!(declares_custom_repositories(Some("{")).is_err()); } + #[test] + fn packagist_options_gate() { + assert!(!packagist_declares_options(None)); + assert!(!packagist_declares_options(Some("{"))); + assert!(!packagist_declares_options(Some( + r#"{"repositories": [{"type": "composer", "url": "https://repo.packagist.org"}]}"# + ))); + assert!(packagist_declares_options(Some( + r#"{"repositories": [{"type": "composer", "url": "https://repo.packagist.org", + "options": {"http": {"proxy": "http://proxy:3128"}}}]}"# + ))); + // Options on a private repository belong to its own entries, which + // the restore refuses: no warning for packagist-origin restores. + assert!(!packagist_declares_options(Some( + r#"{"repositories": {"private": {"type": "composer", "url": "https://r.example", + "options": {"http": {"header": ["X-Token: t"]}}}}}"# + ))); + assert!(packagist_declares_options(Some( + r#"{"repositories": [{"type": "composer", "url": "https://u:p@repo.packagist.org", + "options": {"ssl": {"verify_peer": false}}}]}"# + ))); + assert!(!packagist_declares_options(Some( + r#"{"repositories": [{"type": "composer", "url": "https://packagist.org.evil.example", + "options": {"http": {"header": ["X-Token: t"]}}}]}"# + ))); + } + #[test] fn version_pick_prefers_the_pretty_spelling() { let versions = vec![ diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs index 69ad8ddc3..41668cef5 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/uv.rs @@ -368,14 +368,10 @@ fn artifact_tables(package: &toml_edit::Table) -> Vec<&dyn TableLike> { tables } -/// The lowercased host of `url`. +/// The lowercased `host[:port]` of `url` (never its userinfo). fn url_host(url: &str) -> String { - let rest = url.split_once("://").map_or(url, |(_, rest)| rest); - let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); - authority - .rsplit('@') - .next() - .unwrap_or(authority) + crate::utils::redact::url_host(url) + .unwrap_or("") .to_ascii_lowercase() } diff --git a/crates/socket-patch-core/src/telemetry.rs b/crates/socket-patch-core/src/telemetry.rs index 8a2998e03..dd4393300 100644 --- a/crates/socket-patch-core/src/telemetry.rs +++ b/crates/socket-patch-core/src/telemetry.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::api::client::ApiRoute; use crate::constants::USER_AGENT; -use crate::utils::env_compat::{is_debug_enabled, is_offline_env, proxy_url_from_env}; +use crate::utils::env_compat::{is_offline_env, proxy_url_from_env}; use crate::utils::fs::home_dir; use crate::vex::time::unix_to_ymdhms; @@ -139,9 +139,7 @@ pub fn is_telemetry_disabled() -> bool { /// Log debug messages when debug mode is enabled. fn debug_log(message: &str) { - if is_debug_enabled() { - eprintln!("[socket-patch telemetry] {message}"); - } + crate::utils::env_compat::debug_log("telemetry", message); } // --------------------------------------------------------------------------- @@ -160,9 +158,13 @@ fn build_telemetry_context(command: &str) -> PatchTelemetryContext { /// Sanitize an error message for telemetry. /// -/// Replaces the user's home directory path with `~` to avoid leaking -/// sensitive file system information. +/// Every URL in it is redacted ([`crate::utils::redact::redact_urls_in`]: +/// userinfo, grant tokens, signed query values), and the user's home +/// directory path is replaced with `~` to avoid leaking sensitive file +/// system information. pub fn sanitize_error_message(message: &str) -> String { + let message = crate::utils::redact::redact_urls_in(message); + let message = message.as_ref(); let Some(home) = home_dir() else { return message.to_string(); }; @@ -729,8 +731,21 @@ pub async fn track_patch_fetched( .await; } +/// The `uuid` a `patch_fetch_failed` event reports: `identifier` when it +/// is a patch uuid, else empty. `get` passes whatever the user asked for +/// (a CVE, a GHSA, a purl, a private package name), and only a uuid +/// belongs in that field. +fn fetch_failed_uuid(identifier: &str) -> &str { + if crate::patch::path_safety::is_canonical_uuid(&identifier.to_ascii_lowercase()) { + identifier + } else { + "" + } +} + /// Track a failed `get`. `uuid` may be empty when the failure occurred -/// before the patch was resolved (e.g. lookup miss). +/// before the patch was resolved (e.g. lookup miss); anything that is not +/// a uuid is reported as empty. pub async fn track_patch_fetch_failed( uuid: &str, error: impl std::fmt::Display, @@ -740,7 +755,10 @@ pub async fn track_patch_fetch_failed( fire( PatchTelemetryEventType::PatchFetchFailed, "get", - serde_json::json!({ "uuid": uuid, "fallback_to_proxy": fallback_to_proxy }), + serde_json::json!({ + "uuid": fetch_failed_uuid(uuid), + "fallback_to_proxy": fallback_to_proxy + }), Some(error), auth, ) @@ -1098,6 +1116,39 @@ mod tests { assert!(!sanitized.contains(&home)); } + /// B26: an error that quotes a grant URL or a credentialed registry URL + /// (reqwest's own text does) reaches telemetry redacted. + #[test] + fn sanitize_error_message_redacts_urls() { + let uuid = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + let msg = format!( + "artifact not found: https://patch.socket.dev/patch/npm/a/1.0.0/GRANT/{uuid}/a.tgz; \ + error sending request for url (https://bot:hunter2@goproxy.corp/m/@v/v1.zip)" + ); + let sanitized = sanitize_error_message(&msg); + assert!(!sanitized.contains("GRANT"), "{sanitized}"); + assert!(!sanitized.contains("hunter2"), "{sanitized}"); + assert!( + sanitized.contains(uuid) && sanitized.contains("goproxy.corp"), + "{sanitized}" + ); + } + + /// B26: `patch_fetch_failed` reports a uuid only; a CVE, purl or private + /// package name the user asked `get` for is not sent. + #[test] + fn fetch_failed_uuid_only_reports_uuids() { + let uuid = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + assert_eq!(fetch_failed_uuid(uuid), uuid); + assert_eq!( + fetch_failed_uuid("7C8D9E0F-1A2B-4A1B-8C2D-3E4F5A6B7C8D"), + "7C8D9E0F-1A2B-4A1B-8C2D-3E4F5A6B7C8D" + ); + for identifier in ["@acme/internal-lib", "CVE-2021-44906", "pkg:npm/x@1", ""] { + assert_eq!(fetch_failed_uuid(identifier), "", "{identifier}"); + } + } + #[test] fn test_sanitize_error_message_no_home() { let msg = "Some error without paths"; diff --git a/crates/socket-patch-core/src/utils/concurrent.rs b/crates/socket-patch-core/src/utils/concurrent.rs index a3617ad4f..37370149e 100644 --- a/crates/socket-patch-core/src/utils/concurrent.rs +++ b/crates/socket-patch-core/src/utils/concurrent.rs @@ -25,8 +25,6 @@ use std::future::Future; use futures_util::stream::{self, Stream, StreamExt}; -use crate::utils::env_compat::is_debug_enabled; - /// In-flight request cap for the authenticated patch API: the ceiling a /// large window reaches ([`api_concurrency_for`]), and the whole cap for /// the windows that do not size themselves. Measured with no 429s up to @@ -167,12 +165,12 @@ fn api_concurrency_override() -> Option { match trimmed.parse::() { Ok(limit) if limit > 0 => Some(limit.min(MAX_API_CONCURRENCY)), _ => { - if is_debug_enabled() { - eprintln!( - "[socket-patch debug] ignoring {API_CONCURRENCY_ENV}={raw:?}: \ - expected an integer of 1 or more" - ); - } + crate::utils::env_compat::debug_log( + "debug", + &format!( + "ignoring {API_CONCURRENCY_ENV}={raw:?}: expected an integer of 1 or more" + ), + ); None } } diff --git a/crates/socket-patch-core/src/utils/env_compat.rs b/crates/socket-patch-core/src/utils/env_compat.rs index 56c7879ab..3cd675939 100644 --- a/crates/socket-patch-core/src/utils/env_compat.rs +++ b/crates/socket-patch-core/src/utils/env_compat.rs @@ -9,6 +9,23 @@ pub fn is_debug_enabled() -> bool { ) } +/// `message` made safe for a `SOCKET_DEBUG` line (every URL in it through +/// [`redact_urls_in`](crate::utils::redact::redact_urls_in)), or `None` +/// when debug output is off. Debug lines quote grant URLs and `--api-url`s, +/// and debug output is routinely pasted into CI logs and bug reports. +pub fn debug_message(message: &str) -> Option> { + is_debug_enabled().then(|| crate::utils::redact::redact_urls_in(message)) +} + +/// Print a `SOCKET_DEBUG` line as `[socket-patch ] `, +/// URLs redacted. The one debug printer: every core debug line goes +/// through it (or through [`debug_message`] when it is held back first). +pub fn debug_log(channel: &str, message: &str) { + if let Some(message) = debug_message(message) { + eprintln!("[socket-patch {channel}] {message}"); + } +} + /// Strict-airgap gate: `SOCKET_OFFLINE` is `"1"` or `"true"`. It lives /// here as the single definition of the vocabulary shared by every offline /// gate (telemetry kill-switch, API-client advisory and org-slug @@ -76,6 +93,53 @@ fn promote_aliases(aliases: &[(&str, &str)]) { mod tests { use super::*; + /// Every core `SOCKET_DEBUG` line is printed by [`debug_log`] (or, held + /// back first, by the API client from [`debug_message`]), so every URL + /// in it is redacted: a module that prints its own `[socket-patch …]` + /// line again fails here. + #[test] + fn debug_lines_have_one_printer() { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + walk(&src, &mut files); + let mut printers = Vec::new(); + for path in files { + let rel = path + .strip_prefix(&src) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let text = std::fs::read_to_string(&path) + .unwrap() + .replace("\r\n", "\n"); + let production = text + .find("#[cfg(test)]\nmod tests {") + .map_or(text.as_str(), |at| &text[..at]); + let raw = production.matches("\"[socket-patch ").count(); + if raw > 0 { + printers.push((rel, raw)); + } + } + printers.sort(); + assert_eq!( + printers, + vec![ + ("api/client.rs".to_string(), 2), + ("utils/env_compat.rs".to_string(), 1) + ] + ); + } + /// Peer-alias promotion copies a set alias onto the unset canonical /// name. #[test] diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index cf4d79805..621639382 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -23,6 +23,7 @@ pub mod purl; pub mod purl_key; pub mod python_lock; pub mod python_script; +pub mod redact; pub(crate) mod relpath; pub mod repo_root; pub(crate) mod requirements; diff --git a/crates/socket-patch-core/src/utils/process.rs b/crates/socket-patch-core/src/utils/process.rs index ceb04495f..605871227 100644 --- a/crates/socket-patch-core/src/utils/process.rs +++ b/crates/socket-patch-core/src/utils/process.rs @@ -317,12 +317,13 @@ fn run_resolved_within( let output = match output_within(command, budget) { Ok(output) => output, Err(BoundedError::TimedOut) => { - if crate::utils::env_compat::is_debug_enabled() { - eprintln!( - "[socket-patch debug] probe `{bin} {}` did not answer within {budget:?}; treating it as absent", + crate::utils::env_compat::debug_log( + "debug", + &format!( + "probe `{bin} {}` did not answer within {budget:?}; treating it as absent", args.join(" ") - ); - } + ), + ); return None; } Err(BoundedError::Spawn(_)) => return None, diff --git a/crates/socket-patch-core/src/utils/redact.rs b/crates/socket-patch-core/src/utils/redact.rs new file mode 100644 index 000000000..a28b5cb35 --- /dev/null +++ b/crates/socket-patch-core/src/utils/redact.rs @@ -0,0 +1,552 @@ +//! The one place a URL is made safe to show. +//! +//! Every URL that reaches a human or a log (a warning, an error message, +//! a `--json` event, a debug line, a telemetry error) goes through +//! [`redact_urls_in`] or [`redact_url`] first. Three things in a URL are +//! credentials: +//! +//! * **userinfo** (`https://user:token@host/…`): GOPROXY, `.npmrc` +//! registries, private Composer/PyPI indexes and CI git remotes all embed +//! tokens there; +//! * **the grant token** of a Socket-served artifact or registry URL: the +//! path level right before the patch uuid +//! (`/patch//…///`, +//! `/patch-registry////…`). It authorizes the org's +//! download; +//! * **secret query values** (`?token=…`, `X-Amz-Signature=…`, …). +//! +//! Each is replaced with [`REDACTED`]; the scheme, host, port, the other +//! path levels, the uuid and the file name stay, so the text still says +//! where the request went. +//! +//! [`strip_url_credentials`] is the identifier form: it drops userinfo and +//! secret query parameters outright, for a URL that is recorded as data +//! (a VEX product `@id`) rather than shown. +//! +//! Split by hand because the URLs here are often not well-formed enough +//! for a parser to accept (scp-like remotes, text that merely contains a +//! URL). Per RFC 3986 a raw `@` in the authority can only end the userinfo +//! (it is percent-encoded everywhere else), so the authority's tail after +//! its LAST `@` is exactly `host[:port]`. + +use std::borrow::Cow; +use std::ops::Range; + +/// What a redacted credential is spelled as. +pub const REDACTED: &str = ""; + +/// Path levels under which Socket serves grant-tokenized URLs. +const SERVE_ROOTS: &[&str] = &["patch", "patch-registry"]; + +/// Byte range of `url`'s authority: after `scheme://` (or from the start +/// when there is no scheme) up to the first `/`, `?` or `#`. +fn authority_span(url: &str) -> Range { + let start = url.find("://").map_or(0, |i| i + 3); + let len = url[start..] + .find(['/', '?', '#']) + .unwrap_or(url.len() - start); + start..start + len +} + +/// Byte range of `url`'s userinfo (without the `@`), when it has one. +fn userinfo_span(url: &str) -> Option> { + let authority = authority_span(url); + let at = url[authority.clone()].rfind('@')?; + Some(authority.start..authority.start + at) +} + +/// `scheme://[user[:pass]@]host[:port]/…` → `host[:port]`, never the +/// userinfo; `None` for an empty host. The port is kept (it is part of the +/// authority a lock records). +pub fn url_host(url: &str) -> Option<&str> { + let authority = &url[authority_span(url)]; + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + (!host.is_empty()).then_some(host) +} + +/// [`url_host`] without the port: `host`, or `[v6]` with its brackets. +pub fn url_hostname(url: &str) -> Option<&str> { + let host = url_host(url)?; + let host = if host.starts_with('[') { + &host[..=host.find(']')?] + } else { + host.split(':').next()? + }; + (!host.is_empty()).then_some(host) +} + +/// A uuid-shaped path level (any hex case). +fn is_uuid_shaped(s: &str) -> bool { + s.len() == 36 + && s.bytes().enumerate().all(|(i, c)| match i { + 8 | 13 | 18 | 23 => c == b'-', + _ => c.is_ascii_hexdigit(), + }) +} + +/// A query parameter whose value is a secret, by name. A long marker +/// counts anywhere in the name (`accesstoken`, `X-Amz-Signature`); a short +/// one only as a whole word of it (`auth`, `api_key`, `sig`, but not +/// `author`, `keyword` or `design`). Words split at `-`, `_`, `.` and a +/// lower-to-upper case change (`apiKey`). +fn is_secret_param(name: &str) -> bool { + const ANYWHERE: &[&str] = &[ + "token", + "secret", + "password", + "passwd", + "signature", + "credential", + "session", + "apikey", + ]; + const WORDS: &[&str] = &["auth", "key", "sig", "pass", "pwd", "cred", "jwt"]; + let lower = name.to_ascii_lowercase(); + if ANYWHERE.iter().any(|m| lower.contains(m)) { + return true; + } + let mut words = Vec::new(); + let mut word = String::new(); + let mut prev_lower = false; + for c in name.chars() { + if !c.is_ascii_alphanumeric() || (prev_lower && c.is_ascii_uppercase()) { + words.push(std::mem::take(&mut word)); + } + prev_lower = c.is_ascii_lowercase(); + if c.is_ascii_alphanumeric() { + word.push(c.to_ascii_lowercase()); + } + } + words.push(word); + words.iter().any(|w| WORDS.contains(&w.as_str())) +} + +/// Byte range of the grant-token path level of a Socket-served URL: the +/// level right before the LAST uuid-shaped level, when a serve root +/// (`patch`, `patch-registry`) precedes it. A URL without a serve root, or +/// whose uuid level directly follows the root, has none. +fn grant_token_span(url: &str) -> Option> { + let authority = authority_span(url); + let path_start = authority.end; + let path_len = url[path_start..] + .find(['?', '#']) + .unwrap_or(url.len() - path_start); + let path = &url[path_start..path_start + path_len]; + let mut levels: Vec> = Vec::new(); + let mut offset = 0; + for level in path.split('/') { + levels.push(path_start + offset..path_start + offset + level.len()); + offset += level.len() + 1; + } + let root = levels + .iter() + .position(|r| SERVE_ROOTS.contains(&&url[r.clone()]))?; + let uuid = levels + .iter() + .rposition(|r| is_uuid_shaped(&url[r.clone()]))?; + (uuid > root + 1) + .then(|| levels[uuid - 1].clone()) + .filter(|r| !r.is_empty()) +} + +/// Byte range of `url`'s query (after the `?`, before any `#`). +fn query_span(url: &str) -> Option> { + let path_end = url.find(['?', '#'])?; + if url.as_bytes()[path_end] != b'?' { + return None; + } + let end = url[path_end..] + .find('#') + .map_or(url.len(), |i| path_end + i); + Some(path_end + 1..end) +} + +/// The `name=value` pairs of `url`'s query as `(pair, name, value)` byte +/// ranges. +fn query_pairs(url: &str) -> Vec<(Range, Range, Range)> { + let Some(query) = query_span(url) else { + return Vec::new(); + }; + let mut pairs = Vec::new(); + let mut pos = query.start; + for pair in url[query.clone()].split('&') { + let name_len = pair.find('=').unwrap_or(pair.len()); + let value_start = (pos + name_len + 1).min(pos + pair.len()); + pairs.push(( + pos..pos + pair.len(), + pos..pos + name_len, + value_start..pos + pair.len(), + )); + pos += pair.len() + 1; + } + pairs +} + +/// Byte ranges of the secret query values of `url`. +fn secret_query_spans(url: &str) -> Vec> { + query_pairs(url) + .into_iter() + .filter(|(_, name, value)| !value.is_empty() && is_secret_param(&url[name.clone()])) + .map(|(_, _, value)| value) + .collect() +} + +/// `url` with each span replaced by `with` (spans must not overlap). +fn replace_spans<'a>(url: &'a str, mut spans: Vec>, with: &str) -> Cow<'a, str> { + if spans.is_empty() { + return Cow::Borrowed(url); + } + spans.sort_by_key(|r| r.start); + let mut out = String::with_capacity(url.len()); + let mut at = 0; + for span in spans { + out.push_str(&url[at..span.start]); + out.push_str(with); + at = span.end; + } + out.push_str(&url[at..]); + Cow::Owned(out) +} + +/// Whether `userinfo` of `url` is a bare ssh login name (`git` in +/// `ssh://git@github.com/…`): an ssh URL authenticates with a key, so a +/// userinfo without a `:password` names the account, not a secret. +fn is_ssh_login(url: &str, userinfo: &str) -> bool { + url.split_once("://") + .is_some_and(|(scheme, _)| scheme.to_ascii_lowercase().ends_with("ssh")) + && !userinfo.contains(':') +} + +/// `url` safe to show: userinfo, the Socket grant token and secret query +/// values each replaced with [`REDACTED`]. +pub fn redact_url(url: &str) -> Cow<'_, str> { + let mut spans: Vec> = userinfo_span(url) + .filter(|r| !is_ssh_login(url, &url[r.clone()])) + .into_iter() + .collect(); + spans.extend(grant_token_span(url)); + spans.extend(secret_query_spans(url)); + replace_spans(url, spans, REDACTED) +} + +/// The userinfo of a `scheme://` URL (without the `@`), when it has one. +pub fn url_userinfo(url: &str) -> Option<&str> { + if !url.contains("://") { + return None; + } + userinfo_span(url).map(|r| &url[r]) +} + +/// `url` as an identifier: userinfo and secret query parameters dropped +/// outright (no marker), everything else as written. Only a `scheme://` +/// URL is touched: in an scp-like `user@host:path` the `user` is a login +/// name, and the text has no query. +pub fn strip_url_credentials(url: &str) -> Cow<'_, str> { + if !url.contains("://") { + return Cow::Borrowed(url); + } + let pairs = query_pairs(url); + let secret: Vec = pairs + .iter() + .map(|(_, name, _)| is_secret_param(&url[name.clone()])) + .collect(); + let userinfo = userinfo_span(url); + if userinfo.is_none() && !secret.contains(&true) { + return Cow::Borrowed(url); + } + let path_end = url.find(['?', '#']).unwrap_or(url.len()); + let mut out = String::with_capacity(url.len()); + match userinfo { + // The `@` goes too. + Some(r) => { + out.push_str(&url[..r.start]); + out.push_str(&url[r.end + 1..path_end]); + } + None => out.push_str(&url[..path_end]), + } + let kept: Vec<&str> = pairs + .iter() + .zip(&secret) + .filter(|(_, secret)| !**secret) + .map(|((pair, _, _), _)| &url[pair.clone()]) + .collect(); + if !kept.is_empty() { + out.push('?'); + out.push_str(&kept.join("&")); + } + let fragment_start = query_span(url).map_or(path_end, |q| q.end); + out.push_str(&url[fragment_start..]); + Cow::Owned(out) +} + +/// A byte that cannot be part of a URL quoted in free text. +fn ends_url(c: char) -> bool { + c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '<' | '>' | ')' | ']' | '}' | '|') +} + +/// Start of the scheme that ends at `sep` (the byte offset of a `://`), +/// scanning back no further than `floor`. +fn scheme_start(text: &str, floor: usize, sep: usize) -> usize { + text[floor..sep] + .rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))) + .map_or(floor, |j| floor + j + 1) +} + +/// Where a URL starting with the `://` at `sep` and running to at most +/// `end` stops because the next element of a comma-joined list begins (a +/// GOPROXY value: `https://a,https://u:p@b,direct`): the `,` before the +/// next `scheme://`. A `scheme://` after anything else (`?next=https://…`) +/// is part of the URL, so a query secret after it stays in its query. +fn list_element_end(text: &str, sep: usize, end: usize) -> usize { + let mut from = sep + 3; + while let Some(j) = text[from..end].find("://").map(|j| from + j) { + let scheme = scheme_start(text, from, j); + if scheme > from && scheme < j && text[..scheme].ends_with(',') { + return scheme - 1; + } + from = j + 3; + } + end +} + +/// `text` with every `scheme://…` URL in it passed through [`redact_url`]. +/// Trailing sentence punctuation (`.`, `,`, `;`, `:`) is not taken as part +/// of a URL, and each element of a comma-joined URL list is its own URL. +pub fn redact_urls_in(text: &str) -> Cow<'_, str> { + if !text.contains("://") { + return Cow::Borrowed(text); + } + let mut out = String::with_capacity(text.len()); + let mut at = 0; + let mut changed = false; + while let Some(i) = text[at..].find("://").map(|i| at + i) { + let start = scheme_start(text, at, i); + let tail = &text[i..]; + let end = i + tail.find(ends_url).unwrap_or(tail.len()); + let end = list_element_end(text, i, end); + let end = start + + text[start..end] + .trim_end_matches(['.', ',', ';', ':']) + .len(); + if end <= i + 3 { + out.push_str(&text[at..i + 3]); + at = i + 3; + continue; + } + let url = &text[start..end]; + let redacted = redact_url(url); + out.push_str(&text[at..start]); + changed |= redacted != url; + out.push_str(&redacted); + at = end; + } + if !changed { + return Cow::Borrowed(text); + } + out.push_str(&text[at..]); + Cow::Owned(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + const TOKEN: &str = "0f1e2d3c-4b5a-4968-8776-655443322110"; + + #[test] + fn userinfo_is_redacted_and_the_host_kept() { + assert_eq!( + redact_url("https://user:s3cret@goproxy.corp:8443/mod/@v/v1.zip"), + "https://@goproxy.corp:8443/mod/@v/v1.zip", + "the `@` in the path is not userinfo" + ); + assert_eq!( + redact_url("https://ghp_TOKEN@github.com/o/r.git"), + "https://@github.com/o/r.git" + ); + let plain = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"; + assert!(matches!(redact_url(plain), Cow::Borrowed(_))); + } + + /// The grant token is the level before the patch uuid, in both serve + /// shapes, whatever it looks like; the uuid, host, leaf and any + /// non-secret query stay. + #[test] + fn the_grant_token_level_is_redacted() { + let url = format!( + "https://patch.socket.dev/patch/npm/left-pad/1.3.0/{TOKEN}/{UUID}/left-pad-1.3.0.tgz?x=1" + ); + assert_eq!( + redact_url(&url), + format!( + "https://patch.socket.dev/patch/npm/left-pad/1.3.0//{UUID}/left-pad-1.3.0.tgz?x=1" + ) + ); + let registry = format!("http://127.0.0.1:9/patch-registry/npm/tok/{UUID}"); + assert_eq!( + redact_url(®istry), + format!("http://127.0.0.1:9/patch-registry/npm//{UUID}") + ); + let composer = format!("http://h/patch/composer/acme/rlib/1.0.0/tok/{UUID}/rlib-1.0.0.zip"); + assert!(!redact_url(&composer).contains("/tok/")); + for untouched in [ + format!("https://patch.socket.dev/patch/{UUID}/x.tgz"), + format!("https://registry.example/{TOKEN}/{UUID}/x.tgz"), + "https://patch.socket.dev/patch/npm/left-pad/1.3.0/x.tgz".to_string(), + ] { + assert_eq!(redact_url(&untouched), untouched, "no grant level"); + } + } + + #[test] + fn secret_query_values_are_redacted() { + assert_eq!( + redact_url("https://s3/b/x.zip?X-Amz-Signature=abc&v=1&access_token=t#f"), + "https://s3/b/x.zip?X-Amz-Signature=&v=1&access_token=#f" + ); + assert_eq!( + redact_url("https://h/x?apiKey=a&sig=b&auth=c&accesstoken=d&api-key=e"), + "https://h/x?apiKey=&sig=&auth=\ + &accesstoken=&api-key=" + ); + let plain = "https://h/x?author=a&keyword=b&design=c&monkey=d"; + assert_eq!(redact_url(plain), plain, "a marker inside another word"); + } + + /// An ssh login name is not a secret; an ssh password is. + #[test] + fn an_ssh_login_name_is_kept() { + for kept in [ + "git+ssh://git@github.com/o/r.git", + "ssh://git@git.corp:7999/t/r.git", + ] { + assert_eq!(redact_url(kept), kept); + } + assert_eq!( + redact_url("ssh://git:hunter2@git.corp/r.git"), + "ssh://@git.corp/r.git" + ); + } + + #[test] + fn credentials_are_stripped_from_an_identifier() { + assert_eq!( + strip_url_credentials("https://gitlab-ci-token:glcbt-SECRET@gitlab.corp/g/s/app.git"), + "https://gitlab.corp/g/s/app.git" + ); + assert_eq!( + strip_url_credentials("https://u@h/r.git?private_token=x&ref=main#frag"), + "https://h/r.git?ref=main#frag" + ); + assert_eq!( + strip_url_credentials("https://h/r.git?token=x"), + "https://h/r.git" + ); + for plain in [ + "https://git.example.com/team/repo.git", + "git@git.corp:team/repo.git", + ] { + assert_eq!(strip_url_credentials(plain), plain); + } + } + + /// Every URL quoted in free text is redacted, wherever it sits, and the + /// text around it (punctuation included) is kept byte for byte. + #[test] + fn urls_in_text_are_redacted_in_place() { + let url = format!("https://patch.socket.dev/patch/npm/a/1.0.0/{TOKEN}/{UUID}/a-1.0.0.tgz"); + let text = + format!("GET {url}: HTTP 403 (also git+https://u:p@git.corp/r.git, see \"{url}\".)"); + let got = redact_urls_in(&text); + assert!(!got.contains(TOKEN), "{got}"); + assert!(!got.contains("u:p@"), "{got}"); + assert_eq!( + got, + format!( + "GET https://patch.socket.dev/patch/npm/a/1.0.0//{UUID}/a-1.0.0.tgz: \ + HTTP 403 (also git+https://@git.corp/r.git, see \ + \"https://patch.socket.dev/patch/npm/a/1.0.0//{UUID}/a-1.0.0.tgz\".)" + ) + ); + let none = "nothing here: a://, b"; + assert!(matches!(redact_urls_in(none), Cow::Borrowed(_))); + } + + /// The `vendor_prebuilt_downloaded` advisory, which quotes the + /// grant-tokenized service URL, is built in exactly one place + /// (`VerifiedArchive::downloaded_warning`); a backend that spells its + /// own copy again fails here. + #[test] + fn the_service_download_advisory_has_one_builder() { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } + } + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = Vec::new(); + walk(&src, &mut files); + let mut builders = Vec::new(); + for path in files { + let rel = path + .strip_prefix(&src) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel.ends_with("tests.rs") || rel.contains("test_support") { + continue; + } + let text = std::fs::read_to_string(&path) + .unwrap() + .replace("\r\n", "\n"); + let production = text + .find("#[cfg(test)]\nmod tests {") + .map_or(text.as_str(), |at| &text[..at]); + for _ in production.matches("\"vendor_prebuilt_downloaded\",") { + builders.push(rel.clone()); + } + } + assert_eq!(builders, vec!["vendor/service_fetch.rs".to_string()]); + } + + /// A comma-joined URL list (GOPROXY) is redacted element by element; + /// a URL nested in a query is still part of its URL. + #[test] + fn every_element_of_a_url_list_is_redacted() { + assert_eq!( + redact_urls_in("GOPROXY=https://proxy.golang.org,https://u:p@goproxy.corp,direct"), + "GOPROXY=https://proxy.golang.org,https://@goproxy.corp,direct" + ); + assert_eq!( + redact_urls_in("https://a:b@x,https://c:d@y|https://e:f@z"), + "https://@x,https://@y|https://@z" + ); + assert_eq!( + redact_urls_in("https://h/x?next=https://n/y&token=SECRET"), + "https://h/x?next=https://n/y&token=" + ); + } + + #[test] + fn url_host_never_returns_userinfo() { + assert_eq!( + url_host("https://u:p@h.example:8443/x"), + Some("h.example:8443") + ); + assert_eq!(url_host("h.example/x"), Some("h.example")); + assert_eq!(url_host("https:///x"), None); + assert_eq!( + url_hostname("https://u:p@h.example:8443/x"), + Some("h.example") + ); + assert_eq!(url_hostname("https://u@[::1]:8443/x"), Some("[::1]")); + assert_eq!(url_hostname("https://h.example?q"), Some("h.example")); + assert_eq!(url_hostname("https://u@:80/x"), None); + } +} diff --git a/crates/socket-patch-core/src/utils/socket_cli_config.rs b/crates/socket-patch-core/src/utils/socket_cli_config.rs index cc94651c4..298b5b552 100644 --- a/crates/socket-patch-core/src/utils/socket_cli_config.rs +++ b/crates/socket-patch-core/src/utils/socket_cli_config.rs @@ -221,9 +221,10 @@ pub fn resolve_api_base_url() -> String { env_non_empty("SOCKET_API_URL") .or_else(|| { load().and_then(|c| c.api_base_url.clone()).inspect(|url| { - if crate::utils::env_compat::is_debug_enabled() { - eprintln!("[socket-patch debug] api base url: `{url}` from socket-cli config"); - } + crate::utils::env_compat::debug_log( + "debug", + &format!("api base url: `{url}` from socket-cli config"), + ); }) }) .unwrap_or_else(|| crate::constants::DEFAULT_SOCKET_API_URL.to_string()) diff --git a/crates/socket-patch-core/src/vendor/cargo.rs b/crates/socket-patch-core/src/vendor/cargo.rs index f1091a41b..322397ba2 100644 --- a/crates/socket-patch-core/src/vendor/cargo.rs +++ b/crates/socket-patch-core/src/vendor/cargo.rs @@ -360,13 +360,7 @@ pub(super) async fn cargo_service_copy( format!("cannot move the extracted crate into place: {e}"), ); } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(archive.downloaded_warning(name)); CargoServiceCopy::Used(()) } diff --git a/crates/socket-patch-core/src/vendor/composer_lock.rs b/crates/socket-patch-core/src/vendor/composer_lock.rs index 29a845cd9..e28b635d5 100644 --- a/crates/socket-patch-core/src/vendor/composer_lock.rs +++ b/crates/socket-patch-core/src/vendor/composer_lock.rs @@ -70,7 +70,9 @@ use super::state::{ write_marker_or_warn, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorServiceConfig, VendorWarning}; -use crate::formats::composer::{composer_lock_packages, ComposerLockPackage}; +use crate::formats::composer::{ + composer_lock_packages, ComposerLockPackage, ORIGIN_BOUND_ENTRY_KEYS, +}; mod lock_text; pub(super) mod mirror_filters; @@ -732,13 +734,7 @@ pub(super) async fn composer_service_copy( format!("cannot move the extracted dist into place: {e}"), ); } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {pkg} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(archive.downloaded_warning(pkg)); ComposerServiceCopy::Used(()) } @@ -797,8 +793,7 @@ pub(crate) fn rewrite_lock_entry( let mut replaced_dist = false; for (k, v) in original { match k.as_str() { - "source" => {} - "transport-options" => {} + k if ORIGIN_BOUND_ENTRY_KEYS.contains(&k) => {} "dist" => { out.insert("dist".to_string(), dist.clone()); out.insert("transport-options".to_string(), transport.clone()); diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 829a1c8de..353b636d8 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -1118,13 +1118,7 @@ pub(super) async fn gem_service_copy( format!("cannot move the extracted .gem into place: {e}"), ); } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(archive.downloaded_warning(name)); GemServiceCopy::Used } diff --git a/crates/socket-patch-core/src/vendor/golang.rs b/crates/socket-patch-core/src/vendor/golang.rs index c0ddd3376..7576c5601 100644 --- a/crates/socket-patch-core/src/vendor/golang.rs +++ b/crates/socket-patch-core/src/vendor/golang.rs @@ -567,13 +567,7 @@ async fn go_service_redirect( format!("failed to update go.mod: {e}"), ); } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {module} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(archive.downloaded_warning(module)); GoServiceRedirect::Used(file_inventory) } diff --git a/crates/socket-patch-core/src/vendor/maven_repo.rs b/crates/socket-patch-core/src/vendor/maven_repo.rs index 31df8839e..8615b7c5b 100644 --- a/crates/socket-patch-core/src/vendor/maven_repo.rs +++ b/crates/socket-patch-core/src/vendor/maven_repo.rs @@ -1373,7 +1373,15 @@ async fn fetch_pom_bytes(url: &str) -> Result, String> { fetch_registry_bytes(url, MAX_POM_BYTES as u64).await } +/// Bounded HTTP GET from a Maven registry. Errors quote the URL redacted +/// (a mirror's userinfo, reqwest's own error text included). pub(crate) async fn fetch_registry_bytes(url: &str, cap: u64) -> Result, String> { + fetch_registry_bytes_unredacted(url, cap) + .await + .map_err(|e| crate::utils::redact::redact_urls_in(&e).into_owned()) +} + +async fn fetch_registry_bytes_unredacted(url: &str, cap: u64) -> Result, String> { let client = super::registry_fetch::registry_client_builder(MAVEN_USER_AGENT) .build() .map_err(|e| format!("build http client: {e}"))?; diff --git a/crates/socket-patch-core/src/vendor/mod.rs b/crates/socket-patch-core/src/vendor/mod.rs index d37d81f64..f69750f01 100644 --- a/crates/socket-patch-core/src/vendor/mod.rs +++ b/crates/socket-patch-core/src/vendor/mod.rs @@ -150,11 +150,18 @@ pub struct VendorWarning { } impl VendorWarning { + /// Every URL quoted in `detail` is redacted + /// ([`crate::utils::redact::redact_urls_in`]): a vendor warning lands in + /// `--json` events and CI logs, and the URLs it quotes (service grant + /// URLs, GOPROXY / `.npmrc` / private-index registries) carry + /// credentials. pub fn new(code: &'static str, detail: impl Into) -> Self { - Self { - code, - detail: detail.into(), - } + let detail = detail.into(); + let detail = match crate::utils::redact::redact_urls_in(&detail) { + std::borrow::Cow::Borrowed(_) => detail, + std::borrow::Cow::Owned(redacted) => redacted, + }; + Self { code, detail } } } @@ -1915,6 +1922,28 @@ mod harvest_tests { } } +#[cfg(test)] +mod vendor_warning_redaction_tests { + use super::*; + + /// A vendor warning lands in `--json` events and CI logs: every URL its + /// detail quotes is redacted at construction, whoever builds it. + #[test] + fn a_vendor_warning_never_carries_a_credential() { + let w = VendorWarning::new( + "vendor_registry_fetch_failed", + "GET https://u:p@h.example/patch/npm/a/1.0.0/TOK/7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d/a.tgz: \ + HTTP 404 (GOPROXY=https://proxy.golang.org,https://bot:ghp_X@goproxy.corp,direct)", + ); + for needle in ["TOK", "u:p", "bot:ghp_X"] { + assert!(!w.detail.contains(needle), "{needle}: {}", w.detail); + } + assert!(w.detail.contains("HTTP 404"), "{}", w.detail); + let plain = VendorWarning::new("c", "no url here"); + assert_eq!(plain.detail, "no url here"); + } +} + #[cfg(test)] mod berry_migration_risk_tests { use super::*; diff --git a/crates/socket-patch-core/src/vendor/npm_common.rs b/crates/socket-patch-core/src/vendor/npm_common.rs index 0a82bf702..683f7b5fb 100644 --- a/crates/socket-patch-core/src/vendor/npm_common.rs +++ b/crates/socket-patch-core/src/vendor/npm_common.rs @@ -470,14 +470,10 @@ async fn try_service_pack( .await { Ok(mut staged) => { + warnings.push( + archive.downloaded_warning(format_args!("{}@{}", coords.name, coords.version)), + ); staged.packed.yarn_berry10c0 = archive.yarn_berry10c0; - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {}@{} from the patch service ({})", - coords.name, coords.version, archive.source_url - ), - )); // No local apply to verify — every patched file reads as // `AlreadyPatched` (the tarball's members were checked against // their afterHashes above). diff --git a/crates/socket-patch-core/src/vendor/npm_dir.rs b/crates/socket-patch-core/src/vendor/npm_dir.rs index 1ee6fc851..b3362ce29 100644 --- a/crates/socket-patch-core/src/vendor/npm_dir.rs +++ b/crates/socket-patch-core/src/vendor/npm_dir.rs @@ -874,6 +874,7 @@ pub(super) async fn try_service_dir( Ok(archive) => archive, Err(attempt) => return attempt, }; + let downloaded = archive.downloaded_warning(format_args!("{name}@{version}")); let (bytes, dest) = (archive.bytes, stage.to_path_buf()); let extracted = tokio::task::spawn_blocking(move || { super::registry_fetch::extract_tgz_strict(&bytes, &dest) @@ -898,13 +899,7 @@ pub(super) async fn try_service_dir( ), ); } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name}@{version} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(downloaded); ServiceDir::Used(()) } diff --git a/crates/socket-patch-core/src/vendor/pypi.rs b/crates/socket-patch-core/src/vendor/pypi.rs index 73822f574..5baad640f 100644 --- a/crates/socket-patch-core/src/vendor/pypi.rs +++ b/crates/socket-patch-core/src/vendor/pypi.rs @@ -2712,13 +2712,7 @@ async fn try_pypi_service_wheel( } else { (false, String::new()) }; - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored the wheel for {base} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(archive.downloaded_warning(format_args!("the wheel for {base}"))); PypiServiceWheel::Used(Box::new(AcquiredWheel { rel_wheel, result: if dry_run { diff --git a/crates/socket-patch-core/src/vendor/registry_fetch.rs b/crates/socket-patch-core/src/vendor/registry_fetch.rs index dc3f3cbd9..304e089cf 100644 --- a/crates/socket-patch-core/src/vendor/registry_fetch.rs +++ b/crates/socket-patch-core/src/vendor/registry_fetch.rs @@ -1220,8 +1220,16 @@ fn walk_zip_with_prefix( /// Capped download. http(s) only; [`crate::utils::http::read_capped`] /// enforces [`MAX_DOWNLOAD_BYTES`] on the declared Content-Length AND the -/// actual stream (a lying server cannot blow past it). +/// actual stream (a lying server cannot blow past it). Every error quotes +/// the URL redacted (userinfo from a GOPROXY or `.npmrc` registry, a grant +/// token, a signed query), reqwest's own error text included. pub(crate) async fn download(client: &reqwest::Client, url: &str) -> Result, String> { + download_unredacted(client, url) + .await + .map_err(|e| crate::utils::redact::redact_urls_in(&e).into_owned()) +} + +async fn download_unredacted(client: &reqwest::Client, url: &str) -> Result, String> { if !(url.starts_with("https://") || url.starts_with("http://")) { return Err(format!("refusing non-http(s) artifact URL `{url}`")); } @@ -2953,6 +2961,39 @@ mod tests { assert!(err.contains("sha256"), "{err}"); } + /// Every `download` error quotes its URL redacted: a refused scheme, an + /// HTTP error status and a transport failure (reqwest's own text). + #[tokio::test] + async fn download_errors_never_carry_a_credential() { + const UUID: &str = "7c8d9e0f-1a2b-4a1b-8c2d-3e4f5a6b7c8d"; + use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + let served = server.uri().replace("http://", "http://u:pw@"); + // A port nothing listens on: the connect fails. + let dead = { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap() + }; + for url in [ + format!("ftp://u:pw@h.example/patch/npm/a/1.0.0/GRANTTOKEN/{UUID}/a.tgz"), + format!("{served}/patch/npm/a/1.0.0/GRANTTOKEN/{UUID}/a.tgz?token=QSECRET"), + format!("http://u:pw@{dead}/patch/npm/a/1.0.0/GRANTTOKEN/{UUID}/a.tgz"), + ] { + let err = download(&build_registry_client(), &url).await.unwrap_err(); + for needle in ["GRANTTOKEN", "u:pw", "QSECRET"] { + assert!(!err.contains(needle), "{needle} in {err}"); + } + assert!( + err.contains(UUID), + "the error still names the artifact: {err}" + ); + } + } + #[tokio::test] async fn download_refuses_lying_content_length() { // wiremock cannot send a mismatched Content-Length, so script a raw diff --git a/crates/socket-patch-core/src/vendor/reuse.rs b/crates/socket-patch-core/src/vendor/reuse.rs index 00cf37790..3c6799403 100644 --- a/crates/socket-patch-core/src/vendor/reuse.rs +++ b/crates/socket-patch-core/src/vendor/reuse.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::path::Path; use crate::manifest::schema::PatchRecord; -use crate::utils::env_compat::is_debug_enabled; use super::state::{load_state_shared, VendorEntry}; use super::verify::{ @@ -46,9 +45,10 @@ pub(crate) enum ReuseMiss { /// Debug-log a reuse miss (`SOCKET_DEBUG`); the caller then acquires. pub(crate) fn log_miss(purl: &str, miss: &ReuseMiss) { - if is_debug_enabled() { - eprintln!("[socket-patch debug] vendor reuse skipped for {purl}: {miss:?}"); - } + crate::utils::env_compat::debug_log( + "debug", + &format!("vendor reuse skipped for {purl}: {miss:?}"), + ); } fn norm(path: &str) -> String { diff --git a/crates/socket-patch-core/src/vendor/service_fetch.rs b/crates/socket-patch-core/src/vendor/service_fetch.rs index 6cf734f27..824caca70 100644 --- a/crates/socket-patch-core/src/vendor/service_fetch.rs +++ b/crates/socket-patch-core/src/vendor/service_fetch.rs @@ -92,6 +92,19 @@ pub(crate) struct VerifiedArchive { } impl VerifiedArchive { + /// The `vendor_prebuilt_downloaded` advisory every backend reports once + /// it has used these bytes for `subject`. The source URL carries the + /// org's grant token; [`VendorWarning::new`] quotes it redacted. + pub(crate) fn downloaded_warning(&self, subject: impl std::fmt::Display) -> VendorWarning { + VendorWarning::new( + "vendor_prebuilt_downloaded", + format!( + "vendored {subject} from the patch service ({})", + self.source_url + ), + ) + } + /// Hex sha256 of [`Self::bytes`], digested once on first ask. pub(crate) fn sha256_hex(&self) -> &str { self.sha256_hex @@ -337,13 +350,7 @@ pub(crate) async fn service_archive_copy( ), ); } - warnings.push(VendorWarning::new( - "vendor_prebuilt_downloaded", - format!( - "vendored {name} from the patch service ({})", - archive.source_url - ), - )); + warnings.push(archive.downloaded_warning(name)); ServiceCopy::Used(archive.bytes) } @@ -414,6 +421,35 @@ mod tests { const UUID: &str = "22222222-2222-2222-2222-222222222222"; const SERVE_PATH: &str = "/patch/npm/x/1.0.0/tok/uuid/x-1.0.0.tgz"; + /// The `vendor_prebuilt_downloaded` advisory quotes the service URL the + /// bytes came from, which carries the org's grant token: the token (and + /// any userinfo) never reaches the `--json` event, the uuid and file + /// name do. + #[test] + fn the_download_advisory_quotes_the_grant_url_redacted() { + let archive = VerifiedArchive { + yarn_berry10c0: None, + bytes: Vec::new(), + integrity_sri: String::new(), + sha256_hex: std::sync::OnceLock::new(), + source_url: format!( + "https://u:pw@patch.socket.dev/patch/npm/x/1.0.0/GRANTTOKEN/{UUID}/x-1.0.0.tgz" + ), + secondary: Vec::new(), + prestaged: Default::default(), + }; + let w = archive.downloaded_warning("x@1.0.0"); + assert_eq!(w.code, "vendor_prebuilt_downloaded"); + assert!(!w.detail.contains("GRANTTOKEN"), "{}", w.detail); + assert!(!w.detail.contains("u:pw"), "{}", w.detail); + assert!( + w.detail + .contains(&format!("//{UUID}/x-1.0.0.tgz")), + "{}", + w.detail + ); + } + /// A files-less record for [`UUID`]: the Tier-A afterHash gate then only /// requires the served bytes to be a readable zip. fn record() -> PatchRecord { diff --git a/crates/socket-patch-core/src/vex/discover/yarn.rs b/crates/socket-patch-core/src/vex/discover/yarn.rs index d8a25e9a7..da5da07c9 100644 --- a/crates/socket-patch-core/src/vex/discover/yarn.rs +++ b/crates/socket-patch-core/src/vex/discover/yarn.rs @@ -842,7 +842,11 @@ async fn record_copies(ctx: &DiscoverCtx<'_>, copies: Vec, out: &mut D } CopyRef::Url(url) => ( registry_tarball_name(url, ©.version), - format!("installs it from {url:?}"), + // Published in the VEX document: never a credential. + format!( + "installs it from {:?}", + crate::utils::redact::redact_url(url) + ), ), }; let Some(name) = name else { diff --git a/crates/socket-patch-core/tests/equivalence/composer_lock_rewrite.golden b/crates/socket-patch-core/tests/equivalence/composer_lock_rewrite.golden index faee09391..0cafcac65 100644 --- a/crates/socket-patch-core/tests/equivalence/composer_lock_rewrite.golden +++ b/crates/socket-patch-core/tests/equivalence/composer_lock_rewrite.golden @@ -1,302 +1,302 @@ # One seeded composer.lock + overrides; the output covers a re-run over the result. # -0-9 1288d70b92064336 314f62ae730d5c3a -10-19 a36decf807272c88 f903bf56522007a5 -20-29 bed718a66f637695 462406c0611a9d65 -30-39 41ecc53cebf85f22 62891f3dca92486e -40-49 8531fe0cf2a0b149 6cc9db49566d667e -50-59 f7bd55b9c4e6a7dd cf0cf604f0974464 -60-69 d946a0a8ba18b16f d0514f79dfa6c3d1 -70-79 b1a416d5556a6b55 a1fe1f796b91809c -80-89 6335cce8ee68ce00 0539a0bde4ef7461 -90-99 b1e6cab666a86df2 d761c35a42352cf3 -100-109 c6d76af78779b75a fb704eb8d455e698 -110-119 99095e7ded958ccb 56dd1244c9b952f2 -120-129 de164259504cdcf3 6a69e144193b8900 -130-139 25db3d56283f568b cc2734f57a40e7ea -140-149 d6f61f532d5da9a3 23ec231602b58861 -150-159 fa41d9196dbcfdf6 5aff7446d8c65352 -160-169 9f1f50cbdd8e3566 f1d6621709030c61 -170-179 f6742a787f3a8846 d12bc741378a6013 -180-189 ee98f83b4fe856a3 4be18f7b52ca5e7a -190-199 6cd4f93d27ac9f70 b71dd4d7131a7fe7 -200-209 b6b774f19e4ca83f ffd3df2507a85567 -210-219 90fdbc39469ef867 63a1c6cbd04b0d01 -220-229 23fdb21fd7b0ab00 e53e26f669337155 -230-239 98becd490682ab77 8cb8a92d22dc9e9d -240-249 7ae3f41d6158f100 96900cdbbb6500c4 -250-259 cd77e2b995e6744b d42a87d8e2ac904d -260-269 7836220231beb718 046cde8c95e8485d -270-279 f496fc9ec3b389d0 b742e7d002ae7e82 -280-289 c90d84d93838eb9f f13ade9cd0bb02b3 -290-299 13fc2bf2f252b265 916bfa1d457dda80 -300-309 318988f247160972 7383f683cc2ed1f4 -310-319 d37db14ec486d2ae b8a2f90c62a92f13 -320-329 7b979f29a9b75805 7a67700f3d0e66d6 -330-339 5ce6be257a7ea29f 2229842c276b227c -340-349 001d06da5ae2e64b bc20fbb49cf5dd91 -350-359 580b2b84e5319445 de58cff0f782e25c -360-369 b97a0b97efba5249 c75984d7ea1a6e3f -370-379 1e99642d82e3f4ba 56e71e771d714fd3 -380-389 7d5011d6da814d39 90619b0357d2ac0d -390-399 14dcfc36afc812a2 d3a93fd1d18fc7f3 -400-409 2bf08801c2ab7de1 1b62236f901cb2d0 -410-419 908419d28a7be5ca abc94c829fad2a41 -420-429 f7d72a8153af8d45 43d70a5d5b736192 -430-439 f7a3ae7fb3f16217 5b3c4624c778d797 -440-449 2f8808658628299f ef28eae9343dc3f2 -450-459 2a2e8c1401d9f27c 45a24be31acc765e -460-469 c0e7c2b02cd5070f aefa64f230f78672 -470-479 7b9c101f6f925b75 72b7c992155baaa9 -480-489 5848a14e9a9dfb9a 4405ccce9476e8a7 -490-499 9ba576cc0535724f f72a4d2c855c7649 -500-509 6b89164000d1cf09 f328c0743e81e156 -510-519 f5250cbddb99f79e 27e73aea946406cc -520-529 d68c26d47ce663f6 aeeb053d5f4aef39 -530-539 764ee93408ea9fda c21a96ea3a2a6bf7 -540-549 25960f41885398f4 92516d7e569cae87 -550-559 d7b53465e435475b 935dce2248b1b375 -560-569 71015b30c91e3bbc 25e57f68e88175c3 -570-579 7ff9baa5e227912a ace885c8a2e8f67a -580-589 f87b3278df86f8c8 761ec4e6daf493da -590-599 1d441bdb3e78c6da df37bd8969ccb509 -600-609 9a4e47cdc84fa4f3 540b1babf92d68eb -610-619 61fc827b551d140e e93a1b9e7f17c8cc -620-629 6af02e95bbc08e5f 34a0d277524ad46b -630-639 fabed369435d7788 af6a204e50249ac7 -640-649 07c2d1596eae2cc1 3705f52750ee6282 -650-659 fa08797df4cb91dc 729832224cf74d5d -660-669 ac13f85a56b9920e cc73cf56cbcc70ac -670-679 6a9cf422423133ac 4fb19ffc1b177649 +0-9 5634a8ca76982699 b3a05da5696c9d87 +10-19 84cca971616919ac f45cce3641a2fbdf +20-29 7f22b7178dedc8fb fff910f9eade847e +30-39 b09dea0fd890f9d6 8219aad86ce6e228 +40-49 e247091e2379fe60 fc12399120afebb3 +50-59 1295c45e38910bd7 e5c0aec2deae3b09 +60-69 140ad3bc9063b643 d0514f79dfa6c3d1 +70-79 41f5b9d410562f5e ed1f47faef7fa12c +80-89 e36e8d36d953b319 d7f6a08de8cbd99f +90-99 8398487b384952fd ac14cd57e1a3253a +100-109 b3be8e94c215b29e c7387576ae674f7d +110-119 1db3809dc490101b a8a410c0ac509b00 +120-129 4671326df36501cc 88b8f335615228b6 +130-139 25fc46f3a2a3abc3 573bab92821e9dbb +140-149 0917cbdbc2c22499 1b267a5bc981efbf +150-159 7321a2ea917c82ac 7dde35349f4fbf4a +160-169 e75c8e0d540fd310 9892a8202a3fe757 +170-179 3748997ba6aec13e b4a1dd356c8e0b7f +180-189 78ee79cabb704648 fa66f14352127fcf +190-199 afbf2a2a45e37f1b 637e258feaa4b729 +200-209 c709e11411be3395 ab28c5ef9c900eee +210-219 aa8c0c5581533836 e9d5e4b77f6dda9b +220-229 1db95c2ed5d2e89f ae40428c86bf6dce +230-239 902c064fdb75c8e0 7f807aeeccdc89ae +240-249 ad7d597569ecd151 0e367cfdafea3fef +250-259 02a7f327a3e8c07b eaef74cffaf05f00 +260-269 58d6983695e62896 e30078df4ba0cfab +270-279 d194d95e142b7c8a e3377ca9945c6981 +280-289 ae4131d1da5c564f 1d65538b34096e5f +290-299 4e6ff620f57e1e36 dcf3c3c092c7a647 +300-309 f1139f32bda24a33 293fbd1822aa889a +310-319 2e427edda162ca81 03672b5e491f96dd +320-329 b63bd3b71b75a207 56af421975d6681c +330-339 6f3d6acab1047a6d 9f1ccf62d6cd6303 +340-349 fefad03976f65796 dfb32b453efb58a7 +350-359 80b2c906958ff4ce 4697b241fa84a3ae +360-369 24a8ef6911f9096a 21571254b4bb61a3 +370-379 2d17395d23617cbc c7f26de110a8c992 +380-389 385198714e28f70c 90619b0357d2ac0d +390-399 acd6455b4780000e b893c7b653b0d218 +400-409 474477e6c8540e12 1b62236f901cb2d0 +410-419 887a7c625ef5164b b61b7d7fccc4f8b1 +420-429 ed70690d68a16f5c 3f026d395e72234a +430-439 a29fa3fa04bbe309 ba53355fc699f2b7 +440-449 ec8ba6004a287697 ef28eae9343dc3f2 +450-459 3ae70771c3dd159e 7088da7ea1dae66f +460-469 3d197e3306b77127 aefa64f230f78672 +470-479 e82a7df3d31c1805 273a2ea0b66d710e +480-489 5a542ce75a9f7c11 fe4b22c750149594 +490-499 e11def9a0b1a422d b8a66cc7847f04d7 +500-509 a40b6c5200723bb3 106933cf27fd00f3 +510-519 ac4ba594a19b60db 4f7fdfc32a2097c4 +520-529 3854453143d13807 11768ae16afb4c09 +530-539 fe0249e26e7e5548 c21a96ea3a2a6bf7 +540-549 763b82fc9e2e19b4 f0c44c00e142d267 +550-559 0c5c0825f0889ad8 8c94249b29ddaa06 +560-569 37b7fd75ec8b18a9 25e57f68e88175c3 +570-579 ecfa84f228af4b19 085c9b2aaf9cf51e +580-589 2de440dfce4f0554 6a54d099b7f58145 +590-599 e8b34df0888f70e1 df37bd8969ccb509 +600-609 6f1f7a953c9b5e88 8b1107031257fbc6 +610-619 f31eb1835749d18f 4deb13e6b19bc2a7 +620-629 43325cf64fe8688e 871a1eb8a987c3ac +630-639 7def8b726c4e3758 4d3690c48d1b624d +640-649 75110eb41bf3909b d68b7ed2127146dc +650-659 eca172dbfedec7f3 831e67a8b896f7f7 +660-669 cf5f97e1d12ef2a2 855a616edace296e +670-679 da20b4dbf51e99cc f806358688a36a0f 680-689 06d24aede301ebbe 275abaf308208223 -690-699 4b4caa7886485091 fd126422b142aaa6 -700-709 fccfd87c5b3350d7 a9bbe8be1f07d60b -710-719 ef24af2b1e4e6698 51984f0c7622939a -720-729 0adfcf46ae5b392a 2fd1efe30867d50d -730-739 8dfd74d4c87f150d d119ba3178ca7df5 -740-749 0ee15b9a21ba8fbc 75749140bfadaab1 -750-759 edd347829145cf9a 47c8dd18a3c65f4f -760-769 af7266e6a93221f5 472fe5b204e3df96 -770-779 c378fa59e326d217 d737534c7443adc9 -780-789 58946e80bde8cd55 1d3b493f1a754931 -790-799 8227329724bf9ff0 a735bc38fc1c0ad2 -800-809 48800776f086c092 a41395e1e1337f34 -810-819 f8445eee54339301 df288e83a7c31a13 -820-829 fc28cbbc0f0a8a31 6f1df562b94995e2 -830-839 e36c897b0c26795f 6fe7f5acc8183272 -840-849 64da3b51199bfd22 7b097b108f715a52 -850-859 c2b721375f72e7fb e365ecd26a116bcf -860-869 7074210a1211b984 f49171ff0fa3a479 -870-879 12d1cc1ee968ea3e 2a31c588cf63f285 -880-889 b54dfdb86af605a7 f590f6b5b35c9afe -890-899 c6a704c5b2018def 1f11a089c6551d4b -900-909 0eb737d12bfd5a18 9d90f3170e1f1a0e -910-919 5eb7d4aebf443fc5 87b348d0e0901476 -920-929 ad7c1cef20adb36f 8316af550b1e6688 -930-939 c2242b1c70861eb4 f6f2a02ed61ae54a -940-949 a89bdbc730624854 1f17324518e0ce38 -950-959 bc7a8d0bce932bad fc919e6d650c1023 -960-969 f436d7a93dd04397 e1bf01b6b0370759 -970-979 6f282293c052ae5f 05142dae898b11cc -980-989 37740273da890765 97ad45f8f495f6d3 -990-999 bc0635ce05050f56 4478bf6c7a2e4079 -1000-1009 6df934b6517d6603 d9d4255f07a0c284 -1010-1019 da466cd165a37333 fb4f90e2c6641666 -1020-1029 fa9d944c2b66151e 4c10e3bbac1240d6 -1030-1039 405d49366f2b636f 2fee965bf5fa0a1c -1040-1049 07529e22d6145a6b c00edf2c1f20d15f -1050-1059 21a6d00836e813e5 3d0f28ec79634b80 -1060-1069 1a0f487a51090723 452d295aefbbaaaa -1070-1079 541d36adbe759473 9cfa30b66349785a -1080-1089 e4dbfbe6552ff3d5 b3f9f102f8d567f0 -1090-1099 672e335bfe3fa9cf cba3da2993fbdc70 -1100-1109 478bd41fe84a4698 74bdb7d7912fa956 -1110-1119 3beeb42ff6c6c0e6 24208f9c1c5f1b8a -1120-1129 8e6ee499a4551fdd 4434434c835c6ac3 -1130-1139 96f66f541d1e8224 e8dc9335a2880238 -1140-1149 c87a6f785c6376ab 7eac82c637c3cf06 -1150-1159 b81a1485a49c1249 fae350821908e386 -1160-1169 b86c990c8d2498d4 345ca465e24f6d49 -1170-1179 235ae128bccb5352 dd8eeb3f71b0f77b -1180-1189 7e512b66d284bf01 70dc1d9cc4b7d0c9 -1190-1199 42ad893789f44961 e98fb09350f6cda7 +690-699 3a72c383f03ea28c 26e2b4f1adb02e9d +700-709 8554f796e1a7b094 f4d2efee65cd3b43 +710-719 5b304aab8e5cf8d9 e0faaa97ab7adf01 +720-729 4a4f5adf52d0769a 1a8467c3f2967bfd +730-739 529cb5a7b5564b1e 8693376ed74ea4ef +740-749 79e4baa3bf826828 f24f47063f028dea +750-759 eefc133909a1a1ab 913dbbdef507ce67 +760-769 379a15a13360f293 45114a360f0ca545 +770-779 a6c9f4ac3fda575d 8bb86d8be67ffbc3 +780-789 9c71bec71d01de52 605e335a098d330b +790-799 f104b9b7dd5274cb fcba20b403292484 +800-809 cbbc11ab250da71c a989164374b35261 +810-819 887e43373424de9f 32d0becb44fd3671 +820-829 f275d3fc5ffa190e 94a4f899f8410e59 +830-839 423647324668f259 135c4e379fdbd711 +840-849 96f53e381b3faf54 fbd60a8884fd80f3 +850-859 cf06d6296dc177eb dc7bdb013ed21c4a +860-869 201b07543f98fa8a 98aefa67e4c2b273 +870-879 bcf3fa29f90df5d7 10b83471bcdce206 +880-889 c4c0bd3ffcc6f6d6 f9c90d0a8d772473 +890-899 ce544df15072896f 42b3f2522ea973be +900-909 b5517e15b46e0356 a1c746fc2da94283 +910-919 b9f5816cd325ac4d 248cd76af85970df +920-929 cf8d65260406ae17 8316af550b1e6688 +930-939 c9a9f1891c7a29da b21add02d84e2324 +940-949 76282736e37fb31b 250259571dc4dca2 +950-959 9144efec3528386c 1dd6751b1d2a9d5c +960-969 2b74f22b64f3258a 550ffc3eb1d31066 +970-979 e7137c3c5e0bb6ec 99f22c14d655d352 +980-989 75a79b50d7dd8267 f1bdb0361aadc2cc +990-999 b4892c58c58d32af 171caa1477967c6d +1000-1009 57fe441295077f36 cfad3c63b669b2ff +1010-1019 f9d4c6c75b814e2f 62caca677e56a3c3 +1020-1029 d5939529a55b7954 4a99f31795fd3990 +1030-1039 b8ec1dc396b5ef2c 9af91bc45e9aa65e +1040-1049 140f39e1f16ca7b3 1e174d1db542ae8f +1050-1059 fcc82c5597ae2234 597666e52e08b1b3 +1060-1069 28c1742214f28ea0 1a459182216767ca +1070-1079 65a203941b85a7bd bcd2e78e6bc5da5d +1080-1089 8c062c0da9b0a7d1 58fe4d5a1666c701 +1090-1099 a17d9604d176dc42 7ff68f643a0556c9 +1100-1109 c4c333fbf3eece68 da8cda66e80ad938 +1110-1119 beffd22c14d79ae7 4795537c2345655f +1120-1129 4f5c8086854401a9 f9d8a41c815a73ce +1130-1139 1c7f70ea6d36cc33 05158dc3676ddd1b +1140-1149 dcee61864c6ddbb9 1af99354cb01600c +1150-1159 c30279e015068acc f5b499a1aa7ec9d6 +1160-1169 8813c1bf02c274d3 517c14366b385966 +1170-1179 813426ff3fd32b75 9514ce49cd92c976 +1180-1189 b692930b012d2d56 d8a7e333cca6b8bb +1190-1199 9d70be653ad1ad4a e98fb09350f6cda7 1200-1209 bf64db218180f70f d74cadd355ba7c91 -1210-1219 3fe55f4d75e446d9 d45ba2dfeb56b8c6 -1220-1229 c72c872ab468ca6f c5b2fdd619917435 -1230-1239 11cf3c79d5728e2a cfca6c378560fde2 -1240-1249 4b49bdfd7dc202e4 c983e3b5f179a32e -1250-1259 217ab8c0b9de6b48 8153d73ea4c48fa1 -1260-1269 5448aec7498b8497 d9ec78571068e5bb -1270-1279 3796e7e5c7ab2f59 b102bcbf3d82ea3e -1280-1289 7c478aa6d65da5d5 7d98934c146417ab -1290-1299 d0a8382565b40c7a a7fbd048dbc45974 -1300-1309 5ff547e9854720f7 87efef3e85cb3861 -1310-1319 5481f19fe861547a 0ff7e0be0c2c79ca -1320-1329 d23128d544a4fea6 1f27862eb9715b5e -1330-1339 8e034c89f0d18bfd d44f5abadbee6fac -1340-1349 99ad9d49ec3d5476 4c4d6795d75c7d04 -1350-1359 0ff30717ec2d91c2 3210001deb19202f -1360-1369 74e4086fb223c15f 4dc53d39ea4cdb15 -1370-1379 ffa7d0470ba79162 4034731d7a09ec0a -1380-1389 7f5797342da595b8 18b3044518872502 -1390-1399 bbe9588b0cb72f7b 66883ce7860e342a +1210-1219 f1a88f5c12a476ae 66b36ad8e2b65899 +1220-1229 4c6af7af06237273 f417ad469fa54aab +1230-1239 e2d065315a2d0cb5 0c77b74ac2b7018c +1240-1249 e9c4917a5dbe73ea 9e9ee57b81ac22cc +1250-1259 31a4a39d65226a99 8153d73ea4c48fa1 +1260-1269 09045418330b7d2c 9cc2c5e1ca0dd848 +1270-1279 eaadc80116a960d0 adfbca7f6770f7d5 +1280-1289 dc89fc43f3b83ebf aca33190ac06adbd +1290-1299 e9ed073f54a5072c bbf38b319616e791 +1300-1309 503ca2a313114dc0 077c46481c5d028e +1310-1319 c2cbc1ca7eec8a4c c60f4d859f2826f7 +1320-1329 6fc6e66684a81d97 79f8d01df6a1cde1 +1330-1339 d3badd555d23f07e c724d52967e50cb7 +1340-1349 db30c2a93cab8f1a f114e146cb1d751d +1350-1359 2956aa24abf9ea97 f8720c4f901d656e +1360-1369 bb2eda8d3fa32285 00b7e619e64be058 +1370-1379 da32e46b307de6d4 4ede441972f74b0c +1380-1389 73d896bc0861b130 ead771586b5dc2ae +1390-1399 14f775a28a0afce2 87e2ca993856a63f 1400-1409 67d2094315ccc134 ea91bdf9b2758ab5 -1410-1419 46999913e87dde6a b62aa4652d0c4f5c -1420-1429 47f5aa472f1d99c3 b14c00c229ee737f -1430-1439 94cc6a7004668055 53e4e6bbcbdef862 -1440-1449 eb2a2922ce3761dc 5e3b00d1761fa780 -1450-1459 4c64904d71fbcf41 e56a0ec213bba9dc -1460-1469 d4d8755cf5c7e766 b86a8e19a31cfbf8 -1470-1479 4b1ac5c96bda914e a898752ac6a06cfd -1480-1489 a167b14e7eb25547 b96ec47898203074 -1490-1499 2010d43a102fa78e b75ff7cdc748a389 -1500-1509 c7d3a057c1ba1cea 1bc67a36f87ff22e -1510-1519 4a71113dc5d8f93a 0868332243093390 -1520-1529 de15da7561ed69b3 9014711750d4b701 -1530-1539 6d1cc3c6724609f7 1ff300f7bc106473 -1540-1549 6eefb1393f45336b df196e5b415ff165 -1550-1559 c7103c15717e3688 b0929d0c7e6eca40 -1560-1569 266c88234e1256f3 31eb4e2a207c0cb6 -1570-1579 809a48abfec394b8 7f689f0e2e60e513 -1580-1589 bb87d22616abf011 7051b4edd67cc00a -1590-1599 31aec8556ab31bdc 8e4c086a3d1b3a05 -1600-1609 691bc4b725cbc5de 3f7b0c5ffde64659 -1610-1619 05610371b947c9e4 c57b13549c37238b -1620-1629 bef0e4719af6fcbe f7ad3458b02e125f -1630-1639 2722f2936ebf1dc3 4105f584f19e9cce -1640-1649 0f9ab33506cbc44c 44c475974804b41a -1650-1659 6acc180d0dbf287d 25c526ef20c5ccc0 -1660-1669 ac6f2825e026000d bf9be6581f6063df +1410-1419 fad56b630b34a1e9 c2a9b82cff0ea44e +1420-1429 cdc7eead66d7f0bf 8765e3c4b65ac9d1 +1430-1439 a93b0d8ff58b6ea8 2486f40301f79f8d +1440-1449 1c748da47a60f3cd 354e934f902fc57e +1450-1459 48341877a258ffb1 2372b8a4a27a4ec0 +1460-1469 11d3d35538e578c9 b86a8e19a31cfbf8 +1470-1479 16dfecb5e9645dae 0f8ffff611b23bd6 +1480-1489 06ab5e356afd4487 0938a210d252c4aa +1490-1499 f7c073df0e348be3 5516cc74be172b04 +1500-1509 0183423fa391d080 f82ae2ae04605774 +1510-1519 5d1f396fbd3518c4 59734f93224d94ca +1520-1529 93bdba2a2add4dfb 6c419920e79540d6 +1530-1539 bb8d1c49ebaacaf0 1ff300f7bc106473 +1540-1549 31599d51f9493527 d0c9ba3d1973b1d3 +1550-1559 18bed3cfd25b1548 b0929d0c7e6eca40 +1560-1569 41f2fdc8c070e0b2 865b0259b8b81065 +1570-1579 d528bb323c720049 5b400b35ac14dbf8 +1580-1589 8319ea927081c0ee eeb7bb0db8d16c6a +1590-1599 45f54df1322924c3 c4701435cbe0e464 +1600-1609 8fd42947661aed31 55edfa03f28ac937 +1610-1619 9b373d50f1a4ba36 94b9d75557cad100 +1620-1629 b63022b2101bc8bb 8e09b4ec9b4e5bfc +1630-1639 9961a953cbbf9317 96faf0bd0d6cc1af +1640-1649 23ba58c9fa2d941f 39681fb6b5014753 +1650-1659 e4a374f391bc3bce 02e8ee6f4b696859 +1660-1669 156c82250912d61e bf9be6581f6063df 1670-1679 402fad28f6fe3e69 83b24eaec18c393d -1680-1689 de036ae1ee40e549 03af47ae8d7422a9 -1690-1699 e567bf31e8fda652 0f3f93077f3ba87d -1700-1709 1abfbfdf9c68f150 3c891161d4085218 -1710-1719 1acad0f0568d0625 fc5eb1dae8b38fc6 -1720-1729 b8394b5f2f29cd92 0c650a7de292a7c1 -1730-1739 f5fad73e559abe2c 1a7d282f914a332d -1740-1749 e7022740a3c4cce8 5454277fd9ea72e3 -1750-1759 17a7b88ddf681a46 ac69c8b4d47bb654 -1760-1769 4ae219cd9c430bac ad2656038f5451b4 -1770-1779 0cf7397877cf1243 0126000571106e17 -1780-1789 ff3aef0287f7883d 3d46f6f22c5fe937 -1790-1799 75ddcb47120521a6 ed7fcdc69be4f14b -1800-1809 1c309d4d5b3be6ae b0f707ab8fe837dc -1810-1819 eb8b8754f3402fe3 7c71f03ac549515f -1820-1829 6a6e86846353a438 7367820dd62f8761 -1830-1839 3883e0090d2e9a96 507141b61f3c5b20 -1840-1849 77171e148c5b15ca 4bc5b904f4865513 -1850-1859 2bb62031dc520c23 68d58aa62574bdfd -1860-1869 ce0764c4a39543c4 b6676a21d68ebc8c -1870-1879 d8fa749aa1dc5e91 98ff70dd73ef7021 -1880-1889 f8123fc88eba64f0 ac603f91ce9f141a -1890-1899 8403132ec987b93f 733ea96a7540d4c3 -1900-1909 83108af94f0781d2 f37d872bfb9c6cb1 -1910-1919 21ac20af4b7b4e51 6651908382e3a8dd -1920-1929 88db3b1243d54065 47d3c32ae9efc080 -1930-1939 8749a5fbb02bd2b6 a44abf75274f1460 -1940-1949 e228b396f4171054 872fc0c9ab7feaac -1950-1959 44d87ccec3d96a41 a740a3184072e7c7 -1960-1969 494ac12c169c8c3f 30454dad1f68de39 -1970-1979 bdd191571fe44a9f ca3f9ad43d52af71 -1980-1989 86ff10d21aed5f95 7d133ebcac535db5 -1990-1999 4d2465006202cfeb 8658062ec91e3b81 -2000-2009 383b80af3da684e7 706ee4b80145f432 -2010-2019 a538e80ae301de73 b79158beb55b24bf -2020-2029 7560a73dc974f312 bf7ad4adfa53bab3 -2030-2039 52df516693162413 58815ef913facb82 -2040-2049 6bb52d1d58d0f4e9 d003d7e940c15fed -2050-2059 e2c377c4b472ad22 f1580cb47a9f7de9 -2060-2069 acec092aad64e55f 5ebc33d402335b2a -2070-2079 cb017138e7d7194e a6135be5b0721585 -2080-2089 75c9adc27f1ab03c e34c001f4b998534 -2090-2099 d8c13e56aad319c4 7c620007b7aaa9d8 -2100-2109 8d299ca0a418282d 1943cc8535f781e7 -2110-2119 1cde3d5cec2579c1 16cfcfbd6d21561f -2120-2129 aa50df3336534386 1397fe9ba170f825 -2130-2139 f729a25f385efffe f37192a75ebd2b74 -2140-2149 ced29f6ec2524eae 1dd2b3ae4053d2fb -2150-2159 9d402cd00edddcae 38c05133d23b4483 -2160-2169 0d08beda18a04086 8bd17e0799eb77f8 -2170-2179 613f7163505e7d8d 77e32e0a76fa145f -2180-2189 9457d310493e81d1 cfdfd038d418f142 -2190-2199 17d1f317e4bd2370 d363ddc5b56b0495 -2200-2209 87490213b8c93c59 029f3f83503d3d80 -2210-2219 b46c18e7c7920c4d 3af45b8cd9dbe9f7 -2220-2229 0cd0411f58ccddf3 276b4272b0b78b59 -2230-2239 267bfa2054cfcb40 d993358b0d0da53f -2240-2249 4772bf06d43a4ab9 6b756f9b44fe99e6 -2250-2259 08e0be6b9f2528a6 74424b26b4a529f5 -2260-2269 2ad1572b8f4ad519 f8442eea72439634 -2270-2279 a56fccf5cd64ee27 2e46a073a75effa3 -2280-2289 7aa63ed3ebfc7ce4 6d766e573cfb27d1 -2290-2299 bde646852bbb5bdb b504d1fa47f7aa1d -2300-2309 1e308f0a1a2cd032 07293b810e426c6d -2310-2319 665c05e356baee35 35224a6b50c2886f -2320-2329 4f566e819fa7219d cdb3ae316016874d -2330-2339 5928cf0d0e46e1ec e67612feb36d9af6 +1680-1689 dbdb8a3b8b93a757 fe6d18ab0d72abff +1690-1699 613a20dfe3700a7c a7ad9860e80a1895 +1700-1709 4d6e40083b2d4852 bbfbe0f2619f509a +1710-1719 08739297f7d1a195 66733929f19a7c2e +1720-1729 0bdce88b189f2ef7 e2723a57fc218f58 +1730-1739 2b59657f525657af 5417c7c1ffe18963 +1740-1749 80834af5fb7454a8 6d3fbd1d1e67529f +1750-1759 68ad4582d8d5e705 671a04cd71d96c01 +1760-1769 283a1c622fcc3d41 89ba09f59fd72f67 +1770-1779 a29cffbe3a865f21 5c2a86a668c1f029 +1780-1789 87201a8ddea5681c f31eb1d807b55169 +1790-1799 3e809a6bcac4a4bc e2b8751bceeab63c +1800-1809 d7d4cf5aa36384b1 fa3e9f59578d6399 +1810-1819 5600fa080e680fc1 7f8b82a5999c5108 +1820-1829 06675bef01932ba2 ab71fb509e026d1b +1830-1839 735b1de293452ebd e1a272ceb7daef1b +1840-1849 3076f66fa4fa048f 0943ded235eebd9a +1850-1859 b3f5be3cc6ae90a0 855b3f4af1dcba2b +1860-1869 cfb7cad5eb95476d 55195261e9b9736b +1870-1879 14888657bb067e62 78a2e3c72491c167 +1880-1889 cb5b51013356215f 9038e7e629b5bf4f +1890-1899 1687d182b0891f2b 22fd2e4b1fc4d15c +1900-1909 d5353987f6aec2a1 58b1b0e722710b5f +1910-1919 8e99aec189f14b8a ea8ed764772a7eed +1920-1929 ecbaee8be90711ab ca785d8513467f86 +1930-1939 9d41aaeffda518d0 d13cb9ed204507d5 +1940-1949 81e88977e2d9022a 725e0a7516ab1f14 +1950-1959 05cf72cd2dda8369 b526a77b3534fed2 +1960-1969 14d9dac6cfded9ec 2b02e5647b6c4812 +1970-1979 76e005806b4fa800 ef5d4bb0410598fb +1980-1989 5220783633a5d0ec bee809ea08e0ea84 +1990-1999 0bcb7d7eeeecf9b8 14b5ed313d1997e6 +2000-2009 a5d36626c2608de1 a0c10d215cdbf998 +2010-2019 5217e8f096f5293f 4bc88718f458acea +2020-2029 746405110f5924de 51e99f50f7f2935b +2030-2039 4564f6585314a89f c9050b25e88ee398 +2040-2049 c4654cf457da114a 2993ddbd511a2c57 +2050-2059 4e99596d9caca371 b734da8ec65bd68f +2060-2069 a356bb2819928d4b c9853349d7d5e0c3 +2070-2079 6b718b25345dc4c1 a6135be5b0721585 +2080-2089 1306403086a1003e da9dfcfd1f9b5d3f +2090-2099 920a956f499d7931 d6e1ad7d77b894e1 +2100-2109 7cbd6a8caca5d2b1 65c5bf86c7db5e97 +2110-2119 a76ad73f6985ea21 16cfcfbd6d21561f +2120-2129 930a0e045ba241f2 1c4284edb30ee7dd +2130-2139 c95a37514d11b0e9 f37192a75ebd2b74 +2140-2149 e3df4c9a5ed13b52 dd7f041295d7bdfd +2150-2159 f3d79557e6219693 651ab89ccbafa9fc +2160-2169 f0387e865afd296c 3ae1f5ef0d5543cb +2170-2179 03a497420af5be0e 9d98b84513667591 +2180-2189 f57134fdc51fb79b e56ce8919fdfa8de +2190-2199 9506bfc979774a54 b836c6f4555ccef4 +2200-2209 a55bed19f2074201 61e9bd3e5fefc8fa +2210-2219 0f004e2d30fc0bad 16313bfd09957cbc +2220-2229 4efc25a9b741b935 27e8c16dd2a02e76 +2230-2239 3bdc38d251843d39 e9c7cd10f8192680 +2240-2249 ac810ff2afdb9426 7c235704a810469d +2250-2259 adb297fc08264282 15d146baed8001fd +2260-2269 381e633011ec273f 00750f3a785a6ad0 +2270-2279 a3210ddac1feeeac 319a3b3a8a0c9fc9 +2280-2289 bedf74a4acd76f1a 9ca3790078ab542a +2290-2299 df6d733b70135fda cfbf2c8f34741c1b +2300-2309 7fa1a9a306858a61 07293b810e426c6d +2310-2319 a172f69e2fb2acba 04ed9418720d5881 +2320-2329 24af37b7bd857467 adaf9ad542a6bfc4 +2330-2339 d2754346dab7bcc1 e67612feb36d9af6 2340-2349 01e7d9b744d7a406 d271aca7ca01c9ca -2350-2359 1c4175755ec581f7 707d9b081fc3642c -2360-2369 f28a4c6b7e426e2e 65147abfe70a3b4f -2370-2379 aeff4bcd3ec7b3ba 316d41d7a1f950b5 -2380-2389 b7faee645e80aedc 1b98593806e9aa48 -2390-2399 4d68986030531e3d 08ed054c2d5daf7a -2400-2409 d2a18e7de094295e 7ad54d4458ec453e -2410-2419 d34953644e6c9491 f2077d64060ee04c -2420-2429 e5794ee908000d3e 95672efece6fddf6 -2430-2439 72f2209b6698f2df 1cc517032ad6ad65 -2440-2449 66d206b53e6a8e86 3ea1922bc4fb2c7a -2450-2459 68603693fe6e4123 583b622b30c7eab8 -2460-2469 71cadb76fd31ecc9 935ce00c9196ac79 -2470-2479 f6d4a18195f018af a478003dff44187c -2480-2489 4653730efec943c2 6ecf48acb0f1d9e0 -2490-2499 56cd88cc2dd616e2 7065dd3fb30fbac4 -2500-2509 4b9f55897889d675 0aeb50acb8891fbf -2510-2519 919c05561a6ac117 a7c55449c13c9e25 -2520-2529 b30fc128f5e4b189 9a27efe27ab1a70d -2530-2539 daafda46789d1a8b f1d96d4727af1318 -2540-2549 b0fd9fcd0624ac5b e0fcf1c0c25ec8cb -2550-2559 e503897dea76625c 8afdfe4aed90a227 -2560-2569 fdf01588c33876d2 3a86ddaa9230cfaf -2570-2579 af23c38cb3260599 54050220a4264402 -2580-2589 54b3adb97eb4d11a 2509d06cb90a6300 -2590-2599 ef628c83d6ac25a9 2b1f393527221cd6 -2600-2609 53f12f464ba069a4 725a96d9e1b52fb0 -2610-2619 e51fb9061c5dcb4e c4c71ff872706798 -2620-2629 e0ace3d93a8d1618 3e72563429ccbdb5 -2630-2639 982d7847575b2f50 f56ed387956d9d44 -2640-2649 92e8feec4199118d ca257b288e606afb -2650-2659 a87c9a8e0d4cd62c fcf15e8bca9916af -2660-2669 6802d3cfbfffab5c bbb87262c5fbe4fe -2670-2679 66e754547c391671 a81edd6bc650165f -2680-2689 b84f0f2337396056 2ae17106adcc2056 -2690-2699 8157c1ad2a2e7b17 55497ddfed469d7a -2700-2709 91d01a72a7b90f57 26eb621f53d85171 -2710-2719 939fc3ae7c8a0bfb 66bf3558a873ad5f -2720-2729 0c82a3e6ed73604d 7a8f13883735f29d -2730-2739 ad29efd221d4f8a6 64753424f4ece4d8 -2740-2749 a1792ae74b7732ae 75315d18eee53904 -2750-2759 a9f8e63d82286d06 b6a6619c575fa0a4 -2760-2769 a7d668c811db4121 01e4bb03816f0921 -2770-2779 e040c134ae3bcabf 68fc1577944ee63d -2780-2789 e520e815cbf299d1 91165ab316fea2a2 -2790-2799 4bebd69658a0bce3 8f52328f7ba0b404 -2800-2809 6e8376973ed7a43c 0baa2ba725ae5fb5 -2810-2819 813cf9a896100b39 74a1c242620fd718 -2820-2829 ed3c872fa0e898c9 d97bd009585a309c -2830-2839 923ff6222988cc39 f0a3d66522bc1162 -2840-2849 b6bf0696a65588b1 cca09888d74b41d8 -2850-2859 8aa9ed50ad146877 dc9991d571783b3a -2860-2869 fbb231a3fb9026de 738865abd1c49509 -2870-2879 5cc95f2ba8afd750 a164a1a1d2b3a04a -2880-2889 bf9ad553198c96bd 361b16bb71c5df07 -2890-2899 16542cf823b095a2 6ef4d4592f1319bc -2900-2909 fbd899d14ea3d3cb bf7cf3b1ebdbed38 -2910-2919 a03a8cf366d12064 5cc5e81a831cbfca -2920-2929 28b3499e9da0efaf 1f3e23270e335d4d -2930-2939 8ac1bace339bd646 aa9225ce352f8e70 -2940-2949 308c304effe83cba bd60662cd7e27889 -2950-2959 c119f3c9355368e4 d2f7d9d4a68e41d9 -2960-2969 697f00e4e581674c fc91c0b2165c20b7 -2970-2979 259c4217c54b0c2d 42f210a37d6ab649 -2980-2989 8673b3fb5bec41f6 a6db3d42493bfa57 -2990-2999 819f4dd7454c5dc8 3e295355e154fa9f +2350-2359 0c1dcaa909c6de64 38e3a4a0bfde9183 +2360-2369 083ad85a3ba7f30a 115dade46b6a4397 +2370-2379 ddd27482eea0d608 316d41d7a1f950b5 +2380-2389 31af3af13b49c937 aa0cbe163d182b6a +2390-2399 d9e5667ebffd087a 06d2ef4797f111fb +2400-2409 b233daf94192c4fe 796c479f528ce683 +2410-2419 7e395c28150a675c f2077d64060ee04c +2420-2429 52e7b6dfc3ca53cb aac139a241fddcbd +2430-2439 0abca2e9d83a4a02 1cc517032ad6ad65 +2440-2449 a2d24343b9d828d7 190a33a78899bcb0 +2450-2459 ddc35aadf174eba6 8d8bf1bb89b6e923 +2460-2469 aa37d8d30bcb20e5 5251fb74a016838c +2470-2479 5f8efe6d8d9c5c60 9853c44852aeb8a7 +2480-2489 09f70e4928a9afeb 209f36cd9c812fa2 +2490-2499 7af4942fcfdbfe99 fb4f1d5ca1450768 +2500-2509 ac0ef453490f6d82 bfa0e9f0783bb308 +2510-2519 20d3ea9d9e90f378 2eef24e5437246f1 +2520-2529 6b814dfc465b764f f364091cedd7b5e4 +2530-2539 b15a5689ad74e6e0 ea9e29efbcfb8382 +2540-2549 caadbd12eec43ca4 f1c609c322b135f5 +2550-2559 01cf7bb18b8c1502 bd1cd7ae7852c08b +2560-2569 437fa04d3afd845a 55c2b7b2a1a32e77 +2570-2579 7dd5a61c06e6a8bf 16df91dc3fec3dfd +2580-2589 141a3b559eae9aab 2ed3cd096f010c7f +2590-2599 8aba208bc8342191 2b1f393527221cd6 +2600-2609 db776cc92efb188f 65663ff32d929e6b +2610-2619 fc04ef1773e29799 69b63cf206ca037d +2620-2629 f9f10c5a89400822 07c1d5c5d4dc2c62 +2630-2639 7d901d2954e10739 3232fe7eac53f8a6 +2640-2649 b8dca4ecd8505f00 f3f3380748b650ca +2650-2659 f61216df416cceda 80fc43c7620e28b2 +2660-2669 cfa872e0c04708ad 6a394910f724a6a3 +2670-2679 132b774601015dad a81edd6bc650165f +2680-2689 682b8c3afc47fdcc cf4a48af6e82b02e +2690-2699 22c27ee76518b822 46a726fd30a6d5fc +2700-2709 ca5ba6978f7ab604 4be3e2be0d015065 +2710-2719 c7700589bae474d6 ad1137e5eb004086 +2720-2729 dafd05c6e72f7073 dfc774569785c0da +2730-2739 4b508fcaa258cbee aedd0757aea1d289 +2740-2749 4f383d1b2a61bb38 e6828fecaee24fcd +2750-2759 f99d22ae0e081489 38feefe5cc2aa1ef +2760-2769 b1818b9a32888b7e a330d693ac23dae9 +2770-2779 62b3b01a8681f195 68fc1577944ee63d +2780-2789 c221c20747ca9342 500a04af8d628296 +2790-2799 8600a10cd408c20d 914f2a5ac3c3f0d5 +2800-2809 712b4b779ecd1f4f 2442d48815a64277 +2810-2819 274cb9d636f25615 6c1d492fad1e3059 +2820-2829 baeffde3c01aa3b5 f740dd30b3f5c983 +2830-2839 bceee27cbce03516 61d72b3b50da2b2b +2840-2849 ef0df3213810e446 cca09888d74b41d8 +2850-2859 cb65b7e4ba774070 e56870669c082181 +2860-2869 1fb9377b85a77ce5 452c9e7524cbef1e +2870-2879 8b6d4c6d6b13e6a6 3d6e839465822342 +2880-2889 fbe3d4dd17aea81d c9cd71729f67a96d +2890-2899 603c01ad9a8f0015 34887f388e706c41 +2900-2909 e3d933bfcfde3c62 b2fb7bf7334239fc +2910-2919 9729597b6942e2c3 2747c3204e250af5 +2920-2929 e6d7741c8e062c91 6132a3050e18b1f5 +2930-2939 188b4f1fe169b46b 5e0ce91e103c2ee8 +2940-2949 fd6788949a6c546d 478b7a5cf0cea161 +2950-2959 be29e220222d599a b04e2af2eceb6ae4 +2960-2969 7ec0ddffda352f4c fc91c0b2165c20b7 +2970-2979 6d83c45055eac412 8b334126e27755a1 +2980-2989 0aead8e2acb29b1e e4feb72046e969e5 +2990-2999 e5c78a9b5edd87ff 33e47404894faf1b diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected-edits.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected-edits.json new file mode 100644 index 000000000..cdc01f541 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected-edits.json @@ -0,0 +1,10 @@ +[ + { + "path": "composer.lock", + "kind": "redirect_composer_dist", + "action": "rewritten", + "key": "monolog/monolog", + "original": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://api.github.com/repos/Seldaek/monolog/zipball/abc123\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"\"\n },\n \"transport-options\": {\n \"http\": {\n \"header\": [\n \"X-Private-Token: s3cret\"\n ]\n }\n }", + "new": "\"dist\": {\n \"type\": \"zip\",\n \"url\": \"https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip\",\n \"reference\": \"abc123def456\",\n \"shasum\": \"abcdef0123456789abcdef0123456789abcdef01\"\n }" + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected-warnings.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected-warnings.json new file mode 100644 index 000000000..70f47a610 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected-warnings.json @@ -0,0 +1,3 @@ +[ + "redirect_composer_transport_options_removed" +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected/composer.lock new file mode 100644 index 000000000..8937ef5ed --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/expected/composer.lock @@ -0,0 +1,29 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "reference": "abc123def456", + "shasum": "abcdef0123456789abcdef0123456789abcdef01" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/input/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/input/composer.lock new file mode 100644 index 000000000..f887db3b3 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/input/composer.lock @@ -0,0 +1,36 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + }, + "transport-options": { + "http": { + "header": [ + "X-Private-Token: s3cret" + ] + } + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/overrides.json b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/overrides.json new file mode 100644 index 000000000..a83719b26 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/overrides.json @@ -0,0 +1,14 @@ +[ + { + "ecosystem": "composer", + "name": "monolog", + "namespace": "monolog", + "version": "2.0.0", + "token": "11111111-1111-1111-1111-111111111111", + "patchUuid": "44444444-4444-4444-4444-444444444444", + "artifactUrl": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "integrity": { + "sha1": "abcdef0123456789abcdef0123456789abcdef01" + } + } +] diff --git a/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/restored/composer.lock b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/restored/composer.lock new file mode 100644 index 000000000..333e00f42 --- /dev/null +++ b/crates/socket-patch-core/tests/fixtures/redirect/composer/composer-lock/transport-options/restored/composer.lock @@ -0,0 +1,29 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state" + ], + "content-hash": "abc123def456abc123def456abc1", + "packages": [ + { + "name": "monolog/monolog", + "version": "2.0.0", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/abc123", + "reference": "abc123def456", + "shasum": "" + } + }, + { + "name": "psr/log", + "version": "1.1.4", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + } + } + ], + "packages-dev": [] +} diff --git a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json index b96bdd792..24044dd5a 100644 --- a/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json +++ b/crates/socket-patch-core/tests/fixtures/vex-discover-golden/redirect-composer.json @@ -772,6 +772,51 @@ "elsewhere": [], "live_claims": [] }, + "redirect/composer/composer-lock/transport-options/expected": { + "refs": [ + { + "purl": "pkg:composer/monolog/monolog@2.0.0", + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "source_file": "composer.lock", + "artifact_rel": null, + "locked_integrity": "Sha1Hex(\"abcdef0123456789abcdef0123456789abcdef01\")", + "integrity_required": true, + "url": "https://patch.socket.dev/patch/composer/monolog/monolog/2.0.0/11111111-1111-1111-1111-111111111111/44444444-4444-4444-4444-444444444444/monolog-2.0.0.zip", + "lockfile_basis_ok": true + } + ], + "diagnostics": [], + "recognized": [ + { + "uuid": "11111111-1111-1111-1111-111111111111", + "mode": "hosted", + "file": "composer.lock" + }, + { + "uuid": "44444444-4444-4444-4444-444444444444", + "mode": "hosted", + "file": "composer.lock" + } + ], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [ + { + "mode": "hosted", + "uuid": "44444444-4444-4444-4444-444444444444", + "purl": "pkg:composer/monolog/monolog@2.0.0" + } + ] + }, + "redirect/composer/composer-lock/transport-options/input": { + "refs": [], + "diagnostics": [], + "recognized": [], + "unlocked_pins": [], + "elsewhere": [], + "live_claims": [] + }, "redirect/composer/composer-lock/version-mismatch/input": { "refs": [], "diagnostics": [], diff --git a/docs/testing/composer-compatibility.md b/docs/testing/composer-compatibility.md index afaa6f30d..35859a334 100644 --- a/docs/testing/composer-compatibility.md +++ b/docs/testing/composer-compatibility.md @@ -17,7 +17,7 @@ contract. | Mode | Lock entry after the rewrite | | --- | --- | | Vendored (`vendor`, `scan`/`get --mode vendored`) | `dist` → `{"type": "path", "url": ".socket/vendor/composer///@", "reference": ""}` in the original slot, `"transport-options": {"symlink": false}` after it, `source` removed. The patched copy is committed under `.socket/vendor/composer/`. | -| Hosted (`scan --mode hosted`) | `dist.type` → `zip`, `dist.url` → the hosted archive, `dist.shasum` → its sha1 (inserted when the lock had none). The entry's top-level `source` is removed wherever it sits in the entry, and `dist.mirrors` is removed (`redirect_composer_dist_mirrors_removed`). A `source` that is not an object is left and warned about (`redirect_composer_source_kept`). | +| Hosted (`scan --mode hosted`) | `dist.type` → `zip`, `dist.url` → the hosted archive, `dist.shasum` → its sha1 (inserted when the lock had none). The entry's top-level `source` is removed wherever it sits in the entry, `dist.mirrors` is removed (`redirect_composer_dist_mirrors_removed`), and the entry's `transport-options` are removed (`redirect_composer_transport_options_removed`): Composer copies a repository's `options` (auth headers, client certificates, proxy) into each entry it resolves and applies them to the dist download, so kept on a redirected entry they would go to the hosted host (#399). The ledger's fragment revert restores all three. A `source` that is not an object is left and warned about (`redirect_composer_source_kept`). | The lock's `version` spelling is never rewritten (`v6.4.1` stays `v6.4.1`). Patch coordinates are matched by Composer release identity, so a lock