diff --git a/src/config.rs b/src/config.rs index 5d94e27..5e4986c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,14 @@ use std::{env, fs, io}; pub const DEFAULT_VULN_API_URL: &str = "https://cve-worker-staging.corgea.workers.dev"; +/// Whether `DEFAULT_VULN_API_URL` is an endpoint Corgea tokens belong to. +/// The `-staging` hostname is historical: this worker serves as the production +/// vuln-api, so a token may be sent to it and authenticated (fail-closed) +/// verdicts apply by default. Set to `false` if the default is ever repointed +/// at an endpoint that customer tokens should not reach — token-send and +/// fail-closed then both require the explicit opt-in. +pub const DEFAULT_VULN_API_URL_IS_PRODUCTION: bool = true; + #[derive(Serialize, Deserialize, Clone)] pub struct Config { pub(crate) url: String, diff --git a/src/main.rs b/src/main.rs index 9618721..35a976a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -269,8 +269,8 @@ struct VulnApiAccess { } /// Resolve the shared vuln-api access policy once so both surfaces apply the -/// same base-URL/token-isolation rule (never send a token to a custom URL -/// without explicit opt-in). +/// same base-URL/token-isolation rule (never send a token to an endpoint it +/// does not belong to without explicit opt-in). fn resolve_vuln_api_access(config: &Config) -> VulnApiAccess { let token = config.get_token(); let token = token.trim(); @@ -297,7 +297,7 @@ fn install_wrap_options( verdict: Some(corgea::precheck::VerdictConfig { base_url: access.base_url, mode: access.mode, - public_login_hint: !access.has_token, + public_hint: Some(public_hint_for(access.has_token)), }), npm_registry: utils::generic::get_env_var_if_exists("CORGEA_NPM_REGISTRY"), pypi_registry: utils::generic::get_env_var_if_exists("CORGEA_PYPI_REGISTRY"), @@ -323,15 +323,28 @@ fn advisories_options(config: &Config) -> corgea::advisories::AdvisoriesOptions } } -/// A token enables authenticated (fail-closed) verdicts — but never against -/// a custom vuln-api URL unless the user explicitly opts in to sending the -/// token there. +/// Which public-mode disclosure to print. A withheld token is not the same +/// situation as no token, and telling a logged-in user to log in is useless. +/// Only consulted in public mode — authenticated runs print no hint. +fn public_hint_for(has_token: bool) -> corgea::precheck::PublicHint { + if has_token { + corgea::precheck::PublicHint::TokenWithheld + } else { + corgea::precheck::PublicHint::NoToken + } +} + +/// A token enables authenticated (fail-closed) verdicts — but only against a +/// vuln-api the token belongs to. That means neither a custom URL nor a +/// non-production built-in default, unless the user explicitly opts in to +/// sending the token there. fn select_verdict_mode( token: &str, custom_vuln_api_url: bool, send_token_to_custom: bool, ) -> corgea::precheck::VerdictMode { - if !token.is_empty() && (!custom_vuln_api_url || send_token_to_custom) { + let trusted_default = !custom_vuln_api_url && config::DEFAULT_VULN_API_URL_IS_PRODUCTION; + if !token.is_empty() && (trusted_default || send_token_to_custom) { corgea::precheck::VerdictMode::Authenticated { token: token.to_string(), } @@ -785,22 +798,60 @@ mod tests { fn verdict_mode_selection_matrix() { use corgea::precheck::VerdictMode; + // Built-in default: authenticated only when that default is production. + let default_mode = select_verdict_mode("token", false, false); + if config::DEFAULT_VULN_API_URL_IS_PRODUCTION { + assert_eq!( + default_mode, + VerdictMode::Authenticated { + token: "token".to_string() + } + ); + } else { + assert_eq!(default_mode, VerdictMode::Public); + } + assert_eq!(select_verdict_mode("", false, false), VerdictMode::Public); + assert_eq!( + select_verdict_mode("token", true, false), + VerdictMode::Public + ); assert_eq!( - select_verdict_mode("token", false, false), + select_verdict_mode("token", true, true), VerdictMode::Authenticated { token: "token".to_string() } ); - assert_eq!(select_verdict_mode("", false, false), VerdictMode::Public); + } + + /// A token reaches only an endpoint it belongs to. A custom vuln-api is + /// not one, so it stays public and says so — the withheld hint exists for + /// exactly that cohort. See COR-1549. + #[test] + fn withheld_token_selects_public_mode_and_withheld_hint() { + use corgea::precheck::{PublicHint, VerdictMode}; + + // token + custom URL + no opt-in assert_eq!( select_verdict_mode("token", true, false), VerdictMode::Public ); + assert_eq!(public_hint_for(true), PublicHint::TokenWithheld); + // No token is a different situation with different advice. + assert_eq!(public_hint_for(false), PublicHint::NoToken); + } + + /// The opt-in is what makes an otherwise untrusted endpoint eligible for + /// the token — and it never manufactures a token that does not exist. + #[test] + fn opt_in_enables_authenticated_for_untrusted_endpoints() { + use corgea::precheck::VerdictMode; + assert_eq!( select_verdict_mode("token", true, true), VerdictMode::Authenticated { token: "token".to_string() } ); + assert_eq!(select_verdict_mode("", true, true), VerdictMode::Public); } } diff --git a/src/precheck/mod.rs b/src/precheck/mod.rs index 8e0d06d..eb7c97c 100644 --- a/src/precheck/mod.rs +++ b/src/precheck/mod.rs @@ -144,8 +144,34 @@ impl VerdictMode { pub struct VerdictConfig { pub base_url: String, pub mode: VerdictMode, - /// Print the tokenless public-mode hint after a check is attempted. - pub public_login_hint: bool, + /// Disclosure printed after a check is attempted in public mode, so a + /// fail-open run never looks like an enforced one. `None` suppresses it. + pub public_hint: Option, +} + +/// Why the gate is running public (fail-open) checks. Both cases warn, but a +/// user who never logged in needs different advice from one whose token is +/// deliberately withheld from this endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublicHint { + /// No token at all — logging in is the next step. + NoToken, + /// A token exists but is not sent to this vuln-api, so it cannot enable + /// authenticated enforcement. + TokenWithheld, +} + +impl PublicHint { + fn message(self) -> &'static str { + match self { + PublicHint::NoToken => { + "warning: using public CVE checks; login enables authenticated enforcement and private Corgea intelligence." + } + PublicHint::TokenWithheld => { + "warning: using public CVE checks; your token is not sent to this vuln-api, so lookup failures warn instead of blocking. Set CORGEA_VULN_API_SEND_TOKEN_TO_CUSTOM_URL=1 to enable authenticated enforcement." + } + } + } } /// Threat verdict for one resolved target. @@ -845,10 +871,8 @@ fn run_parsed_install( "warning: transitive dependencies not checked ({reason}); only named packages were verified." ); } - if verdict::public_verdict(&opts).is_some_and(|cfg| cfg.public_login_hint) { - eprintln!( - "warning: using public CVE checks; login enables authenticated enforcement and private Corgea intelligence." - ); + if let Some(hint) = verdict::public_verdict(&opts).and_then(|cfg| cfg.public_hint) { + eprintln!("{}", hint.message()); } let report = PrecheckReport { @@ -877,12 +901,10 @@ fn run_locked_install( // Direct callers may still disable verdicts completely. return exec(); }; - // Same disclosure as run_parsed_install: a tokenless `npm ci`/`uv sync` - // runs public checks — say so rather than gate silently. - if verdict::public_verdict(opts).is_some_and(|cfg| cfg.public_login_hint) { - eprintln!( - "warning: using public CVE checks; login enables authenticated enforcement and private Corgea intelligence." - ); + // Same disclosure as run_parsed_install: an `npm ci`/`uv sync` running + // public checks says so rather than gating silently. + if let Some(hint) = verdict::public_verdict(opts).and_then(|cfg| cfg.public_hint) { + eprintln!("{}", hint.message()); } let jobs = match lock { Ok(jobs) => jobs, diff --git a/src/precheck/test_support.rs b/src/precheck/test_support.rs index 9341649..2b2d277 100644 --- a/src/precheck/test_support.rs +++ b/src/precheck/test_support.rs @@ -32,7 +32,7 @@ pub(crate) fn verdict_opts(base_url: &str) -> PrecheckOptions { mode: VerdictMode::Authenticated { token: "test-token".to_string(), }, - public_login_hint: false, + public_hint: None, }), ..stub_opts() } @@ -44,7 +44,7 @@ pub(crate) fn public_opts(force: bool) -> PrecheckOptions { verdict: Some(VerdictConfig { base_url: "http://127.0.0.1:9".to_string(), mode: VerdictMode::Public, - public_login_hint: true, + public_hint: Some(super::PublicHint::NoToken), }), ..stub_opts() } diff --git a/src/precheck/verdict.rs b/src/precheck/verdict.rs index 26e9e7f..1746caf 100644 --- a/src/precheck/verdict.rs +++ b/src/precheck/verdict.rs @@ -786,7 +786,7 @@ mod tests { mode: super::super::VerdictMode::Authenticated { token: "test-token".to_string(), }, - public_login_hint: false, + public_hint: None, }; let jobs: Vec = ["a", "b", "evil", "c", "d", "e"] diff --git a/tests/cli_verdict.rs b/tests/cli_verdict.rs index 9f685b9..228fb24 100644 --- a/tests/cli_verdict.rs +++ b/tests/cli_verdict.rs @@ -275,6 +275,57 @@ fn tokenless_public_check_discloses_mode() { ); } +#[test] +fn withheld_token_discloses_public_mode_and_names_opt_in() { + // The cohort this PR creates: a token exists, but it is not sent to this + // vuln-api, so verdicts stay public and lookup failures warn. The old hint + // keyed off "has a token" and went silent here. + let mut checks = HashMap::new(); + checks.insert( + key("pypi", "oldpkg", "1.0.0"), + vulnerable_body("pypi", "oldpkg", "1.0.0", "CVE-2024-0001", Some("2.0.0")), + ); + let mut h = pip_harness(checks, HashMap::new(), Some("opaque-token"), 0); + let out = h + .cmd + .args(["pip", "install", "oldpkg==1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("your token is not sent to this vuln-api"), + "a withheld token must be disclosed, not silently downgraded: {stderr}" + ); + assert!( + stderr.contains("CORGEA_VULN_API_SEND_TOKEN_TO_CUSTOM_URL"), + "withheld-token warning must name the opt-in: {stderr}" + ); + assert!( + !stderr.contains("login enables"), + "a logged-in user must not be told to log in: {stderr}" + ); +} + +#[test] +fn withheld_token_reports_public_verdict_mode_in_json() { + let mut checks = HashMap::new(); + checks.insert( + key("pypi", "oldpkg", "1.0.0"), + vulnerable_body("pypi", "oldpkg", "1.0.0", "CVE-2024-0001", Some("2.0.0")), + ); + let mut h = pip_harness(checks, HashMap::new(), Some("opaque-token"), 0); + let out = h + .cmd + .args(["pip", "--json", "install", "oldpkg==1.0.0"]) + .output() + .expect("run corgea"); + assert_eq!(out.status.code(), Some(1)); + let parsed: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON"); + assert_eq!(parsed["verdict_mode"], "public"); +} + #[test] fn custom_vuln_api_url_with_token_does_not_send_token_by_default() { let (base_url, requests) = spawn_capturing_vuln_api_stub();