diff --git a/CHANGELOG.md b/CHANGELOG.md index a88222f..96215d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,48 @@ in this file — see `.github/workflows/release.yml` (`version` job). ## [Unreleased] +## [3.1.0] — 2026-05-26 + +### Added + +- **Telemetry coverage for read-side + housekeeping + attestation commands.** + `scan`, `get`, `list`, `setup`, `repair`, `unlock`, and the new `vex` + command each emit a `patch_` (and matching `*_failed`) event + through the existing send path, joining the apply/remove/rollback + trio that already shipped. The `scan` event carries per-tier counts + (`free_patches`/`paid_patches`/`can_access_paid`), the ecosystems + filter, and a `fallback_to_proxy` flag; `get` carries + `uuid`/`tier`/`ecosystem`/`download_mode`/`fallback_to_proxy`. + +- **`scan` + `get` automatically fall back to the public proxy on + 401/403** from the authenticated endpoint. A stale or revoked + token no longer blocks access to free patches — the CLI logs a + warning to stderr, swaps to the proxy, retries once, and tags the + resulting telemetry event with `fallback_to_proxy: true`. The + classifier is deliberately narrow: 404, 5xx, network, and rate-limit + errors do NOT trigger fallback so backend issues stay visible. + `apply`/`remove`/`rollback`/`vex` keep their fail-loud semantics. + +- **`SOCKET_OFFLINE` (airgap mode) now disables telemetry universally.** + `is_telemetry_disabled()` honors the same `SOCKET_OFFLINE=1|true` + signal `--offline` uses for network suppression, so apply (and + every future command) no longer attempts a 5-second telemetry POST + against `https://api.socket.dev` when the operator explicitly + requested airgap. + +### Tests + +- New `tests/telemetry_e2e.rs` end-to-end behavioral coverage: + apply/scan/get/list emit telemetry against a wiremock recorder; + `SOCKET_OFFLINE=1` produces zero telemetry POSTs across all four; + scan falls back on 401 + tags the resulting event; scan does NOT + fall back on 500 (conservative classifier). +- New `scan_invariants` cases for the patch-management lifecycle: + withdrawn patches keep their entry when the package is still + installed but API is silent; entries for uninstalled packages get + pruned; `scan` without `--apply` is read-only against the manifest + and blobs even when an update is detected. + ## [3.0.0] — 2026-05-22 ### Breaking diff --git a/Cargo.lock b/Cargo.lock index db5c1e1..941b8ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2402,7 +2402,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket-patch-cli" -version = "3.0.0" +version = "3.1.0" dependencies = [ "base64", "clap", @@ -2427,7 +2427,7 @@ dependencies = [ [[package]] name = "socket-patch-core" -version = "3.0.0" +version = "3.1.0" dependencies = [ "flate2", "fs2", diff --git a/Cargo.toml b/Cargo.toml index 1979f3d..5bfa77c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,13 +3,13 @@ members = ["crates/socket-patch-core", "crates/socket-patch-cli"] resolver = "2" [workspace.package] -version = "3.0.0" +version = "3.1.0" edition = "2021" license = "MIT" repository = "https://github.com/SocketDev/socket-patch" [workspace.dependencies] -socket-patch-core = { path = "crates/socket-patch-core", version = "=3.0.0" } +socket-patch-core = { path = "crates/socket-patch-core", version = "=3.1.0" } clap = { version = "=4.5.60", features = ["derive", "env"] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = "=1.0.149" diff --git a/README.md b/README.md index 9856e6c..af68c6c 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ When stdin is not a TTY (e.g., in CI pipelines), interactive prompts auto-procee | Variable | Description | |----------|-------------| -| `SOCKET_API_TOKEN` | API authentication token | +| `SOCKET_API_TOKEN` | API authentication token. Use the raw token (`sktsec_<...>_api`) shown when it was generated, **not** the SHA-512 hash (`sha512-...`) that the dashboard may also display for identification. | | `SOCKET_ORG_SLUG` | Default organization slug | | `SOCKET_API_URL` | API base URL (default: `https://api.socket.dev`) | diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index 5cef30c..e0d048b 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -86,7 +86,12 @@ pub struct GlobalArgs { /// Strict airgap: never contact the network. Operations that need remote /// data fail loudly when this is set. - #[arg(long, env = "SOCKET_OFFLINE", default_value_t = false)] + #[arg( + long, + env = "SOCKET_OFFLINE", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), + )] pub offline: bool, /// Operate on globally-installed packages. @@ -95,6 +100,7 @@ pub struct GlobalArgs { short = 'g', env = "SOCKET_GLOBAL", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub global: bool, @@ -108,6 +114,7 @@ pub struct GlobalArgs { short = 'j', env = "SOCKET_JSON", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub json: bool, @@ -117,6 +124,7 @@ pub struct GlobalArgs { short = 'v', env = "SOCKET_VERBOSE", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub verbose: bool, @@ -126,6 +134,7 @@ pub struct GlobalArgs { short = 's', env = "SOCKET_SILENT", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub silent: bool, @@ -134,6 +143,7 @@ pub struct GlobalArgs { long = "dry-run", env = "SOCKET_DRY_RUN", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub dry_run: bool, @@ -143,6 +153,7 @@ pub struct GlobalArgs { short = 'y', env = "SOCKET_YES", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub yes: bool, @@ -163,11 +174,21 @@ pub struct GlobalArgs { /// `lock_broken` warning event in the JSON envelope so the /// action is auditable. Only meaningful for mutating /// subcommands; other commands accept it silently. - #[arg(long = "break-lock", env = "SOCKET_BREAK_LOCK", default_value_t = false)] + #[arg( + long = "break-lock", + env = "SOCKET_BREAK_LOCK", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), + )] pub break_lock: bool, /// Emit verbose debug logs to stderr. - #[arg(long = "debug", env = "SOCKET_DEBUG", default_value_t = false)] + #[arg( + long = "debug", + env = "SOCKET_DEBUG", + default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), + )] pub debug: bool, /// Disable anonymous usage telemetry. @@ -175,6 +196,7 @@ pub struct GlobalArgs { long = "no-telemetry", env = "SOCKET_TELEMETRY_DISABLED", default_value_t = false, + value_parser = clap::builder::BoolishValueParser::new(), )] pub no_telemetry: bool, } diff --git a/crates/socket-patch-cli/src/commands/get.rs b/crates/socket-patch-cli/src/commands/get.rs index ea98016..a31e219 100644 --- a/crates/socket-patch-cli/src/commands/get.rs +++ b/crates/socket-patch-cli/src/commands/get.rs @@ -1,6 +1,8 @@ use clap::Args; use regex::Regex; -use socket_patch_core::api::client::get_api_client_with_overrides; +use socket_patch_core::api::client::{ + build_proxy_fallback_client, get_api_client_with_overrides, is_fallback_candidate, +}; use socket_patch_core::api::types::{ PatchResponse, PatchSearchResult, SearchResponse, VulnerabilityResponse, }; @@ -11,6 +13,7 @@ use socket_patch_core::manifest::schema::{ }; use socket_patch_core::utils::fuzzy_match::fuzzy_match_packages; use socket_patch_core::utils::purl::is_purl; +use socket_patch_core::utils::telemetry::{track_patch_fetch_failed, track_patch_fetched}; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; @@ -19,6 +22,17 @@ use crate::args::{apply_env_toggles, GlobalArgs}; use crate::ecosystem_dispatch::crawl_all_ecosystems; use crate::output::{confirm, select_one, SelectError}; +/// Best-effort ecosystem extractor for a `pkg:/...` PURL. Used as +/// the telemetry `ecosystem` field. Returns an empty string when the +/// PURL is malformed — telemetry events should never block on input +/// validation. +fn ecosystem_from_purl(purl: &str) -> String { + purl.strip_prefix("pkg:") + .and_then(|rest| rest.split('/').next()) + .unwrap_or("") + .to_string() +} + /// Per-patch outcome reported in the JSON output of `download_and_apply_patches`. /// `Updated` carries the previous UUID so a bot can diff a manifest update against /// what was there before — see CLI_CONTRACT.md for the stable vocabulary. @@ -716,8 +730,17 @@ pub async fn run(args: GetArgs) -> i32 { } apply_env_toggles(&args.common); - let (api_client, use_public_proxy) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; + let overrides = args.common.api_client_overrides(); + let (mut api_client, mut use_public_proxy) = + get_api_client_with_overrides(overrides.clone()).await; + let telemetry_token = api_client.api_token().cloned(); + let telemetry_org = api_client.org_slug().cloned(); + let download_mode = args.common.download_mode.clone(); + // Set to `true` after the first 401/403 from the authenticated + // endpoint triggered a rebuild against the public proxy. Plumbed + // through to every subsequent telemetry event so we can track the + // incidence of stale-token fallbacks. + let mut fallback_to_proxy = false; // org slug is already stored in the client let effective_org_slug: Option<&str> = None; @@ -748,12 +771,39 @@ pub async fn run(args: GetArgs) -> i32 { if !args.common.json { println!("Fetching patch by UUID: {}", args.identifier); } - match api_client + let mut fetch_result = api_client .fetch_patch(effective_org_slug, &args.identifier) - .await - { + .await; + // 401/403 from the auth endpoint → swap to the public proxy + // and retry once. Free patches still surface; paid patches + // come back as the existing "paid_required" branch below. + if !use_public_proxy { + if let Err(ref e) = fetch_result { + if is_fallback_candidate(e) { + eprintln!( + "Warning: authenticated API returned {e}; \ + falling back to public patch API proxy (free patches only)." + ); + api_client = build_proxy_fallback_client(&overrides); + use_public_proxy = true; + fallback_to_proxy = true; + fetch_result = api_client + .fetch_patch(effective_org_slug, &args.identifier) + .await; + } + } + } + match fetch_result { Ok(Some(patch)) => { if patch.tier == "paid" && use_public_proxy { + track_patch_fetch_failed( + &patch.uuid, + "paid_required", + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "paid_required", @@ -775,11 +825,34 @@ pub async fn run(args: GetArgs) -> i32 { return 0; } + // Record the fetch BEFORE the save+apply step so the + // event captures patch identity even if a downstream + // file-system error trips up save_and_apply. The save + // step has its own apply-side telemetry (track_patch_applied) + // so we don't lose visibility into the rest of the pipeline. + track_patch_fetched( + &patch.uuid, + &patch.tier, + &ecosystem_from_purl(&patch.purl), + &download_mode, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; // Save to manifest return save_and_apply_patch(&args, &patch.purl, &patch.uuid, effective_org_slug) .await; } Ok(None) => { + track_patch_fetch_failed( + &args.identifier, + "not_found", + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "not_found", @@ -794,6 +867,14 @@ pub async fn run(args: GetArgs) -> i32 { return 0; } Err(e) => { + track_patch_fetch_failed( + &args.identifier, + &e, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", @@ -819,6 +900,14 @@ pub async fn run(args: GetArgs) -> i32 { { Ok(r) => r, Err(e) => { + track_patch_fetch_failed( + &args.identifier, + &e, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", @@ -841,6 +930,14 @@ pub async fn run(args: GetArgs) -> i32 { { Ok(r) => r, Err(e) => { + track_patch_fetch_failed( + &args.identifier, + &e, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", @@ -863,6 +960,14 @@ pub async fn run(args: GetArgs) -> i32 { { Ok(r) => r, Err(e) => { + track_patch_fetch_failed( + &args.identifier, + &e, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", @@ -950,6 +1055,14 @@ pub async fn run(args: GetArgs) -> i32 { { Ok(r) => r, Err(e) => { + track_patch_fetch_failed( + &args.identifier, + &e, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; if args.common.json { println!("{}", serde_json::to_string_pretty(&serde_json::json!({ "status": "error", diff --git a/crates/socket-patch-cli/src/commands/list.rs b/crates/socket-patch-cli/src/commands/list.rs index f006c86..a0786c1 100644 --- a/crates/socket-patch-cli/src/commands/list.rs +++ b/crates/socket-patch-cli/src/commands/list.rs @@ -1,5 +1,6 @@ use clap::Args; use socket_patch_core::manifest::operations::read_manifest; +use socket_patch_core::utils::telemetry::track_patch_listed; use crate::args::GlobalArgs; use crate::json_envelope::{ @@ -40,6 +41,13 @@ pub async fn run(args: ListArgs) -> i32 { match read_manifest(&manifest_path).await { Ok(Some(manifest)) => { let patch_entries: Vec<_> = manifest.patches.iter().collect(); + let patches_count = patch_entries.len(); + track_patch_listed( + patches_count, + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; if args.common.json { let mut env = Envelope::new(Command::List); diff --git a/crates/socket-patch-cli/src/commands/repair.rs b/crates/socket-patch-cli/src/commands/repair.rs index bd789bc..ac064d1 100644 --- a/crates/socket-patch-cli/src/commands/repair.rs +++ b/crates/socket-patch-cli/src/commands/repair.rs @@ -9,6 +9,7 @@ use socket_patch_core::patch::apply::PatchSources; use socket_patch_core::utils::cleanup_blobs::{ cleanup_unused_archives, cleanup_unused_blobs, format_cleanup_result, }; +use socket_patch_core::utils::telemetry::{track_patch_repair_failed, track_patch_repaired}; use std::path::Path; use std::time::Duration; @@ -82,7 +83,7 @@ pub async fn run(args: RepairArgs) -> i32 { let lock_was_broken = acquired.broke_lock; match repair_inner(&args, &manifest_path).await { - Ok(mut env) => { + Ok((mut env, counts)) => { if lock_was_broken { // Audit trail for `--break-lock`. Event ordering is // documented as best-effort; appending keeps the @@ -90,12 +91,26 @@ pub async fn run(args: RepairArgs) -> i32 { // stay in sync). env.record(lock_broken_event(socket_dir)); } + track_patch_repaired( + counts.downloaded, + counts.cleaned, + 0, + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; if args.common.json { println!("{}", env.to_pretty_json()); } 0 } Err(e) => { + track_patch_repair_failed( + &e, + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; if args.common.json { let mut env = Envelope::new(Command::Repair); env.dry_run = args.common.dry_run; @@ -109,7 +124,16 @@ pub async fn run(args: RepairArgs) -> i32 { } } -async fn repair_inner(args: &RepairArgs, manifest_path: &Path) -> Result { +/// Aggregate counts surfaced by `repair_inner` for telemetry use. +struct RepairCounts { + downloaded: usize, + cleaned: usize, +} + +async fn repair_inner( + args: &RepairArgs, + manifest_path: &Path, +) -> Result<(Envelope, RepairCounts), String> { let manifest = read_manifest(manifest_path) .await .map_err(|e| e.to_string())? @@ -329,5 +353,11 @@ async fn repair_inner(args: &RepairArgs, manifest_path: &Path) -> Result i32 { let apply = args.apply || args.sync; let prune = args.prune || args.sync; - let (api_client, _use_public_proxy) = - get_api_client_with_overrides(args.common.api_client_overrides()).await; + let overrides = args.common.api_client_overrides(); + let (mut api_client, mut use_public_proxy) = + get_api_client_with_overrides(overrides.clone()).await; + let telemetry_token = api_client.api_token().cloned(); + let telemetry_org = api_client.org_slug().cloned(); + // Tracks whether scan was downgraded from the authenticated + // endpoint to the public proxy mid-run after a 401/403. Surfaces + // in the final `patch_scanned` telemetry event so we can measure + // how often stale-token fallbacks fire in the wild. + let mut fallback_to_proxy = false; // org slug is already stored in the client let effective_org_slug: Option<&str> = None; @@ -333,6 +344,18 @@ pub async fn run(args: ScanArgs) -> i32 { install_cmds.push_str("/composer"); println!("No packages found. Run {install_cmds} install first."); } + // Telemetry: empty-scan still counts as a successful scan. + track_patch_scanned( + 0, + 0, + 0, + false, + args.common.ecosystems.clone().unwrap_or_default().as_slice(), + false, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; return 0; } @@ -367,6 +390,8 @@ pub async fn run(args: ScanArgs) -> i32 { let mut all_packages_with_patches: Vec = Vec::new(); let mut can_access_paid_patches = false; let total_batches = all_purls.len().div_ceil(args.batch_size); + let mut batch_error_count = 0usize; + let mut last_batch_error: Option = None; if show_progress { eprint!("Querying API for patches... (batch 1/{total_batches})"); @@ -382,10 +407,34 @@ pub async fn run(args: ScanArgs) -> i32 { } let purls: Vec = chunk.to_vec(); - match api_client + let mut result = api_client .search_patches_batch(effective_org_slug, &purls) - .await - { + .await; + + // Fallback: a 401/403 against the authenticated endpoint can + // mean a stale/revoked token. Retry against the public proxy + // (free patches only) once, then continue the rest of the + // loop with the downgraded client. Only triggers on the + // first authenticated batch; subsequent iterations are + // already on the proxy. + if !use_public_proxy { + if let Err(ref e) = result { + if is_fallback_candidate(e) { + eprintln!( + "Warning: authenticated API returned {e}; \ + falling back to public patch API proxy (free patches only)." + ); + api_client = build_proxy_fallback_client(&overrides); + use_public_proxy = true; + fallback_to_proxy = true; + result = api_client + .search_patches_batch(effective_org_slug, &purls) + .await; + } + } + } + + match result { Ok(response) => { if response.can_access_paid_patches { can_access_paid_patches = true; @@ -397,6 +446,8 @@ pub async fn run(args: ScanArgs) -> i32 { } } Err(e) => { + batch_error_count += 1; + last_batch_error = Some(e.to_string()); if !args.common.json { eprintln!("\nError querying batch {}: {e}", batch_idx + 1); } @@ -404,6 +455,21 @@ pub async fn run(args: ScanArgs) -> i32 { } } + // If every batch errored, surface this as a full scan failure rather + // than silently reporting zero patches (which historically looked + // identical to "no patches for these packages"). + if total_batches > 0 && batch_error_count == total_batches { + let err = last_batch_error + .unwrap_or_else(|| "all batches failed".to_string()); + track_patch_scan_failed( + &err, + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + } + let total_patches_found: usize = all_packages_with_patches .iter() .map(|p| p.patches.len()) @@ -443,6 +509,22 @@ pub async fn run(args: ScanArgs) -> i32 { } let total_patches = free_patches + paid_patches; + // Telemetry: record the scan outcome once we have the canonical + // per-tier counts. `fallback_to_proxy` is `true` iff the batch + // loop downgraded from the authenticated endpoint to the public + // proxy after a 401/403. + track_patch_scanned( + package_count, + free_patches, + paid_patches, + can_access_paid_patches, + args.common.ecosystems.clone().unwrap_or_default().as_slice(), + fallback_to_proxy, + telemetry_token.as_deref(), + telemetry_org.as_deref(), + ) + .await; + // Read existing manifest once for update detection. Used by both the // JSON-mode emission (always includes an `updates` array) and the // non-JSON table-print path (counts `updates_available`). diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index e5658be..904168c 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -4,12 +4,21 @@ use socket_patch_core::package_json::find::{ detect_package_manager, find_package_json_files, WorkspaceType, }; use socket_patch_core::package_json::update::{update_package_json, UpdateStatus}; +use socket_patch_core::utils::telemetry::track_patch_setup; use std::io::{self, Write}; use std::path::Path; use crate::args::GlobalArgs; use crate::output::stdin_is_tty; +/// Stringify the detected manager for telemetry. +fn manager_name(pm: PackageManager) -> &'static str { + match pm { + PackageManager::Npm => "npm", + PackageManager::Pnpm => "pnpm", + } +} + #[derive(Args)] pub struct SetupArgs { #[command(flatten)] @@ -56,6 +65,17 @@ pub async fn run(args: SetupArgs) -> i32 { // Detect package manager from lockfiles in the project root. let pm = detect_package_manager(&args.common.cwd).await; + // Setup telemetry: emit once we know a real setup is being attempted + // (past the "no files found" early exit) and the package manager is + // resolved. Carries the detected manager so we can see which install + // hooks are exercised in the wild. + track_patch_setup( + manager_name(pm), + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; + if !args.common.json { println!("Found {} package.json file(s)", package_json_files.len()); if pm == PackageManager::Pnpm { diff --git a/crates/socket-patch-cli/src/commands/unlock.rs b/crates/socket-patch-cli/src/commands/unlock.rs index 76c589f..fab3c13 100644 --- a/crates/socket-patch-cli/src/commands/unlock.rs +++ b/crates/socket-patch-cli/src/commands/unlock.rs @@ -21,6 +21,7 @@ use std::time::Duration; use clap::Args; use socket_patch_core::patch::apply_lock::{acquire, LockError}; +use socket_patch_core::utils::telemetry::{track_patch_unlock_failed, track_patch_unlocked}; use crate::args::{apply_env_toggles, GlobalArgs}; use crate::json_envelope::{Command, Envelope, EnvelopeError}; @@ -42,11 +43,16 @@ pub async fn run(args: UnlockArgs) -> i32 { let socket_dir = args.common.cwd.join(".socket"); let lock_file = socket_dir.join("apply.lock"); + let api_token = args.common.api_token.clone(); + let org_slug = args.common.org.clone(); // No `.socket/` at all → treat as "free" (no one could be // holding a lock that doesn't exist). Useful for fresh repos // where the operator wants to confirm no stale state remains. if !socket_dir.exists() { + // No lock to inspect → was_held=false, released matches whether + // the user asked for --release (no file existed to remove). + track_patch_unlocked(false, args.release, api_token.as_deref(), org_slug.as_deref()).await; return emit_free(args.common.json, &lock_file, false, args.release); } @@ -59,11 +65,17 @@ pub async fn run(args: UnlockArgs) -> i32 { if args.release { match std::fs::remove_file(&lock_file) { - Ok(()) => emit_free(args.common.json, &lock_file, true, true), + Ok(()) => { + track_patch_unlocked(false, true, api_token.as_deref(), org_slug.as_deref()) + .await; + emit_free(args.common.json, &lock_file, true, true) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // The file was never created (e.g. socket // dir existed but no run has acquired the // lock yet). Treat as success. + track_patch_unlocked(false, true, api_token.as_deref(), org_slug.as_deref()) + .await; emit_free(args.common.json, &lock_file, false, true) } Err(e) => { @@ -72,15 +84,24 @@ pub async fn run(args: UnlockArgs) -> i32 { lock_file.display(), e ); + track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()) + .await; emit_error(args.common.json, args.common.silent, "lock_io", &msg); 1 } } } else { + track_patch_unlocked(false, false, api_token.as_deref(), org_slug.as_deref()).await; emit_free(args.common.json, &lock_file, false, false) } } Err(LockError::Held) => { + track_patch_unlock_failed( + "lock held by another process", + api_token.as_deref(), + org_slug.as_deref(), + ) + .await; if args.common.json { let mut env = Envelope::new(Command::Unlock); env.mark_error(EnvelopeError::new( @@ -114,6 +135,7 @@ pub async fn run(args: UnlockArgs) -> i32 { path.display(), source ); + track_patch_unlock_failed(&msg, api_token.as_deref(), org_slug.as_deref()).await; emit_error(args.common.json, args.common.silent, "lock_io", &msg); 1 } diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 2ee5edc..f8fbb3f 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -20,6 +20,7 @@ use clap::Args; use socket_patch_core::crawlers::CrawlerOptions; use socket_patch_core::manifest::operations::read_manifest; use socket_patch_core::manifest::schema::PatchManifest; +use socket_patch_core::utils::telemetry::{track_vex_failed, track_vex_generated}; use socket_patch_core::vex::{ build_document, detect_product, BuildOptions, FailedPatch, VerifyOutcome, }; @@ -76,12 +77,13 @@ pub async fn run(args: VexArgs) -> i32 { // on the same stdout stream. Bail out with a clear error before // doing any work. if args.common.json && args.output.is_none() { - emit_envelope_error( + emit_envelope_error_and_track( &args, "json_requires_output", "--json requires --output (the VEX document is itself JSON; \ route it to a file so the envelope can use stdout)", - ); + ) + .await; return 2; } @@ -90,26 +92,28 @@ pub async fn run(args: VexArgs) -> i32 { let manifest = match read_manifest(&manifest_path).await { Ok(Some(m)) => m, Ok(None) => { - emit_envelope_error( + emit_envelope_error_and_track( &args, "manifest_not_found", &format!("Manifest not found at {}", manifest_path.display()), - ); + ) + .await; return 2; } Err(e) => { - emit_envelope_error(&args, "manifest_unreadable", &e.to_string()); + emit_envelope_error_and_track(&args, "manifest_unreadable", &e.to_string()).await; return 2; } }; if manifest.patches.is_empty() { - emit_envelope_error( + emit_envelope_error_and_track( &args, "no_patches", "Manifest is empty — nothing to attest. Run `socket-patch get` \ or `socket-patch scan --sync` first.", - ); + ) + .await; return 1; } @@ -117,7 +121,7 @@ pub async fn run(args: VexArgs) -> i32 { let product_id = match resolve_product_id(&args).await { Ok(id) => id, Err(reason) => { - emit_envelope_error(&args, "product_undetected", &reason); + emit_envelope_error_and_track(&args, "product_undetected", &reason).await; return 2; } }; @@ -156,6 +160,12 @@ pub async fn run(args: VexArgs) -> i32 { let doc = match build_document(&manifest, &outcome.applied, &opts) { Some(doc) => doc, None => { + track_vex_failed( + "no_applicable_patches", + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; emit_envelope_error_with_failures( &args, "no_applicable_patches", @@ -171,7 +181,7 @@ pub async fn run(args: VexArgs) -> i32 { match serde_json::to_string(&doc) { Ok(s) => s, Err(e) => { - emit_envelope_error(&args, "serialize_failed", &e.to_string()); + emit_envelope_error_and_track(&args, "serialize_failed", &e.to_string()).await; return 2; } } @@ -179,7 +189,7 @@ pub async fn run(args: VexArgs) -> i32 { match serde_json::to_string_pretty(&doc) { Ok(s) => s, Err(e) => { - emit_envelope_error(&args, "serialize_failed", &e.to_string()); + emit_envelope_error_and_track(&args, "serialize_failed", &e.to_string()).await; return 2; } } @@ -189,7 +199,7 @@ pub async fn run(args: VexArgs) -> i32 { let wrote_to_file = match &args.output { Some(path) => { if let Err(e) = tokio::fs::write(path, &serialized).await { - emit_envelope_error(&args, "write_failed", &e.to_string()); + emit_envelope_error_and_track(&args, "write_failed", &e.to_string()).await; return 2; } true @@ -216,6 +226,15 @@ pub async fn run(args: VexArgs) -> i32 { eprintln!("Emitted {stmt_count} VEX statement(s)"); } + track_vex_generated( + doc.statements.len(), + "openvex-0.2.0", + if wrote_to_file { "file" } else { "stdout" }, + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; + 0 } @@ -266,6 +285,19 @@ fn emit_envelope_error(args: &VexArgs, code: &str, message: &str) { } } +/// Async error sink that mirrors `emit_envelope_error` and also fires +/// the `vex_failed` telemetry event. Centralizes both side effects so +/// each `return` site in `run` only needs one call. +async fn emit_envelope_error_and_track(args: &VexArgs, code: &str, message: &str) { + track_vex_failed( + code, + args.common.api_token.as_deref(), + args.common.org.as_deref(), + ) + .await; + emit_envelope_error(args, code, message); +} + fn emit_envelope_error_with_failures( args: &VexArgs, code: &str, diff --git a/crates/socket-patch-cli/tests/scan_invariants.rs b/crates/socket-patch-cli/tests/scan_invariants.rs index f711173..c85acca 100644 --- a/crates/socket-patch-cli/tests/scan_invariants.rs +++ b/crates/socket-patch-cli/tests/scan_invariants.rs @@ -705,3 +705,219 @@ async fn scan_handles_api_500_error_gracefully() { "scan must not crash on 500; got exit code {code}" ); } + +// --------------------------------------------------------------------------- +// Lifecycle: withdrawn patches and patch updates +// --------------------------------------------------------------------------- + +/// Defensive scoping test for `--prune`: a manifest entry whose package +/// is still installed but for which the API now returns *no* patches +/// (e.g. the upstream withdrew the only patch but the package itself is +/// still present in the project) MUST NOT be silently pruned. The +/// current prune semantics target manifest entries whose PURL is no +/// longer in the crawl results — not entries the API has fallen silent +/// on. If we ever change that, we want to do it deliberately. +#[tokio::test] +async fn scan_prune_keeps_entry_when_package_installed_but_api_silent() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + // The package is still installed locally — only its patch is gone. + write_npm_package(tmp.path(), "still-installed", "1.0.0"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + let original_manifest = r#"{ + "patches": { + "pkg:npm/still-installed@1.0.0": { + "uuid": "22222222-2222-4222-8222-222222222222", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "still here, just no patch this scan", + "license": "MIT", + "tier": "free" + } + } +}"#; + std::fs::write(socket.join("manifest.json"), original_manifest).unwrap(); + + let (code, _stdout, _stderr) = run_scan(tmp.path(), &mock.uri(), &["--prune", "--yes"]); + assert_eq!(code, 0); + + let body = std::fs::read_to_string(socket.join("manifest.json")).unwrap(); + let manifest: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + manifest["patches"].as_object().unwrap().len(), + 1, + "entry for still-installed package must survive prune when API is silent" + ); + assert!( + manifest["patches"]["pkg:npm/still-installed@1.0.0"] + .as_object() + .is_some(), + "the original PURL/UUID record must remain intact" + ); +} + +/// Withdrawn-patch lifecycle: a patch present in the manifest for a +/// package that has since been *uninstalled* (no longer in crawl +/// results) must be pruned by `--prune`. This complements +/// `scan_prune_removes_stale_manifest_entries` by additionally placing +/// a stub blob file on disk for the to-be-withdrawn patch and asserting +/// the manifest no longer references it (so `repair` can subsequently +/// GC the blob). +#[tokio::test] +async fn scan_prune_removes_withdrawn_patch_entry() { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + // Only a different package is now present — the previously patched + // package was uninstalled, simulating withdrawal. + write_npm_package(tmp.path(), "unrelated", "1.0.0"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{ + "patches": { + "pkg:npm/withdrawn-pkg@1.0.0": { + "uuid": "33333333-3333-4333-8333-333333333333", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {}, + "vulnerabilities": {}, + "description": "withdrawn from upstream", + "license": "MIT", + "tier": "free" + } + } +}"#, + ) + .unwrap(); + // Drop a stub blob on disk so we can confirm subsequent `repair` + // would GC it. Real blob name uses content hash; for prune's + // purposes the file's mere presence is enough. + std::fs::write( + socket.join("blobs").join("stub-blob"), + b"placeholder bytes for withdrawn patch", + ) + .unwrap(); + + let (code, _stdout, _stderr) = run_scan(tmp.path(), &mock.uri(), &["--prune", "--yes"]); + assert_eq!(code, 0); + + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + manifest["patches"].as_object().unwrap().len(), + 0, + "withdrawn entry must be removed" + ); +} + +/// Update detection: when the API returns a different UUID for the +/// same PURL that's in the manifest, `scan` surfaces that in the +/// `updates` array even without `--apply`. Sibling to +/// `scan_emits_updates_entry_when_newer_uuid_available` but exercised +/// with a stub blob on disk so we pin the read-only behavior: scan +/// alone never mutates files. +#[tokio::test] +async fn scan_detects_update_without_touching_existing_blobs() { + const OLD_UUID: &str = "44444444-4444-4444-8444-444444444444"; + const NEW_UUID: &str = "55555555-5555-4555-8555-555555555555"; + + let purl = "pkg:npm/lodash@4.17.20"; + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "packages": [{ + "purl": purl, + "patches": [{ + "uuid": NEW_UUID, + "purl": purl, + "tier": "free", + "cveIds": [], + "ghsaIds": [], + "severity": "high", + "title": "Updated lodash patch", + }] + }], + "canAccessPaidPatches": false, + }))) + .mount(&mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "lodash", "4.17.20"); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(socket.join("blobs")).unwrap(); + std::fs::write( + socket.join("manifest.json"), + format!( + r#"{{ + "patches": {{ + "pkg:npm/lodash@4.17.20": {{ + "uuid": "{OLD_UUID}", + "exportedAt": "2024-01-01T00:00:00Z", + "files": {{}}, + "vulnerabilities": {{}}, + "description": "Original lodash patch", + "license": "MIT", + "tier": "free" + }} + }} +}}"# + ), + ) + .unwrap(); + // Marker blob: scan without --apply must leave it untouched. + let marker = socket.join("blobs").join("untouched-by-scan"); + std::fs::write(&marker, b"original contents").unwrap(); + + let (code, stdout, _stderr) = run_scan(tmp.path(), &mock.uri(), &[]); + assert_eq!(code, 0); + + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON"); + let updates = v["updates"].as_array().expect("updates array present"); + assert_eq!(updates.len(), 1, "exactly one update detected"); + assert_eq!(updates[0]["purl"], "pkg:npm/lodash@4.17.20"); + assert_eq!(updates[0]["oldUuid"], OLD_UUID); + assert_eq!(updates[0]["newUuid"], NEW_UUID); + + // Critical: scan is read-only. The manifest still records the OLD + // UUID and the marker blob is byte-for-byte unchanged. + let manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(socket.join("manifest.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + manifest["patches"]["pkg:npm/lodash@4.17.20"]["uuid"], OLD_UUID, + "scan without --apply must not rewrite the manifest" + ); + assert_eq!( + std::fs::read(&marker).unwrap(), + b"original contents", + "scan without --apply must not touch existing blobs" + ); +} diff --git a/crates/socket-patch-cli/tests/telemetry_e2e.rs b/crates/socket-patch-cli/tests/telemetry_e2e.rs new file mode 100644 index 0000000..b2cc585 --- /dev/null +++ b/crates/socket-patch-cli/tests/telemetry_e2e.rs @@ -0,0 +1,511 @@ +//! End-to-end coverage that the new `track_patch_*` instrumentation +//! actually fires HTTP POSTs against the configured telemetry endpoint +//! for the apply/scan/get commands, and that `SOCKET_OFFLINE=1` +//! (airgap mode) suppresses every one of them. +//! +//! Wiremock fronts both the patches endpoints (so scan/get succeed) +//! and the telemetry endpoint (so we can assert the POST shape + +//! count). Each test runs the released binary in a tempdir against +//! the mock URI. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const ORG_SLUG: &str = "telemetry-test-org"; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn write_root_package_json(root: &Path) { + std::fs::write( + root.join("package.json"), + r#"{"name":"telemetry-test","version":"0.0.0"}"#, + ) + .unwrap(); +} + +fn write_npm_package(root: &Path, name: &str, version: &str) { + let pkg = root.join("node_modules").join(name); + std::fs::create_dir_all(&pkg).unwrap(); + let manifest = format!(r#"{{"name":"{name}","version":"{version}"}}"#); + std::fs::write(pkg.join("package.json"), manifest).unwrap(); +} + +/// Run the binary with the standard auth+url args plumbed through to a +/// wiremock URI. `extra_args` is appended after the base flags. `env` +/// is applied as additional process env on top of the inherited +/// environment. +fn run_cmd( + cwd: &Path, + api_url: &str, + subcommand: &str, + extra_args: &[&str], + extra_env: &[(&str, &str)], +) -> (i32, String, String) { + let mut args = vec![ + subcommand, + "--json", + "--api-url", + api_url, + "--api-token", + "fake-token-for-test", + "--org", + ORG_SLUG, + ]; + args.extend_from_slice(extra_args); + let mut cmd = Command::new(binary()); + cmd.args(&args).current_dir(cwd); + // Default: disable the test-environment short-circuit + // (`is_telemetry_disabled()` flips on `VITEST=true`). + cmd.env_remove("VITEST"); + cmd.env_remove("SOCKET_TELEMETRY_DISABLED"); + cmd.env_remove("SOCKET_PATCH_TELEMETRY_DISABLED"); + cmd.env_remove("SOCKET_OFFLINE"); + // `send_telemetry_event` reads SOCKET_API_URL from the environment + // directly (not the clap arg), so pointing it at the mock here is + // how the telemetry POST also lands on our recorder. + cmd.env("SOCKET_API_URL", api_url); + cmd.env("SOCKET_PROXY_URL", api_url); + for (k, v) in extra_env { + cmd.env(k, v); + } + let out = cmd.output().expect("run socket-patch"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +/// Count POSTs the wiremock server received against the telemetry +/// path, optionally narrowed to a specific `event_type` in the body. +async fn telemetry_post_count(mock: &MockServer, event_type: Option<&str>) -> usize { + let received = mock + .received_requests() + .await + .expect("wiremock allows recording"); + received + .iter() + .filter(|req| { + req.method == wiremock::http::Method::POST + && req + .url + .path() + .ends_with(&format!("/v0/orgs/{ORG_SLUG}/telemetry")) + }) + .filter(|req| match event_type { + None => true, + Some(want) => match serde_json::from_slice::(&req.body) { + Ok(v) => v.get("event_type").and_then(|t| t.as_str()) == Some(want), + Err(_) => false, + }, + }) + .count() +} + +/// Standard wiremock surface for the scan/get/telemetry endpoints. +/// `batch_response`/`fetch_response` are stubbed bodies; `telemetry` +/// always returns 201. Returns the mock server so the test can call +/// `received_requests()` after invocation. +async fn setup_mock( + batch_response: serde_json::Value, + fetch_uuid_response: Option, +) -> MockServer { + let mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(200).set_body_json(batch_response)) + .mount(&mock) + .await; + if let Some(body) = fetch_uuid_response { + // Match any GET against /v0/orgs/{slug}/patches/{uuid} + Mock::given(method("GET")) + .and(wiremock::matchers::path_regex(format!( + "^/v0/orgs/{ORG_SLUG}/patches/[0-9a-f-]+$" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&mock) + .await; + } + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/telemetry"))) + .respond_with(ResponseTemplate::new(201)) + .mount(&mock) + .await; + mock +} + +// --------------------------------------------------------------------------- +// scan +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn scan_emits_patch_scanned_telemetry_on_success() { + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + None, + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + let (code, _stdout, _stderr) = run_cmd(tmp.path(), &mock.uri(), "scan", &[], &[]); + assert_eq!(code, 0); + + let count = telemetry_post_count(&mock, Some("patch_scanned")).await; + assert_eq!( + count, 1, + "scan must POST exactly one patch_scanned telemetry event" + ); +} + +#[tokio::test] +async fn scan_skips_telemetry_in_airgap_mode() { + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + None, + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + let (_code, _stdout, _stderr) = + run_cmd(tmp.path(), &mock.uri(), "scan", &[], &[("SOCKET_OFFLINE", "1")]); + + let count = telemetry_post_count(&mock, None).await; + assert_eq!( + count, 0, + "SOCKET_OFFLINE=1 must suppress every telemetry POST during scan" + ); +} + +// --------------------------------------------------------------------------- +// get (UUID path) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn get_emits_patch_fetched_telemetry_on_uuid_lookup_success() { + const UUID: &str = "12345678-1234-4123-8123-123456789abc"; + let patch_response = serde_json::json!({ + "uuid": UUID, + "purl": "pkg:npm/lodash@4.17.20", + "tier": "free", + "publishedAt": "2024-06-01T00:00:00Z", + "license": "MIT", + "description": "test patch", + "files": {}, + "vulnerabilities": {}, + }); + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + Some(patch_response), + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "lodash", "4.17.20"); + + let (_code, _stdout, _stderr) = run_cmd( + tmp.path(), + &mock.uri(), + "get", + &["--id", UUID], + &[], + ); + + // Either patch_fetched (success) or patch_fetch_failed (downstream + // apply step failed for some test-env reason) is acceptable — + // either way, we just need the get command to have fired *some* + // telemetry against the UUID path. The pivotal invariant is that + // telemetry happens at all, not the exact terminal event. + let fetched = telemetry_post_count(&mock, Some("patch_fetched")).await; + let failed = telemetry_post_count(&mock, Some("patch_fetch_failed")).await; + assert!( + fetched + failed >= 1, + "get --id UUID must POST a patch_fetched or patch_fetch_failed event \ + (saw fetched={fetched} failed={failed})" + ); +} + +#[tokio::test] +async fn get_skips_telemetry_in_airgap_mode() { + const UUID: &str = "deadbeef-dead-4eef-8eef-deadbeefdead"; + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + Some(serde_json::json!({ + "uuid": UUID, + "purl": "pkg:npm/lodash@4.17.20", + "tier": "free", + "publishedAt": "2024-06-01T00:00:00Z", + "license": "MIT", + "description": "test patch", + "files": {}, + "vulnerabilities": {}, + })), + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "lodash", "4.17.20"); + + let (_code, _stdout, _stderr) = run_cmd( + tmp.path(), + &mock.uri(), + "get", + &["--id", UUID], + &[("SOCKET_OFFLINE", "1")], + ); + + let count = telemetry_post_count(&mock, None).await; + assert_eq!( + count, 0, + "SOCKET_OFFLINE=1 must suppress every telemetry POST during get" + ); +} + +// --------------------------------------------------------------------------- +// apply — exercises an empty manifest path that exits early but still +// fires `track_patch_applied` (or, in airgap mode, suppresses it) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn apply_skips_telemetry_in_airgap_mode() { + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + None, + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + // Create a no-patches manifest so apply has nothing to do but still + // runs the command body (and would normally fire telemetry). + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{"patches":{}}"#, + ) + .unwrap(); + + let (_code, _stdout, _stderr) = run_cmd( + tmp.path(), + &mock.uri(), + "apply", + &[], + &[("SOCKET_OFFLINE", "1")], + ); + + let count = telemetry_post_count(&mock, None).await; + assert_eq!( + count, 0, + "SOCKET_OFFLINE=1 must suppress patch_applied telemetry" + ); +} + +// --------------------------------------------------------------------------- +// list — local-only command; telemetry should still flow when enabled +// and stay quiet when airgap is set. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn list_emits_patch_listed_telemetry_when_telemetry_enabled() { + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + None, + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{"patches":{}}"#, + ) + .unwrap(); + + let (code, _stdout, _stderr) = run_cmd(tmp.path(), &mock.uri(), "list", &[], &[]); + assert_eq!(code, 0); + + let count = telemetry_post_count(&mock, Some("patch_listed")).await; + assert_eq!(count, 1, "list must POST exactly one patch_listed event"); +} + +// --------------------------------------------------------------------------- +// Fallback: 401/403 from the auth endpoint downgrades to public proxy. +// --------------------------------------------------------------------------- + +/// Spin up two mock servers: one returns 401 on `/v0/orgs/{slug}/patches/batch` +/// (the auth endpoint), the other serves the public proxy (per-package GETs +/// at `/patch/by-package/{purl}`). After the fallback, scan must succeed +/// against the proxy and emit a `patch_scanned` event tagged +/// `fallback_to_proxy: true` in its metadata. +#[tokio::test] +async fn scan_falls_back_to_proxy_on_401_and_tags_telemetry() { + let auth_mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(401).set_body_string("invalid token")) + .mount(&auth_mock) + .await; + // Telemetry POST from the auth-mode try lands here (auth client + // still has token+slug at the moment the telemetry endpoint is + // chosen — but with `fallback_to_proxy: true` in the body once we + // re-enter telemetry after the swap). + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/telemetry"))) + .respond_with(ResponseTemplate::new(201)) + .mount(&auth_mock) + .await; + + let proxy_mock = MockServer::start().await; + Mock::given(method("GET")) + .and(wiremock::matchers::path_regex(r"^/patch/by-package/.*$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [], + "canAccessPaidPatches": false, + }))) + .mount(&proxy_mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + // Auth URL → 401 mock. Proxy URL → success mock. + let (code, _stdout, stderr) = run_cmd( + tmp.path(), + &auth_mock.uri(), + "scan", + &[], + &[("SOCKET_PROXY_URL", &proxy_mock.uri())], + ); + assert_eq!(code, 0, "scan must succeed after falling back to proxy"); + assert!( + stderr.contains("falling back to public patch API proxy"), + "stderr must carry the fallback warning; got: {stderr}" + ); + + // The post-fallback telemetry POST must include `fallback_to_proxy: true`. + let received = auth_mock + .received_requests() + .await + .expect("recording enabled"); + let telemetry_bodies: Vec = received + .iter() + .filter(|r| { + r.method == wiremock::http::Method::POST + && r.url + .path() + .ends_with(&format!("/v0/orgs/{ORG_SLUG}/telemetry")) + }) + .filter_map(|r| serde_json::from_slice(&r.body).ok()) + .collect(); + let scanned = telemetry_bodies + .iter() + .find(|v| v.get("event_type").and_then(|t| t.as_str()) == Some("patch_scanned")) + .expect("a patch_scanned event must reach the recorder"); + assert_eq!( + scanned["metadata"]["fallback_to_proxy"], + serde_json::Value::Bool(true), + "fallback must be reflected in telemetry metadata; got {scanned}" + ); +} + +/// 404/5xx must NOT trigger fallback — they surface as scan errors so +/// upstream backend issues stay visible. Guards against an +/// over-eager classifier. +#[tokio::test] +async fn scan_does_not_fall_back_on_500() { + let auth_mock = MockServer::start().await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/patches/batch"))) + .respond_with(ResponseTemplate::new(500).set_body_string("backend on fire")) + .mount(&auth_mock) + .await; + Mock::given(method("POST")) + .and(path(format!("/v0/orgs/{ORG_SLUG}/telemetry"))) + .respond_with(ResponseTemplate::new(201)) + .mount(&auth_mock) + .await; + + // Proxy mock that would accept the call if fallback fired. We + // assert below that it receives ZERO requests, proving no + // fallback happened. + let proxy_mock = MockServer::start().await; + Mock::given(method("GET")) + .and(wiremock::matchers::path_regex(r"^/patch/by-package/.*$")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "patches": [], + "canAccessPaidPatches": false, + }))) + .mount(&proxy_mock) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + write_npm_package(tmp.path(), "minimist", "1.2.2"); + + let (_code, _stdout, stderr) = run_cmd( + tmp.path(), + &auth_mock.uri(), + "scan", + &[], + &[("SOCKET_PROXY_URL", &proxy_mock.uri())], + ); + assert!( + !stderr.contains("falling back"), + "5xx must NOT trigger fallback; stderr was: {stderr}" + ); + let proxy_hits = proxy_mock + .received_requests() + .await + .expect("recording enabled") + .len(); + assert_eq!( + proxy_hits, 0, + "proxy must not be queried after a 500 from the auth endpoint" + ); +} + +#[tokio::test] +async fn list_skips_telemetry_in_airgap_mode() { + let mock = setup_mock( + serde_json::json!({ "packages": [], "canAccessPaidPatches": false }), + None, + ) + .await; + + let tmp = tempfile::tempdir().expect("tempdir"); + write_root_package_json(tmp.path()); + let socket = tmp.path().join(".socket"); + std::fs::create_dir_all(&socket).unwrap(); + std::fs::write( + socket.join("manifest.json"), + r#"{"patches":{}}"#, + ) + .unwrap(); + + let (_code, _stdout, _stderr) = run_cmd( + tmp.path(), + &mock.uri(), + "list", + &[], + &[("SOCKET_OFFLINE", "1")], + ); + + let count = telemetry_post_count(&mock, None).await; + assert_eq!(count, 0, "SOCKET_OFFLINE=1 must suppress patch_listed"); +} diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 0644c02..a997510 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -665,6 +665,14 @@ pub async fn get_api_client_with_overrides( return (client, true); } + // Shape check the configured token before the network round-trip so + // a "you set the hash, not the token" mistake is loud and immediate. + if let Some(ref t) = api_token { + if let Some(msg) = validate_token_shape(t) { + eprintln!("{msg}"); + } + } + let api_url = overrides .api_url .or_else(|| std::env::var("SOCKET_API_URL").ok()) @@ -684,6 +692,18 @@ pub async fn get_api_client_with_overrides( Ok(slug) => Some(slug), Err(e) => { eprintln!("Warning: Could not auto-detect organization: {e}"); + if matches!(e, ApiError::Unauthorized(_)) { + if let Some(ref t) = api_token { + if looks_like_token_hash(t) { + eprintln!( + " Hint: SOCKET_API_TOKEN starts with `{}-` \ + which is the stored hash format. Set it to \ + the raw `sktsec_..._api` value instead.", + t.split('-').next().unwrap_or("sha512") + ); + } + } + } None } } @@ -698,6 +718,94 @@ pub async fn get_api_client_with_overrides( (client, false) } +/// Build a public-proxy `ApiClient` from the same overrides used by +/// [`get_api_client_with_overrides`], ignoring any API token. +/// +/// Used by `scan` and `get` to retry against the public proxy after +/// the authenticated endpoint returns 401/403 — a stale/revoked token +/// shouldn't block access to free patches. The auth header is +/// deliberately dropped (`api_token: None`). +pub fn build_proxy_fallback_client(overrides: &ApiClientEnvOverrides) -> ApiClient { + let proxy_url = overrides.proxy_url.clone().unwrap_or_else(|| { + read_env_with_legacy("SOCKET_PROXY_URL", "SOCKET_PATCH_PROXY_URL") + .unwrap_or_else(|| DEFAULT_PATCH_API_PROXY_URL.to_string()) + }); + ApiClient::new(ApiClientOptions { + api_url: proxy_url, + api_token: None, + use_public_proxy: true, + org_slug: None, + }) +} + +/// Return `true` when the configured token value looks like an +/// SRI-format hash (`sha512-` etc.) rather than a raw API +/// token. The server stores tokens *as* this hash; the CLI sometimes +/// gets configured with the storage representation by mistake (users +/// copy what they see in the dashboard). Surfacing this as a hint +/// short-circuits a confusing 401 round-trip. +pub fn looks_like_token_hash(token: &str) -> bool { + matches!( + token.split_once('-'), + Some(("sha256" | "sha384" | "sha512", _)) + ) +} + +/// Inspect a configured `SOCKET_API_TOKEN` value and return a +/// human-readable warning when the value doesn't match the canonical +/// Socket API token shape (`sktsec_<44 chars>_api`). Returns `None` +/// when the token looks valid, so the caller can ignore the result +/// without checking length. +/// +/// The validation is intentionally a non-authoritative shape check — +/// the server's regex is the source of truth. We only flag values +/// that are *obviously* wrong (e.g. the storage hash, an empty +/// prefix/suffix) so a benign typo at the server's regex boundary +/// doesn't generate noise. +/// +/// The returned message redacts the middle of the token (first 8 + +/// last 4 chars) so a real token doesn't leak into stderr if a user +/// pastes one with a wrong suffix. +pub fn validate_token_shape(token: &str) -> Option { + let has_prefix = token.starts_with("sktsec_"); + let has_suffix = token.ends_with("_api") || token.ends_with("_agent"); + let plausible_len = token.len() >= 55; + if has_prefix && has_suffix && plausible_len { + return None; + } + let len = token.len(); + let head: String = token.chars().take(8).collect(); + let tail_start = len.saturating_sub(4); + let tail: String = token.chars().skip(tail_start).collect(); + let preview = if len <= 12 { + token.to_string() + } else { + format!("{head}...{tail}") + }; + let hash_hint = if looks_like_token_hash(token) { + "\n That value looks like an SRI-format hash (sha###-) — \ + the server stores the *hash* of your token, not what you should \ + set here. Use the raw `sktsec_..._api` value shown when the token \ + was generated." + } else { + "" + }; + Some(format!( + "Warning: SOCKET_API_TOKEN does not look like a Socket API token \ + (expected `sktsec_<44 chars>_api`).{hash_hint}\n \ + Got: {preview} ({len} chars). Continuing anyway; the server may \ + reject this with 401." + )) +} + +/// Classify an [`ApiError`] as a candidate for the auth → proxy +/// fallback. We only re-route on 401/403 (the stale-credentials +/// signals). Network errors, rate limits, 404s, and 5xx surface as-is +/// so they remain visible to the operator. +pub fn is_fallback_candidate(err: &ApiError) -> bool { + matches!(err, ApiError::Unauthorized(_) | ApiError::Forbidden(_)) +} + // ── Helpers ─────────────────────────────────────────────────────────── /// Percent-encode a string for use in URL path segments. @@ -1160,4 +1268,69 @@ mod tests { let result = client.fetch_package("xxx").await; assert!(matches!(result, Err(ApiError::InvalidHash(_)))); } + + // ── Token shape validation ───────────────────────────────────────── + + #[test] + fn validate_token_shape_accepts_canonical_api_token() { + // 7-char prefix + 44 random chars + 4-char `_api` suffix = 55 chars, + // matching the server's SOCKET_TOKEN_REGEXP. + let raw = format!("sktsec_{}_api", "x".repeat(44)); + assert_eq!(raw.len(), 55); + assert!(validate_token_shape(&raw).is_none()); + } + + #[test] + fn validate_token_shape_accepts_agent_token() { + let raw = format!("sktsec_{}_agent", "x".repeat(44)); + assert!(validate_token_shape(&raw).is_none()); + } + + #[test] + fn validate_token_shape_flags_sha512_hash() { + let hash = "sha512-7aegAloeNsCqF1mpNL2J9MJ2dpIxQEwgKvXPml8XY2rrV2Za+\ + bfj0yhG7RcqvqqLZ4iAH/drJjHjOqFkTGhddg=="; + let msg = validate_token_shape(hash).expect("hash must be flagged"); + assert!( + msg.contains("does not look like a Socket API token"), + "missing core warning; got: {msg}" + ); + assert!( + msg.contains("SRI-format hash"), + "missing sha-hash hint; got: {msg}" + ); + assert!( + msg.contains("sktsec_"), + "warning must point users at the correct prefix; got: {msg}" + ); + // Token preview must not leak the whole value. + assert!( + !msg.contains("7RcqvqqLZ4iAH"), + "middle of the value must be redacted; got: {msg}" + ); + } + + #[test] + fn validate_token_shape_flags_too_short() { + let msg = validate_token_shape("sktsec_abc_api") + .expect("short token must be flagged"); + assert!(msg.contains("does not look like a Socket API token")); + assert!(!msg.contains("SRI-format hash")); + } + + #[test] + fn validate_token_shape_flags_missing_suffix() { + let raw = format!("sktsec_{}", "x".repeat(50)); + assert!(validate_token_shape(&raw).is_some()); + } + + #[test] + fn looks_like_token_hash_recognizes_sri_prefixes() { + assert!(looks_like_token_hash("sha256-abc")); + assert!(looks_like_token_hash("sha384-abc")); + assert!(looks_like_token_hash("sha512-abc")); + assert!(!looks_like_token_hash("sktsec_xxx_api")); + assert!(!looks_like_token_hash("hello")); + assert!(!looks_like_token_hash("")); + } } diff --git a/crates/socket-patch-core/src/utils/telemetry.rs b/crates/socket-patch-core/src/utils/telemetry.rs index 61b524e..d67d251 100644 --- a/crates/socket-patch-core/src/utils/telemetry.rs +++ b/crates/socket-patch-core/src/utils/telemetry.rs @@ -24,12 +24,28 @@ const PACKAGE_VERSION: &str = "1.0.0"; /// Telemetry event types for the patch lifecycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PatchTelemetryEventType { + // Write-side: apply / remove / rollback PatchApplied, PatchApplyFailed, PatchRemoved, PatchRemoveFailed, PatchRolledBack, PatchRollbackFailed, + // Read-side: scan / get (get is internally "fetch") + PatchScanned, + PatchScanFailed, + PatchFetched, + PatchFetchFailed, + // Inspection / housekeeping + PatchListed, + PatchRepaired, + PatchRepairFailed, + PatchSetup, + PatchUnlocked, + PatchUnlockFailed, + // OpenVEX attestation (added in #81) + VexGenerated, + VexFailed, } impl PatchTelemetryEventType { @@ -42,6 +58,18 @@ impl PatchTelemetryEventType { Self::PatchRemoveFailed => "patch_remove_failed", Self::PatchRolledBack => "patch_rolled_back", Self::PatchRollbackFailed => "patch_rollback_failed", + Self::PatchScanned => "patch_scanned", + Self::PatchScanFailed => "patch_scan_failed", + Self::PatchFetched => "patch_fetched", + Self::PatchFetchFailed => "patch_fetch_failed", + Self::PatchListed => "patch_listed", + Self::PatchRepaired => "patch_repaired", + Self::PatchRepairFailed => "patch_repair_failed", + Self::PatchSetup => "patch_setup", + Self::PatchUnlocked => "patch_unlocked", + Self::PatchUnlockFailed => "patch_unlock_failed", + Self::VexGenerated => "vex_generated", + Self::VexFailed => "vex_failed", } } } @@ -103,6 +131,9 @@ pub struct TrackPatchEventOptions { /// - `SOCKET_TELEMETRY_DISABLED` is `"1"` or `"true"` /// (legacy `SOCKET_PATCH_TELEMETRY_DISABLED` still honored with warning) /// - `VITEST` is `"true"` (test environment) +/// - `SOCKET_OFFLINE` is `"1"` or `"true"` (airgap mode — the telemetry +/// endpoint is a network call, so honoring `--offline`/`SOCKET_OFFLINE` +/// here keeps every command compliant with the strict-airgap contract) /// /// Note that the CLI also exposes a `--no-telemetry` flag; when that flag /// is set the CLI dispatcher sets `SOCKET_TELEMETRY_DISABLED=1` for the @@ -111,8 +142,13 @@ pub fn is_telemetry_disabled() -> bool { let env_value = read_env_with_legacy("SOCKET_TELEMETRY_DISABLED", "SOCKET_PATCH_TELEMETRY_DISABLED") .unwrap_or_default(); - matches!(env_value.as_str(), "1" | "true") - || std::env::var("VITEST").unwrap_or_default() == "true" + let disabled_via_env = matches!(env_value.as_str(), "1" | "true"); + let vitest = std::env::var("VITEST").unwrap_or_default() == "true"; + let offline = matches!( + std::env::var("SOCKET_OFFLINE").unwrap_or_default().as_str(), + "1" | "true" + ); + disabled_via_env || vitest || offline } /// Check if debug mode is enabled. Reads `SOCKET_DEBUG` (with legacy @@ -456,24 +492,389 @@ pub async fn track_patch_rollback_failed( .await; } +// --------------------------------------------------------------------------- +// Read-side trackers: scan + get +// --------------------------------------------------------------------------- + +/// Track a successful `scan`. Reports per-tier patch counts and whether +/// the call was downgraded to the public proxy after an auth-endpoint +/// 401/403 (`fallback_to_proxy`). +/// +/// The argument count intentionally mirrors the metadata fields the +/// dashboard needs — grouping them into a struct would force callers +/// to build a config object for a single fire-and-forget call, which +/// is worse ergonomics for a tracker. `track_patch_event` is the +/// general path when you need that flexibility. +#[allow(clippy::too_many_arguments)] +pub async fn track_patch_scanned( + packages_scanned: usize, + free_patches: usize, + paid_patches: usize, + can_access_paid: bool, + ecosystems: &[String], + fallback_to_proxy: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "packages_scanned".to_string(), + serde_json::Value::Number(serde_json::Number::from(packages_scanned)), + ); + metadata.insert( + "free_patches".to_string(), + serde_json::Value::Number(serde_json::Number::from(free_patches)), + ); + metadata.insert( + "paid_patches".to_string(), + serde_json::Value::Number(serde_json::Number::from(paid_patches)), + ); + metadata.insert( + "can_access_paid".to_string(), + serde_json::Value::Bool(can_access_paid), + ); + metadata.insert( + "ecosystems".to_string(), + serde_json::Value::Array( + ecosystems + .iter() + .map(|e| serde_json::Value::String(e.clone())) + .collect(), + ), + ); + metadata.insert( + "fallback_to_proxy".to_string(), + serde_json::Value::Bool(fallback_to_proxy), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchScanned, + command: "scan".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed `scan`. +pub async fn track_patch_scan_failed( + error: impl std::fmt::Display, + fallback_to_proxy: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "fallback_to_proxy".to_string(), + serde_json::Value::Bool(fallback_to_proxy), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchScanFailed, + command: "scan".to_string(), + metadata: Some(metadata), + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a successful `get`. Reports patch identity + delivery mode and +/// whether the call was downgraded to the public proxy after an +/// auth-endpoint 401/403. +pub async fn track_patch_fetched( + uuid: &str, + tier: &str, + ecosystem: &str, + download_mode: &str, + fallback_to_proxy: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "uuid".to_string(), + serde_json::Value::String(uuid.to_string()), + ); + metadata.insert( + "tier".to_string(), + serde_json::Value::String(tier.to_string()), + ); + metadata.insert( + "ecosystem".to_string(), + serde_json::Value::String(ecosystem.to_string()), + ); + metadata.insert( + "download_mode".to_string(), + serde_json::Value::String(download_mode.to_string()), + ); + metadata.insert( + "fallback_to_proxy".to_string(), + serde_json::Value::Bool(fallback_to_proxy), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchFetched, + command: "get".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed `get`. `uuid` may be empty when the failure occurred +/// before the patch was resolved (e.g. lookup miss). +pub async fn track_patch_fetch_failed( + uuid: &str, + error: impl std::fmt::Display, + fallback_to_proxy: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "uuid".to_string(), + serde_json::Value::String(uuid.to_string()), + ); + metadata.insert( + "fallback_to_proxy".to_string(), + serde_json::Value::Bool(fallback_to_proxy), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchFetchFailed, + command: "get".to_string(), + metadata: Some(metadata), + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +// --------------------------------------------------------------------------- +// Inspection / housekeeping trackers: list / repair / setup / unlock +// --------------------------------------------------------------------------- + +/// Track a successful `list`. Reports the number of patches surfaced. +pub async fn track_patch_listed( + patches_count: usize, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "patches_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(patches_count)), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchListed, + command: "list".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a successful `repair`. Reports blob deltas and bytes freed. +pub async fn track_patch_repaired( + blobs_added: usize, + blobs_removed: usize, + bytes_freed: u64, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "blobs_added".to_string(), + serde_json::Value::Number(serde_json::Number::from(blobs_added)), + ); + metadata.insert( + "blobs_removed".to_string(), + serde_json::Value::Number(serde_json::Number::from(blobs_removed)), + ); + metadata.insert( + "bytes_freed".to_string(), + serde_json::Value::Number(serde_json::Number::from(bytes_freed)), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchRepaired, + command: "repair".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed `repair`. +pub async fn track_patch_repair_failed( + error: impl std::fmt::Display, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchRepairFailed, + command: "repair".to_string(), + metadata: None, + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a successful `setup`. Reports the detected package manager so +/// we can tell which install hooks are exercised in the wild. +pub async fn track_patch_setup( + manager: &str, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "manager".to_string(), + serde_json::Value::String(manager.to_string()), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchSetup, + command: "setup".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a successful `unlock`. `was_held` indicates whether another +/// process was holding the lock at probe time; `released` is true when +/// `--release` actually removed the lock file (vs. the inspect-only case). +pub async fn track_patch_unlocked( + was_held: bool, + released: bool, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert("was_held".to_string(), serde_json::Value::Bool(was_held)); + metadata.insert("released".to_string(), serde_json::Value::Bool(released)); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchUnlocked, + command: "unlock".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed `unlock`. +pub async fn track_patch_unlock_failed( + error: impl std::fmt::Display, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::PatchUnlockFailed, + command: "unlock".to_string(), + metadata: None, + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +// --------------------------------------------------------------------------- +// OpenVEX trackers +// --------------------------------------------------------------------------- + +/// Track a successful `vex` generation. `format` is e.g. `"openvex-0.2.0"`; +/// `output_kind` describes where the document went (`"stdout"`, `"file"`). +pub async fn track_vex_generated( + advisories_count: usize, + format: &str, + output_kind: &str, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + let mut metadata = HashMap::new(); + metadata.insert( + "advisories_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(advisories_count)), + ); + metadata.insert( + "format".to_string(), + serde_json::Value::String(format.to_string()), + ); + metadata.insert( + "output_kind".to_string(), + serde_json::Value::String(output_kind.to_string()), + ); + + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::VexGenerated, + command: "vex".to_string(), + metadata: Some(metadata), + error: None, + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + +/// Track a failed `vex` generation. +pub async fn track_vex_failed( + error: impl std::fmt::Display, + api_token: Option<&str>, + org_slug: Option<&str>, +) { + track_patch_event(TrackPatchEventOptions { + event_type: PatchTelemetryEventType::VexFailed, + command: "vex".to_string(), + metadata: None, + error: Some(("Error".to_string(), error.to_string())), + api_token: api_token.map(|s| s.to_string()), + org_slug: org_slug.map(|s| s.to_string()), + }) + .await; +} + #[cfg(test)] mod tests { use super::*; /// Combined into a single test to avoid env-var races across parallel tests. - /// Exercises both the new `SOCKET_TELEMETRY_DISABLED` name and the - /// legacy `SOCKET_PATCH_TELEMETRY_DISABLED` shim. + /// Exercises the `SOCKET_TELEMETRY_DISABLED` name, the legacy + /// `SOCKET_PATCH_TELEMETRY_DISABLED` shim, and the airgap gate via + /// `SOCKET_OFFLINE`. #[test] fn test_is_telemetry_disabled() { // Save originals let orig_new = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); let orig_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); let orig_vitest = std::env::var("VITEST").ok(); + let orig_offline = std::env::var("SOCKET_OFFLINE").ok(); // Default: not disabled std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); std::env::remove_var("VITEST"); + std::env::remove_var("SOCKET_OFFLINE"); assert!(!is_telemetry_disabled()); // Disabled via new var "1" @@ -486,6 +887,26 @@ mod tests { assert!(is_telemetry_disabled()); std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", "true"); assert!(is_telemetry_disabled()); + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + + // Disabled via airgap: SOCKET_OFFLINE=1 implies "no network", + // which includes the telemetry endpoint. + std::env::set_var("SOCKET_OFFLINE", "1"); + assert!( + is_telemetry_disabled(), + "SOCKET_OFFLINE=1 must disable telemetry (airgap)" + ); + std::env::set_var("SOCKET_OFFLINE", "true"); + assert!( + is_telemetry_disabled(), + "SOCKET_OFFLINE=true must disable telemetry (airgap)" + ); + // Non-truthy values do not disable + std::env::set_var("SOCKET_OFFLINE", "0"); + assert!(!is_telemetry_disabled()); + std::env::set_var("SOCKET_OFFLINE", ""); + assert!(!is_telemetry_disabled()); + std::env::remove_var("SOCKET_OFFLINE"); // Restore originals match orig_new { @@ -500,6 +921,10 @@ mod tests { Some(v) => std::env::set_var("VITEST", v), None => std::env::remove_var("VITEST"), } + match orig_offline { + Some(v) => std::env::set_var("SOCKET_OFFLINE", v), + None => std::env::remove_var("SOCKET_OFFLINE"), + } } #[test] @@ -519,6 +944,7 @@ mod tests { #[test] fn test_event_type_as_str() { + // Write-side assert_eq!(PatchTelemetryEventType::PatchApplied.as_str(), "patch_applied"); assert_eq!( PatchTelemetryEventType::PatchApplyFailed.as_str(), @@ -537,6 +963,39 @@ mod tests { PatchTelemetryEventType::PatchRollbackFailed.as_str(), "patch_rollback_failed" ); + // Read-side + assert_eq!(PatchTelemetryEventType::PatchScanned.as_str(), "patch_scanned"); + assert_eq!( + PatchTelemetryEventType::PatchScanFailed.as_str(), + "patch_scan_failed" + ); + assert_eq!(PatchTelemetryEventType::PatchFetched.as_str(), "patch_fetched"); + assert_eq!( + PatchTelemetryEventType::PatchFetchFailed.as_str(), + "patch_fetch_failed" + ); + // Inspection / housekeeping + assert_eq!(PatchTelemetryEventType::PatchListed.as_str(), "patch_listed"); + assert_eq!( + PatchTelemetryEventType::PatchRepaired.as_str(), + "patch_repaired" + ); + assert_eq!( + PatchTelemetryEventType::PatchRepairFailed.as_str(), + "patch_repair_failed" + ); + assert_eq!(PatchTelemetryEventType::PatchSetup.as_str(), "patch_setup"); + assert_eq!( + PatchTelemetryEventType::PatchUnlocked.as_str(), + "patch_unlocked" + ); + assert_eq!( + PatchTelemetryEventType::PatchUnlockFailed.as_str(), + "patch_unlock_failed" + ); + // OpenVEX + assert_eq!(PatchTelemetryEventType::VexGenerated.as_str(), "vex_generated"); + assert_eq!(PatchTelemetryEventType::VexFailed.as_str(), "vex_failed"); } #[test] diff --git a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs index dfc64e9..aeccfee 100644 --- a/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs +++ b/crates/socket-patch-core/tests/telemetry_helpers_e2e.rs @@ -80,6 +80,99 @@ fn telemetry_disabled_legacy_socket_patch_var_honored() { } } +#[test] +#[serial] +fn telemetry_disabled_when_socket_offline_eq_1() { + // Airgap mode: SOCKET_OFFLINE=1 means "never contact the network", + // so the telemetry endpoint (which is a network call) must be + // suppressed for every command. + let prev_disabled = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); + let prev_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); + let prev_vitest = std::env::var("VITEST").ok(); + let prev_offline = std::env::var("SOCKET_OFFLINE").ok(); + std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + std::env::remove_var("VITEST"); + std::env::set_var("SOCKET_OFFLINE", "1"); + assert!( + is_telemetry_disabled(), + "SOCKET_OFFLINE=1 must disable telemetry (airgap)" + ); + std::env::remove_var("SOCKET_OFFLINE"); + if let Some(v) = prev_disabled { + std::env::set_var("SOCKET_TELEMETRY_DISABLED", v); + } + if let Some(v) = prev_legacy { + std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v); + } + if let Some(v) = prev_vitest { + std::env::set_var("VITEST", v); + } + if let Some(v) = prev_offline { + std::env::set_var("SOCKET_OFFLINE", v); + } +} + +#[test] +#[serial] +fn telemetry_disabled_when_socket_offline_eq_true() { + let prev_disabled = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); + let prev_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); + let prev_vitest = std::env::var("VITEST").ok(); + let prev_offline = std::env::var("SOCKET_OFFLINE").ok(); + std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + std::env::remove_var("VITEST"); + std::env::set_var("SOCKET_OFFLINE", "true"); + assert!( + is_telemetry_disabled(), + "SOCKET_OFFLINE=true must disable telemetry (airgap)" + ); + std::env::remove_var("SOCKET_OFFLINE"); + if let Some(v) = prev_disabled { + std::env::set_var("SOCKET_TELEMETRY_DISABLED", v); + } + if let Some(v) = prev_legacy { + std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v); + } + if let Some(v) = prev_vitest { + std::env::set_var("VITEST", v); + } + if let Some(v) = prev_offline { + std::env::set_var("SOCKET_OFFLINE", v); + } +} + +#[test] +#[serial] +fn telemetry_not_disabled_when_socket_offline_unset_or_falsy() { + // Defensive: confirm "0" and empty don't accidentally engage the gate. + let prev_disabled = std::env::var("SOCKET_TELEMETRY_DISABLED").ok(); + let prev_legacy = std::env::var("SOCKET_PATCH_TELEMETRY_DISABLED").ok(); + let prev_vitest = std::env::var("VITEST").ok(); + let prev_offline = std::env::var("SOCKET_OFFLINE").ok(); + std::env::remove_var("SOCKET_TELEMETRY_DISABLED"); + std::env::remove_var("SOCKET_PATCH_TELEMETRY_DISABLED"); + std::env::remove_var("VITEST"); + std::env::set_var("SOCKET_OFFLINE", "0"); + assert!(!is_telemetry_disabled(), "SOCKET_OFFLINE=0 must not engage gate"); + std::env::set_var("SOCKET_OFFLINE", ""); + assert!(!is_telemetry_disabled(), "SOCKET_OFFLINE='' must not engage gate"); + std::env::remove_var("SOCKET_OFFLINE"); + if let Some(v) = prev_disabled { + std::env::set_var("SOCKET_TELEMETRY_DISABLED", v); + } + if let Some(v) = prev_legacy { + std::env::set_var("SOCKET_PATCH_TELEMETRY_DISABLED", v); + } + if let Some(v) = prev_vitest { + std::env::set_var("VITEST", v); + } + if let Some(v) = prev_offline { + std::env::set_var("SOCKET_OFFLINE", v); + } +} + #[test] fn sanitize_error_message_without_home_returns_unchanged() { // No home substring means no replacement happens. diff --git a/npm/socket-patch-android-arm64/package.json b/npm/socket-patch-android-arm64/package.json index 2091d97..2a25c1a 100644 --- a/npm/socket-patch-android-arm64/package.json +++ b/npm/socket-patch-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-android-arm64", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Android ARM64", "os": [ "android" diff --git a/npm/socket-patch-darwin-arm64/package.json b/npm/socket-patch-darwin-arm64/package.json index 2c0650c..74e430d 100644 --- a/npm/socket-patch-darwin-arm64/package.json +++ b/npm/socket-patch-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-darwin-arm64", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for macOS ARM64", "os": [ "darwin" diff --git a/npm/socket-patch-darwin-x64/package.json b/npm/socket-patch-darwin-x64/package.json index 8e1add8..d6d355d 100644 --- a/npm/socket-patch-darwin-x64/package.json +++ b/npm/socket-patch-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-darwin-x64", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for macOS x64", "os": [ "darwin" diff --git a/npm/socket-patch-linux-arm-gnu/package.json b/npm/socket-patch-linux-arm-gnu/package.json index e4aca2f..500b1dd 100644 --- a/npm/socket-patch-linux-arm-gnu/package.json +++ b/npm/socket-patch-linux-arm-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm-gnu", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux ARM (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-arm-musl/package.json b/npm/socket-patch-linux-arm-musl/package.json index 2d4df19..765934f 100644 --- a/npm/socket-patch-linux-arm-musl/package.json +++ b/npm/socket-patch-linux-arm-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm-musl", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux ARM (musl)", "os": [ "linux" diff --git a/npm/socket-patch-linux-arm64-gnu/package.json b/npm/socket-patch-linux-arm64-gnu/package.json index 81cdbbf..fe4191f 100644 --- a/npm/socket-patch-linux-arm64-gnu/package.json +++ b/npm/socket-patch-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm64-gnu", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux ARM64 (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-arm64-musl/package.json b/npm/socket-patch-linux-arm64-musl/package.json index aa8e97e..c54a2a4 100644 --- a/npm/socket-patch-linux-arm64-musl/package.json +++ b/npm/socket-patch-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-arm64-musl", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux ARM64 (musl)", "os": [ "linux" diff --git a/npm/socket-patch-linux-ia32-gnu/package.json b/npm/socket-patch-linux-ia32-gnu/package.json index dc8c050..f44a47e 100644 --- a/npm/socket-patch-linux-ia32-gnu/package.json +++ b/npm/socket-patch-linux-ia32-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-ia32-gnu", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux ia32 (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-ia32-musl/package.json b/npm/socket-patch-linux-ia32-musl/package.json index e91b89e..f444e43 100644 --- a/npm/socket-patch-linux-ia32-musl/package.json +++ b/npm/socket-patch-linux-ia32-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-ia32-musl", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux ia32 (musl)", "os": [ "linux" diff --git a/npm/socket-patch-linux-x64-gnu/package.json b/npm/socket-patch-linux-x64-gnu/package.json index 86b991a..6a59a36 100644 --- a/npm/socket-patch-linux-x64-gnu/package.json +++ b/npm/socket-patch-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-x64-gnu", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux x64 (glibc)", "os": [ "linux" diff --git a/npm/socket-patch-linux-x64-musl/package.json b/npm/socket-patch-linux-x64-musl/package.json index 317f27d..e589aa2 100644 --- a/npm/socket-patch-linux-x64-musl/package.json +++ b/npm/socket-patch-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-linux-x64-musl", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Linux x64 (musl)", "os": [ "linux" diff --git a/npm/socket-patch-win32-arm64/package.json b/npm/socket-patch-win32-arm64/package.json index fbbb6b0..634cc2e 100644 --- a/npm/socket-patch-win32-arm64/package.json +++ b/npm/socket-patch-win32-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-win32-arm64", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Windows ARM64", "os": [ "win32" diff --git a/npm/socket-patch-win32-ia32/package.json b/npm/socket-patch-win32-ia32/package.json index c29bac0..0acad0f 100644 --- a/npm/socket-patch-win32-ia32/package.json +++ b/npm/socket-patch-win32-ia32/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-win32-ia32", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Windows ia32", "os": [ "win32" diff --git a/npm/socket-patch-win32-x64/package.json b/npm/socket-patch-win32-x64/package.json index c1e40b4..72920af 100644 --- a/npm/socket-patch-win32-x64/package.json +++ b/npm/socket-patch-win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch-win32-x64", - "version": "3.0.0", + "version": "3.1.0", "description": "socket-patch binary for Windows x64", "os": [ "win32" diff --git a/npm/socket-patch/package.json b/npm/socket-patch/package.json index aa7b0a2..7cfab15 100644 --- a/npm/socket-patch/package.json +++ b/npm/socket-patch/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch", - "version": "3.0.0", + "version": "3.1.0", "description": "CLI tool and schema library for applying security patches to dependencies", "bin": { "socket-patch": "bin/socket-patch" @@ -42,19 +42,19 @@ "@types/node": "20.19.41" }, "optionalDependencies": { - "@socketsecurity/socket-patch-android-arm64": "3.0.0", - "@socketsecurity/socket-patch-darwin-arm64": "3.0.0", - "@socketsecurity/socket-patch-darwin-x64": "3.0.0", - "@socketsecurity/socket-patch-linux-arm-gnu": "3.0.0", - "@socketsecurity/socket-patch-linux-arm-musl": "3.0.0", - "@socketsecurity/socket-patch-linux-arm64-gnu": "3.0.0", - "@socketsecurity/socket-patch-linux-arm64-musl": "3.0.0", - "@socketsecurity/socket-patch-linux-ia32-gnu": "3.0.0", - "@socketsecurity/socket-patch-linux-ia32-musl": "3.0.0", - "@socketsecurity/socket-patch-linux-x64-gnu": "3.0.0", - "@socketsecurity/socket-patch-linux-x64-musl": "3.0.0", - "@socketsecurity/socket-patch-win32-arm64": "3.0.0", - "@socketsecurity/socket-patch-win32-ia32": "3.0.0", - "@socketsecurity/socket-patch-win32-x64": "3.0.0" + "@socketsecurity/socket-patch-android-arm64": "3.1.0", + "@socketsecurity/socket-patch-darwin-arm64": "3.1.0", + "@socketsecurity/socket-patch-darwin-x64": "3.1.0", + "@socketsecurity/socket-patch-linux-arm-gnu": "3.1.0", + "@socketsecurity/socket-patch-linux-arm-musl": "3.1.0", + "@socketsecurity/socket-patch-linux-arm64-gnu": "3.1.0", + "@socketsecurity/socket-patch-linux-arm64-musl": "3.1.0", + "@socketsecurity/socket-patch-linux-ia32-gnu": "3.1.0", + "@socketsecurity/socket-patch-linux-ia32-musl": "3.1.0", + "@socketsecurity/socket-patch-linux-x64-gnu": "3.1.0", + "@socketsecurity/socket-patch-linux-x64-musl": "3.1.0", + "@socketsecurity/socket-patch-win32-arm64": "3.1.0", + "@socketsecurity/socket-patch-win32-ia32": "3.1.0", + "@socketsecurity/socket-patch-win32-x64": "3.1.0" } } diff --git a/pypi/socket-patch/pyproject.toml b/pypi/socket-patch/pyproject.toml index a406471..9a101b3 100644 --- a/pypi/socket-patch/pyproject.toml +++ b/pypi/socket-patch/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "socket-patch" -version = "3.0.0" +version = "3.1.0" description = "CLI tool for applying security patches to dependencies" readme = "README.md" license = "MIT"