Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ mod utils {
pub mod terminal;
}
mod targets;
mod version_check;

use clap::{CommandFactory, Parser, Subcommand};
use config::Config;
Expand Down Expand Up @@ -520,7 +521,7 @@ fn main() {
}
utils::api::set_auth_token(&config.get_token());
match utils::api::verify_token(config.get_url().as_str()) {
Ok(true) => {}
Ok(true) => version_check::warn_if_webapp_outdated(config.get_url().as_str()),
Ok(false) => {
println!("Invalid token provided.\nPlease run 'corgea login' to authenticate.\nFor more info checkout our docs at Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli");
std::process::exit(1);
Expand Down
72 changes: 72 additions & 0 deletions src/utils/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ use std::path::Path;

const CHUNK_SIZE: usize = 50 * 1024 * 1024; // 50 MB
const API_BASE: &str = "/api/v1";
/// Unversioned on purpose: the webapp serves its own version outside the
/// versioned API so clients can read it before agreeing on an API version.
const WEBAPP_VERSION_PATH: &str = "/api/version";

/// Far below the shared client's timeout, because this request is advisory and
/// runs ahead of every authenticated command: a deployment that stalls this one
/// route must not hold the command the user actually asked for.
const WEBAPP_VERSION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);

fn auth_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
Expand Down Expand Up @@ -122,6 +130,14 @@ impl DebugRequestBuilder {
}
}

/// Override the shared client's timeout for this request alone.
pub fn timeout(self, timeout: std::time::Duration) -> Self {
Self {
inner: self.inner.timeout(timeout),
client: self.client,
}
}

pub fn send(self) -> reqwest::Result<reqwest::blocking::Response> {
use reqwest::cookie::CookieStore;

Expand Down Expand Up @@ -1181,6 +1197,55 @@ pub fn verify_token(corgea_url: &str) -> Result<bool, Box<dyn Error>> {
}
}

/// The version the webapp at `corgea_url` reports for itself.
///
/// `Ok(None)` means the deployment did not tell us: either it predates
/// `/api/version` (404) or it could not determine its own version (null).
///
/// A 404 is not evidence of an outdated webapp and callers must not treat it as
/// one. The endpoint ships in a release *above* `MIN_WEBAPP_VERSION`, so every
/// deployment sitting exactly at the floor answers 404 while being perfectly
/// compatible. A 404 only narrows the version to "older than the release that
/// added this route", which spans both sides of the floor.
///
/// Deliberately skips `check_for_warnings`: this runs as a pre-flight before
/// the command the user asked for, and it must not be what terminates the
/// process. The command's own requests still surface deprecation signals.
pub fn get_webapp_version(corgea_url: &str) -> Result<Option<String>, Box<dyn Error>> {
let url = format!("{}{}", corgea_url, WEBAPP_VERSION_PATH);
let client = http_client();
debug(&format!("Sending request to URL: {}", url));

let response = client.get(&url).timeout(WEBAPP_VERSION_TIMEOUT).send()?;
let status = response.status();

if status == StatusCode::NOT_FOUND {
Comment thread
Ibrahimrahhal marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The documented rollout makes the default check unable to warn any normally-versioned outdated deployment. This code says /api/version first ships in a release above v1.71.3: therefore every real release below the v1.71.3 floor takes this 404 → Ok(None) path and stays silent, while every release new enough to have the endpoint is already above the floor and also stays silent. The passing outdated test only works by giving a server with the new endpoint the artificial older value v1.70.0, a state the stated release sequence does not produce. As shipped, the users this task targets still receive no warning. Preserve 404 as a distinct result and emit an accurately worded “compatibility could not be verified; this CLI requires v1.71.3+” warning (the existing skip variable can suppress it), or ensure the endpoint is deployed/backported at or below the minimum before enabling this floor.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this finding and think it should be addressed.

high: Default check cannot detect normally deployed outdated webapps

The endpoint first ships above v1.71.3, while v1.71.3 is the minimum. Thus every normal release below the minimum returns 404 and is treated as unknown, while releases exposing the endpoint are already above the minimum. The outdated test models an artificial v1.70.0 deployment that nevertheless exposes the newer endpoint. Delay enabling this floor until the endpoint release is at or below the minimum, or distinguish 404 and provide an accurate unverifiable-compatibility warning.

Proof or reproduction:

// Current behavior for every normally deployed version below v1.71.3:
if status == StatusCode::NOT_FOUND {
    return Ok(None);
}
// warn_if_webapp_outdated then returns without warning on Ok(None).

debug("Webapp does not serve /api/version");
return Ok(None);
}

if !status.is_success() {
return Err(format!("Request failed with status: {}", status).into());
}

let body_text = response.text()?;
let body: WebappVersionResponse = match serde_json::from_str(&body_text) {
Ok(json) => json,
Err(e) => {
debug(&format!(
"Failed to parse response as JSON: {}. Response body: {}",
e, body_text
));
return Err("Failed to parse response".to_string().into());
}
};

Ok(body
.version
.map(|version| version.trim().to_string())
.filter(|version| !version.is_empty()))
}

/// Evaluate a scan against blocking rules.
///
/// `block_on` is a comma-separated list of CI rule slugs. When omitted the
Expand Down Expand Up @@ -1351,6 +1416,13 @@ pub fn get_all_sca_issues(
Ok(all_issues)
}

#[derive(Deserialize, Serialize, Debug)]
pub struct WebappVersionResponse {
/// Null on deployments that cannot determine their own version.
#[serde(default)]
pub version: Option<String>,
}

#[derive(Deserialize, Serialize, Debug)]
pub struct ScanResponse {
pub id: String,
Expand Down
214 changes: 214 additions & 0 deletions src/version_check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Compatibility pre-flight: warn when the Corgea webapp a command is about to
//! talk to is older than this CLI expects.
//!
//! Best effort throughout. A webapp that does not report its version, an
//! unreachable endpoint, or an unparsable version all leave the command to run
//! exactly as before — the check only ever adds a warning.

use crate::log::debug;
use crate::utils::api;
use crate::utils::generic::get_env_var_if_exists;
use regex::Regex;
use semver::Version;
use std::sync::LazyLock;

/// Oldest webapp release this CLI is built against. Raise it whenever the CLI
/// starts depending on a webapp change.
pub const MIN_WEBAPP_VERSION: &str = "v1.71.3";

/// Overrides `MIN_WEBAPP_VERSION`, for testing a different floor without a
/// rebuild.
const MIN_VERSION_ENV_VAR: &str = "CORGEA_MIN_WEBAPP_VERSION";

/// Escape hatch for anyone deliberately pinned to an older self-hosted webapp
/// who does not want the warning on every command.
const SKIP_ENV_VAR: &str = "CORGEA_SKIP_WEBAPP_VERSION_CHECK";

/// Deployment versions are not plain semver: releases ship as `v1.71.3`, while
/// pre-release and per-customer builds add a suffix (`v1.71.3-beta`,
/// `v1.71.3-client-a`). Only the leading numeric part is comparable, so a
/// suffixed build counts as the release it was cut from rather than sorting
/// below it the way semver pre-release ordering would.
///
/// Anchored, and the numbers must end the string or start a suffix. Matching a
/// dotted number anywhere would read something like `build-2026.08-v1.70.0` as
/// `2026.8.0` and call an outdated webapp current; a shape we do not recognize
/// has to be unknown instead.
static NUMERIC_VERSION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^v?(\d+)\.(\d+)(?:\.(\d+))?(?:$|[-+])").expect("valid version regex")
});

/// The `major.minor.patch` numbers leading `raw`, or `None` when it is not a
/// shape we recognize. A missing patch reads as `0`, so `v1.71` and `v1.71.0`
/// compare equal.
pub fn extract_version(raw: &str) -> Option<Version> {
let captures = NUMERIC_VERSION.captures(raw.trim())?;
let number = |group: usize| match captures.get(group) {
Some(digits) => digits.as_str().parse::<u64>().ok(),
None => Some(0),
};
Some(Version::new(number(1)?, number(2)?, number(3)?))
}

/// The warning to show for a webapp running `webapp_version`, or `None` when it
/// is new enough or when either version has no numbers to compare.
pub fn outdated_warning(corgea_url: &str, webapp_version: &str, minimum: &str) -> Option<String> {
let running = extract_version(webapp_version)?;
let required = extract_version(minimum)?;

if running >= required {
return None;
}

Some(format!(
"Warning: this Corgea CLI (v{cli}) requires Corgea webapp {minimum} or newer, \
but {corgea_url} is running {webapp_version}. Commands may fail or return \
incomplete results until the webapp is upgraded. \
Set {SKIP_ENV_VAR}=1 to silence this warning.",
cli = env!("CARGO_PKG_VERSION"),
))
}

/// The version floor to enforce: `CORGEA_MIN_WEBAPP_VERSION`, else the built-in
/// `MIN_WEBAPP_VERSION`.
fn min_webapp_version() -> String {
get_env_var_if_exists(MIN_VERSION_ENV_VAR).unwrap_or_else(|| MIN_WEBAPP_VERSION.to_string())
}

fn check_disabled() -> bool {
matches!(
get_env_var_if_exists(SKIP_ENV_VAR)
.map(|value| value.trim().to_ascii_lowercase())
.as_deref(),
Some("1") | Some("true") | Some("yes")
)
}

/// Warn on stderr when the webapp at `corgea_url` is older than this CLI needs.
/// Call before running the command the user asked for.
pub fn warn_if_webapp_outdated(corgea_url: &str) {
if check_disabled() {
debug("Webapp version check disabled");
return;
}

let webapp_version = match api::get_webapp_version(corgea_url) {
Ok(Some(version)) => version,
// Includes a 404 from a webapp predating the endpoint. Warning on that
// would flag every deployment sitting exactly at the floor, since the
// endpoint ships above it -- see `api::get_webapp_version`.
Ok(None) => {
debug("Webapp did not report a version; skipping compatibility check");
return;
}
Err(e) => {
debug(&format!("Failed to read the webapp version: {}", e));
return;
}
};

debug(&format!("Webapp reports version {}", webapp_version));

if let Some(warning) = outdated_warning(corgea_url, &webapp_version, &min_webapp_version()) {
log::warn!("{}", warning);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extracts_the_numbers_from_release_and_suffixed_versions() {
assert_eq!(extract_version("v1.71.3"), Some(Version::new(1, 71, 3)));
assert_eq!(extract_version("1.71.3"), Some(Version::new(1, 71, 3)));
assert_eq!(
extract_version("v1.71.3-beta"),
Some(Version::new(1, 71, 3))
);
assert_eq!(
extract_version("v1.71.3-client-a"),
Some(Version::new(1, 71, 3))
);
assert_eq!(
extract_version("v1.71.3-main-a1b2c3d"),
Some(Version::new(1, 71, 3))
);
}

#[test]
fn a_missing_patch_reads_as_zero() {
assert_eq!(extract_version("v1.71"), Some(Version::new(1, 71, 0)));
assert_eq!(extract_version("v2"), None);
}

#[test]
fn versions_without_numbers_are_not_comparable() {
assert_eq!(extract_version(""), None);
assert_eq!(extract_version("unknown"), None);
assert_eq!(extract_version("main"), None);
}

#[test]
fn a_version_buried_in_another_string_is_unknown() {
// Matching a dotted number anywhere would read the date here as
// 2026.8.0 and call this outdated webapp current.
assert_eq!(extract_version("build-2026.08-v1.70.0"), None);
assert_eq!(extract_version("corgea 1.70.0"), None);
assert_eq!(extract_version("1.71.3.4"), None);
}

#[test]
fn surrounding_whitespace_is_tolerated() {
assert_eq!(extract_version(" v1.71.3\n"), Some(Version::new(1, 71, 3)));
}

#[test]
fn warns_only_when_the_webapp_is_behind_the_minimum() {
let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3");

assert!(warn("v1.71.2").is_some());
assert!(warn("v1.70.9").is_some());
assert!(warn("v0.9.0").is_some());
assert!(warn("v1.71.3").is_none());
assert!(warn("v1.71.4").is_none());
assert!(warn("v2.0.0").is_none());
}

#[test]
fn a_suffixed_build_counts_as_the_release_it_was_cut_from() {
// Plain semver sorts `1.71.3-beta` below `1.71.3`; comparing only the
// numeric part deliberately does not.
let warn = |version: &str| outdated_warning("https://corgea.test", version, "v1.71.3");

assert!(warn("v1.71.3-beta").is_none());
assert!(warn("v1.71.3-client-a").is_none());
assert!(warn("v1.71.2-client-a").is_some());
}

#[test]
fn unreadable_versions_never_warn() {
assert!(outdated_warning("https://corgea.test", "unknown", "v1.71.3").is_none());
assert!(outdated_warning("https://corgea.test", "v1.0.0", "not-a-version").is_none());
}

#[test]
fn the_warning_names_the_instance_the_minimum_and_the_escape_hatch() {
let warning = outdated_warning("https://corgea.test", "v1.70.0", "v1.71.3")
.expect("an outdated webapp warns");

assert!(warning.contains("v1.71.3"), "{warning}");
assert!(warning.contains("v1.70.0"), "{warning}");
assert!(warning.contains("https://corgea.test"), "{warning}");
assert!(warning.contains(env!("CARGO_PKG_VERSION")), "{warning}");
assert!(warning.contains(SKIP_ENV_VAR), "{warning}");
}

#[test]
fn the_default_minimum_is_a_readable_version() {
assert_eq!(
extract_version(MIN_WEBAPP_VERSION),
Some(Version::new(1, 71, 3))
);
}
}
Loading
Loading