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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<action>` (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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

Expand Down
28 changes: 25 additions & 3 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,

Expand All @@ -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,

Expand All @@ -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,

Expand All @@ -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,

Expand All @@ -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,

Expand All @@ -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,

Expand All @@ -163,18 +174,29 @@ 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.
#[arg(
long = "no-telemetry",
env = "SOCKET_TELEMETRY_DISABLED",
default_value_t = false,
value_parser = clap::builder::BoolishValueParser::new(),
)]
pub no_telemetry: bool,
}
Expand Down
125 changes: 119 additions & 6 deletions crates/socket-patch-cli/src/commands/get.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand All @@ -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;
Expand All @@ -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:<eco>/...` 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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading