Skip to content
Closed
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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ actual CLI never drift, so it is always authoritative:
| `--source-fallback` | `ZED_PKG_SOURCE_FALLBACK` | on; retry public R2 and GitHub when the HTTP registry is down (`file://` and loopback stay hermetic) |
| (env only) | `ZED_PKG_SOURCE_FALLBACK_ALLOW_LOOPBACK` | off; test-org canaries that bind mocks to `127.0.0.1` must set this |
| `--home` | `ZED_PKG_HOME` | `~/.zed-pkg` |
| `--token` | `ZED_PKG_TOKEN` | saved credentials |
| (env only; secret) | `ZED_PKG_TOKEN` | saved credentials |
| `--auth-url` | `ZED_PKG_AUTH_URL` | `<registry>/shared-auth` |
| `--supabase-url` | `ZED_PKG_SUPABASE_URL` | optional Supabase project URL |
| `--supabase-key` | `ZED_PKG_SUPABASE_KEY` | optional public publishable/anon key |
Expand Down Expand Up @@ -540,6 +540,14 @@ actual CLI never drift, so it is always authoritative:
`--registry file:///path` selects a directory-backed registry: hermetic CI,
air-gapped mirrors, and the default `zed r2g` mode all use it.

Secret-bearing values never have a flag, because argv is visible to `ps`,
process accounting, and shell history. The registry bearer token is read only
from `ZED_PKG_TOKEN` (or saved credentials) and the auth password only from
`ZED_PKG_AUTH_PASSWORD`, `--password-stdin`, or the hidden prompt. The removed
spellings `--token` and `--zed-pkg-auth-password` fail closed with exit code 2
and a redacted error before any dispatch; there is no warn-and-accept window.
Both names are inventoried in `[env].ignore` of every affected `.*cli-flags.toml`.

## Authentication

`zed login` and `zed auth login` are the same operation. With
Expand Down Expand Up @@ -651,7 +659,8 @@ zed r2g --docker --image node:22-slim # pick an image with the runtime you nee
# Certify a disposable Rust registry reached through a port-forward. Publishing
# is persistent from the registry's point of view: an identical retry reuses
# the immutable version, while changed bytes require a new version or a reset.
zed --registry http://127.0.0.1:48080 --token "$ZED_PKG_TOKEN" \
# The bearer token comes from the exported ZED_PKG_TOKEN, never from argv.
zed --registry http://127.0.0.1:48080 \
r2g --registry-mode server --clean
```

Expand Down
70 changes: 64 additions & 6 deletions src/external_subcommands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ const EXTERNAL_PREFIX: &str = "zed-";
const KNOWN_EXTERNAL_COMMAND: &str = "gitops";
const EXTERNAL_COMMAND_ENV: &str = "ZED_EXTERNAL_SUBCOMMAND";

/// Root value options lifted into the child environment. Secret-bearing values
/// (`ZED_PKG_TOKEN`, `ZED_PKG_AUTH_PASSWORD`) are deliberately absent: they are
/// environment/session/stdin-only and are inherited from the parent process
/// environment, never translated from argv.
const ROOT_VALUE_OPTIONS: &[(&str, &str)] = &[
("--registry", "ZED_PKG_REGISTRY"),
("--home", "ZED_PKG_HOME"),
("--token", "ZED_PKG_TOKEN"),
("--auth-url", "ZED_PKG_AUTH_URL"),
("--supabase-url", "ZED_PKG_SUPABASE_URL"),
("--supabase-key", "ZED_PKG_SUPABASE_KEY"),
Expand All @@ -33,6 +36,20 @@ const ROOT_BOOLEAN_OPTIONS: &[(&str, &str)] = &[
("--git-submodules", "ZED_PKG_GIT_SUBMODULES"),
];

/// Removed secret-bearing spellings. Root startup already rejects these; the
/// router independently refuses to build a route so a secret can never reach a
/// child's argv or environment even if that ordering changes.
const REJECTED_SECRET_OPTIONS: &[&str] = &["--token", "--zed-pkg-auth-password"];

fn is_rejected_secret_spelling(token: &str) -> bool {
REJECTED_SECRET_OPTIONS.iter().any(|option| {
token == *option
|| token
.strip_prefix(option)
.is_some_and(|tail| tail.starts_with('='))
})
}

type ExternalEnvironment = Vec<(OsString, OsString)>;
type ParsedExternalArguments = (Vec<OsString>, ExternalEnvironment);

Expand Down Expand Up @@ -136,7 +153,9 @@ fn external_route(args: &[OsString]) -> Option<ExternalRoute> {

while index < args.len() {
let token = args[index].to_str()?;
if matches!(token, "--" | "--help" | "-h" | "--version" | "-V") {
if matches!(token, "--" | "--help" | "-h" | "--version" | "-V")
|| is_rejected_secret_spelling(token)
{
return None;
}

Expand Down Expand Up @@ -226,6 +245,9 @@ fn extract_root_options(args: &[OsString]) -> Option<ParsedExternalArguments> {
arguments.extend_from_slice(&args[index..]);
break;
}
if is_rejected_secret_spelling(token) {
return None;
}

if let Some((key, inline)) = root_value_option(token) {
let (value, consumed) = match inline {
Expand Down Expand Up @@ -486,8 +508,8 @@ mod tests {
"zed",
"gitops",
"validate",
"--token",
"fixture-value",
"--registry",
"https://registry.example.invalid",
"--offline",
"--",
"--home",
Expand All @@ -501,12 +523,48 @@ mod tests {
assert_eq!(
route.environment,
vec![(
OsString::from("ZED_PKG_TOKEN"),
OsString::from("fixture-value")
OsString::from("ZED_PKG_REGISTRY"),
OsString::from("https://registry.example.invalid")
)]
);
}

#[test]
fn secret_argv_spellings_never_become_a_route_or_child_environment() {
let secret = "SYNTHETIC_ROUTER_SECRET";
let inline_token = format!("--token={secret}");
let inline_password = format!("--zed-pkg-auth-password={secret}");
let cases: [&[&str]; 6] = [
&["zed", "--token", secret, "gitops", "validate"],
&["zed", inline_token.as_str(), "gitops", "validate"],
&["zed", "gitops", "validate", "--token", secret],
&["zed", "gitops", "validate", inline_token.as_str()],
&[
"zed",
"--zed-pkg-auth-password",
secret,
"gitops",
"validate",
],
&["zed", "gitops", "validate", inline_password.as_str()],
];
for case in cases {
assert!(external_route(&os_args(case)).is_none(), "routed {case:?}");
}
assert!(
ROOT_VALUE_OPTIONS
.iter()
.all(|(_, env)| !matches!(*env, "ZED_PKG_TOKEN" | "ZED_PKG_AUTH_PASSWORD")),
"secret-bearing env must never be lifted from argv"
);
// Child-owned arguments after `--` stay opaque to the router.
let route = external_route(&os_args(&["zed", "gitops", "validate", "--", "--token"]));
assert_eq!(
route.map(|route| (route.arguments, route.environment)),
Some((os_args(&["validate", "--", "--token"]), Vec::new()))
);
}

#[test]
fn malformed_trailing_root_boolean_fails_closed() {
assert!(
Expand Down
2 changes: 1 addition & 1 deletion src/graph_export/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pub(super) fn graph_url(
);
ensure!(
url.username().is_empty() && url.password().is_none(),
"registry URL may not embed credentials; use --token or ZED_PKG_TOKEN"
"registry URL may not embed credentials; set ZED_PKG_TOKEN in the environment"
);
ensure!(
url.query().is_none() && url.fragment().is_none(),
Expand Down
2 changes: 1 addition & 1 deletion src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3669,7 +3669,7 @@ pub fn login(cfg: &Config) -> Result<()> {
}
};
if token.is_empty() {
bail!("no token provided (pass --token, set ZED_PKG_TOKEN, or type one)");
bail!("no token provided (set ZED_PKG_TOKEN in the environment or type one)");
}
let mut credentials = Credentials::load(&cfg.home)?;
credentials.set_token(&cfg.registry, token);
Expand Down
24 changes: 24 additions & 0 deletions tests/secret_argv_boundary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,27 @@ fn early_dispatch_does_not_bypass_rejected_secret_argv() {
assert_secret_argv_rejected(&args, secret, env, option);
}
}

/// User-facing guidance must not advertise the removed secret flags as a
/// supported spelling; rejection tests and the rejection message may name them.
#[test]
fn docs_and_diagnostics_do_not_advertise_removed_secret_flags() {
const SURFACES: &[&str] = &["README.md", "src/ops.rs", "src/graph_export/download.rs"];
const ADVERTISED: &[&str] = &[
"| `--token` |",
"--token \"$",
"pass --token",
"use --token",
"--zed-pkg-auth-password \"$",
];
for file in SURFACES {
let text =
fs::read_to_string(file).unwrap_or_else(|error| panic!("reading {file}: {error}"));
for pattern in ADVERTISED {
assert!(
!text.contains(pattern),
"{file} still advertises removed secret argv spelling {pattern:?}"
);
}
}
}
Loading